diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 98f242f7a9..54146674fa 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -54,16 +54,6 @@ jobs:
path: |
app/libs/libcore.aar
key: ${{ hashFiles('.github/workflows/*', 'golang_status', 'libcore_status') }}
- - name: HevTun Status
- run: |
- git submodule status hev-socks5-tunnel > hev_status
- cat buildScript/compile-hevtun.sh | sha1sum >> hev_status
- - name: HevTun Cache
- uses: actions/cache@v5
- with:
- path: |
- app/src/main/jniLibs
- key: hevtun-${{ hashFiles('.github/workflows/*', 'hev_status') }}
- name: Gradle cache
uses: actions/cache@v5
with:
@@ -84,5 +74,5 @@ jobs:
- name: Upload Artifacts
uses: actions/upload-artifact@v7
with:
- name: NekoBoxs
+ name: Asteria
path: ${{ env.APK }}
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 31a00ba003..a572a491a6 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -64,16 +64,6 @@ jobs:
path: |
app/libs/libcore.aar
key: ${{ hashFiles('.github/workflows/*', 'golang_status', 'libcore_status') }}-ci
- - name: HevTun Status
- run: |
- git submodule status hev-socks5-tunnel > hev_status
- cat buildScript/compile-hevtun.sh | sha1sum >> hev_status
- - name: HevTun Cache
- uses: actions/cache@v5
- with:
- path: |
- app/src/main/jniLibs
- key: hevtun-${{ hashFiles('.github/workflows/*', 'hev_status') }}-ci
- name: Gradle cache
uses: actions/cache@v5
with:
diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml
index e1099b9265..a0b999d250 100644
--- a/.github/workflows/preview.yml
+++ b/.github/workflows/preview.yml
@@ -50,16 +50,6 @@ jobs:
path: |
app/libs/libcore.aar
key: ${{ hashFiles('.github/workflows/*', 'golang_status', 'libcore_status') }}
- - name: HevTun Status
- run: |
- git submodule status hev-socks5-tunnel > hev_status
- cat buildScript/compile-hevtun.sh | sha1sum >> hev_status
- - name: HevTun Cache
- uses: actions/cache@v5
- with:
- path: |
- app/src/main/jniLibs
- key: hevtun-${{ hashFiles('.github/workflows/*', 'hev_status') }}
- name: Gradle cache
uses: actions/cache@v5
with:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 84ca1fa004..c5a5d91ccf 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -56,16 +56,6 @@ jobs:
path: |
app/libs/libcore.aar
key: ${{ hashFiles('.github/workflows/*', 'golang_status', 'libcore_status') }}
- - name: HevTun Status
- run: |
- git submodule status hev-socks5-tunnel > hev_status
- cat buildScript/compile-hevtun.sh | sha1sum >> hev_status
- - name: HevTun Cache
- uses: actions/cache@v5
- with:
- path: |
- app/src/main/jniLibs
- key: hevtun-${{ hashFiles('.github/workflows/*', 'hev_status') }}
- name: Gradle cache
uses: actions/cache@v5
with:
diff --git a/.gitignore b/.gitignore
index c9cec16fe6..be3415e12b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -18,4 +18,28 @@ jniLibs/
# submodules
/external
-.hev-build/
+
+# Local logs and test dumps
+*.log
+/nekobox_*.json
+.superpowers/
+
+# Private configuration exports: local only, never include in source uploads.
+**/nekobox_*.json
+**/*备份*
+**/*[Bb]ackup*.json
+**/*[Bb]ackup*.zip
+/A[0-9]*.yaml
+/A[0-9]*.json
+
+# Keystores and signing credentials
+*.keystore
+*.jks
+
+# Development scratch / checkpoint dumps (config exports, one-off scripts)
+/scratch/
+scratch/
+*.md.local
+
+# gomobile-matsuri source installed by libcore/init.sh
+/libcore/gomobile/
diff --git a/.gitmodules b/.gitmodules
deleted file mode 100644
index b8f4bdc32d..0000000000
--- a/.gitmodules
+++ /dev/null
@@ -1,3 +0,0 @@
-[submodule "hev-socks5-tunnel"]
- path = hev-socks5-tunnel
- url = https://github.com/heiher/hev-socks5-tunnel
diff --git a/CODEBUDDY_GO_TUN_REVIEW_AND_IMPROVEMENT.md b/CODEBUDDY_GO_TUN_REVIEW_AND_IMPROVEMENT.md
new file mode 100644
index 0000000000..31ffd2defb
--- /dev/null
+++ b/CODEBUDDY_GO_TUN_REVIEW_AND_IMPROVEMENT.md
@@ -0,0 +1,598 @@
+# NekoBoxForAndroid `router-groups-go-tun` 改进任务
+
+目标仓库:`https://github.com/Gitefy/NekoBoxForAndroid`
+
+目标分支:`router-groups-go-tun`
+
+基线分支:`router-groups`
+
+当前 Go TUN 分支提交:`c841034de16e24ee66e7928017458ccb1a29b0dd`
+
+当前 sing-box 固定提交:`cf69a007cd45311fbd5d7bafe39e5eda3267089f` (`Add go TUN stack`)
+
+当前 sing-tun:`v0.9.1-0.20260908051822-65ba32b917d5`
+
+---
+
+## 0. 总原则
+
+这是一次 **sing-box 1.15 / Go TUN 核心升级**,不要顺便做无关性能优化或 UI 重构。
+
+优先级顺序:
+
+1. 保证原有功能不回归。
+2. 保证 Go TUN 真正启用并可回退。
+3. 保证可编译、可安装、可真机运行。
+4. 最后再做性能优化。
+
+禁止为了“先编译通过”把已有功能改成 no-op、固定返回 0、默认直连或静默删除协议支持。
+
+所有修改分成小提交,每个提交只解决一类问题。
+
+---
+
+# P0:必须先修复的回归
+
+## 1. 修复 URL Test / Router Groups 测速路径
+
+当前 `libcore/box.go` 中,因为旧 `boxapi.CreateProxyHttpClient` 被 sing-box 1.15 移除,代码临时改成了:
+
+```go
+speedtest.UrlTest(http.DefaultClient, ...)
+```
+
+这是不可接受的临时兼容方案。
+
+原因:
+
+- `http.DefaultClient` 走系统直连;
+- 它没有通过待测试的 sing-box Box / outbound;
+- Router Group 的 URL Test、节点延迟测试、自动选择可能得到错误结果;
+- 直连能访问时会产生“所有节点都正常”的假象;
+- 直连不能访问时又可能产生“所有节点失败”的假象。
+
+### 任务
+
+研究 sing-box 1.15 当前 API,重新实现一个 **真正通过指定 Box/outbound 路由的 HTTP client**。
+
+优先寻找上游已有实现或等价 API,不要自行重造复杂拨号栈。
+
+检查:
+
+- `experimental/v2rayapi`
+- router / outbound manager
+- dialer / connection manager
+- libbox 中 URLTest 或 HTTP client 的实现
+- sing-box for Android 当前版本如何做 URL Test
+
+### 验收
+
+至少验证:
+
+1. 测试节点 A 时流量确实经过节点 A;
+2. 测试节点 B 时流量确实经过节点 B;
+3. direct test 与 proxy test 行为不同且符合预期;
+4. Router Group 自动测试/选择恢复正常;
+5. 禁止继续使用 `http.DefaultClient` 作为代理节点 URL Test 的正式实现。
+
+---
+
+## 2. 恢复流量统计能力
+
+当前升级代码把:
+
+- `SetV2rayStats`
+- `QueryStats`
+
+改成了 no-op / 固定返回 0。
+
+这属于明确功能回归。
+
+### 任务
+
+调查 sing-box 1.15 中原 `boxapi` stats 的迁移位置。
+
+优先检查:
+
+```text
+experimental/v2rayapi
+```
+
+以及当前上游 Clash API / V2Ray API / connection tracker 的实现。
+
+重新实现:
+
+- Stats service 初始化;
+- 指定 outbound 流量跟踪;
+- `QueryStats()`;
+- 原有 gomobile ABI 尽量保持不变。
+
+### 验收
+
+如果 UI 原来能显示流量,则升级后必须继续显示真实数据,而不是永久为 0。
+
+如果确认该功能已经彻底无法用原方式实现,不允许静默 no-op:必须输出技术报告说明替代方案和影响,再等待确认。
+
+---
+
+## 3. 恢复 ResetAllConnections
+
+当前:
+
+```go
+ResetAllConnections(system bool)
+```
+
+在 sing-box 1.15 升级后被改成日志 + no-op。
+
+### 任务
+
+检查 sing-box 1.15 对连接管理的替代 API,例如:
+
+- trafficcontrol
+- connection manager
+- outbound interrupt / reset
+- router connection tracking
+
+恢复真正关闭/重置连接的能力。
+
+不要为了 ABI 保留而留下假实现。
+
+### 验收
+
+调用 ResetAllConnections 后,已有 TCP/UDP 连接应真正重建,而不仅是打印日志。
+
+---
+
+## 4. 检查 WireGuard 支持是否被意外删除
+
+`nekoboxAndroidOutboundRegistry()` 中原 WireGuard 注册被删除。
+
+sing-box 新版本可能已经把 WireGuard 从传统 outbound 迁移成 endpoint,因此不要机械把旧代码加回来。
+
+### 任务
+
+确认 sing-box 1.15 的 WireGuard 架构:
+
+- 是否已经从 outbound 迁移到 endpoint;
+- NekoBox 当前 WireGuard bean/config 是否已经同步新 schema;
+- endpoint registry 是否注册 WireGuard;
+- 旧用户配置是否可以迁移;
+- WireGuard 节点能否真正启动。
+
+### 验收
+
+项目 README 声明支持 WireGuard,则升级后不得因为核心升级而静默失效。
+
+---
+
+## 5. 检查 ShadowsocksR 支持
+
+升级中 `shadowsocksr.RegisterOutbound()` 被删除。
+
+### 任务
+
+确认 sing-box 1.15 是否彻底移除了上游 SSR。
+
+如果 NekoBox 仍计划支持 SSR:
+
+- 查明原项目通过什么 fork/registry 提供;
+- 寻找兼容 sing 0.9.x / sing-box 1.15 的实现;
+- 不要只为了编译通过删除功能。
+
+如果确实暂时不能兼容:
+
+- 必须明确标注当前分支 SSR 不可用;
+- 不要在 UI/README 中继续假装可用;
+- 给出后续恢复方案。
+
+但在没有确认前,不要删除用户已有 SSR 配置数据。
+
+---
+
+## 6. 检查被禁用的协议 fork
+
+`libcore/go.mod` 中原来的:
+
+```text
+replace github.com/sagernet/sing-vmess => github.com/starifly/sing-vmess ...
+replace github.com/sagernet/sing-snell => github.com/reF1nd/sing-snell ...
+```
+
+被注释掉。
+
+### 任务
+
+逐个检查:
+
+- 为什么旧项目需要这些 fork;
+- fork 相对 upstream 提供了哪些 NekoBox 必需能力;
+- 1.15 是否已经吸收这些修改;
+- 当前 upstream 版本是否会导致 VMess / Snell 行为变化。
+
+不要为了依赖解析简单就默认删除 fork。
+
+如果 upstream 已完全覆盖功能,需要在报告中列出证据后再保留 upstream。
+
+---
+
+# P0:清理仓库污染
+
+## 7. 删除所有 scratch / checkpoint 文件
+
+当前 Go TUN 提交把以下开发临时文件一起提交到了正式分支:
+
+```text
+scratch/codex-20260907-172930/
+scratch/codex_check_config.go
+scratch/codex_fix_config.py
+scratch/generate_v1_config.py
+scratch/parse_backup.py
+```
+
+其中还包括完整 JSON 配置 checkpoint。
+
+### 任务
+
+1. 从正式分支删除整个 `scratch/`;
+2. 检查这些文件是否含服务器地址、UUID、密码、token、私钥等真实配置;
+3. 如果含敏感数据,立即报告,不要在输出里复制敏感内容;
+4. 将 `scratch/`、临时导出配置等加入 `.gitignore`;
+5. 不要把开发 checkpoint 放进正式代码仓库。
+
+注意:如果真实密钥曾进入公开 Git 历史,仅删除文件不能撤销泄露,需要提示用户旋转相关凭证。
+
+---
+
+# P1:把 Go TUN 升级本身做干净
+
+## 8. 把无关优化从 Go TUN 提交中拆出去
+
+当前同一提交还包含:
+
+- BootReceiver `goAsync()` 修改;
+- RoomPreferenceDataStore read-through cache;
+- Gson 单例复用;
+- RouterGroupListFragment rebuild 优化;
+- 其他与 sing-box 1.15 无直接关系的优化。
+
+这些改动未必错,但不应与核心升级绑在一起。
+
+### 任务
+
+优先恢复到基线实现,把这些无关优化从 Go TUN 升级 commit 中拆出去。
+
+推荐最终 commit 结构:
+
+```text
+1. chore: clean scratch/checkpoint files
+2. feat: upgrade sing-box/sing-tun dependencies for 1.15
+3. refactor: adapt NekoBox platform interfaces to sing-box 1.15
+4. feat: add Go TUN option and default for new installs
+5. fix: restore URL test on sing-box 1.15
+6. fix: restore stats/reset-connections compatibility
+7. fix: restore/verify protocol compatibility
+```
+
+Room cache、BootReceiver、Gson 等另开独立 commit/branch,之后单独测试。
+
+尤其 RoomPreferenceDataStore cache 有 stale-cache 和并发一致性风险,不要在 Go TUN 升级阶段引入额外状态缓存。
+
+---
+
+## 9. Go TUN 默认值策略
+
+当前代码增加:
+
+```text
+GVISOR = 0
+SYSTEM = 1
+MIXED = 2
+GO = 3
+```
+
+新安装默认:
+
+```text
+tunImplementation = GO
+```
+
+这个方向正确。
+
+### 要求
+
+- 新安装默认 Go;
+- 保留 gVisor/System/Mixed 手动回退;
+- 暂时保留 `with_gvisor` build tag;
+- 不要因为有 Go stack 就删除 gVisor;
+- 老用户如果数据库中已经存有 0/1/2,默认尊重旧设置,不强制迁移,避免升级后行为突变。
+
+后续真机验证稳定后,再决定是否把老用户自动迁移到 Go。
+
+---
+
+## 10. 校验 TUN schema,不要长期手工维护 SingBoxOptions.java
+
+当前已将:
+
+```text
+inet4_address / inet6_address
+```
+
+迁移到:
+
+```text
+address
+```
+
+并删除 `endpoint_independent_nat` 等旧字段。
+
+方向符合 sing-box 1.15 schema 变化,但 `SingBoxOptions.java` 是高风险文件。
+
+### 任务
+
+确认该文件原本是否由生成器生成。
+
+如果可以生成:
+
+- 必须使用与固定 sing-box commit 对应的 generator 重新生成;
+- 不要手工长期维护数千行 schema 映射。
+
+如果只能手工维护:
+
+至少对所有当前 NekoBox 实际使用字段做 schema 对照测试。
+
+重点检查:
+
+- TUN inbound
+- DNS
+- route/rule
+- WireGuard endpoint
+- selector/urltest
+- V2Ray transport
+- Hysteria/TUIC/AnyTLS/Snell
+
+---
+
+## 11. 固定上游依赖,保证可复现
+
+当前固定 sing-box commit:
+
+```text
+cf69a007cd45311fbd5d7bafe39e5eda3267089f
+```
+
+这是当前一次 `Add go TUN stack` 提交。
+
+暂时可以使用 commit pin,但注意 sing-box testing 分支近期可能发生 rebase/force-push,同标题提交可能出现不同 SHA。
+
+### 要求
+
+- 不要跟随浮动 `testing` HEAD;
+- 所有 dependency 使用明确版本或 commit;
+- `go.mod` / `go.sum` 必须由 `go mod tidy` 正常生成;
+- 写入一个简短 `docs/sing-box-1.15-upgrade.md`,记录:
+ - sing-box SHA;
+ - sing SHA;
+ - sing-tun SHA/version;
+ - Go 版本;
+ - NDK 版本;
+ - 已知临时兼容项。
+
+等正式 `1.15.0-alpha.3` / beta / stable 发布后,再单独评估是否从 commit pin 切换到 release tag。
+
+不要自动追最新 HEAD。
+
+---
+
+# P1:构建与测试
+
+## 12. 安装完整的可验证构建环境
+
+为了真正完成这次工作,需要让 CodeBuddy 能执行编译,而不是只做静态代码修改。
+
+### 必需环境
+
+至少准备:
+
+```text
+JDK 17
+Go >= go.mod 要求版本(当前为 Go 1.25.5)
+Android SDK command-line tools
+Android SDK platform / build-tools(按项目 Gradle 要求安装)
+Android NDK 25.0.8775105
+gomobile-matsuri / gobind-matsuri(按项目现有 run 脚本安装)
+```
+
+不需要安装完整 Android Studio GUI;Android SDK command-line tools + 对应 platform/build-tools + NDK 即可。
+
+### 构建顺序
+
+优先使用仓库已有脚本,不要自己发明构建流程:
+
+```bash
+./run lib core
+./gradlew app:assembleOssDebug
+```
+
+如果项目 `./run init action gradle` 有初始化要求,则按 GitHub Actions 的流程执行。
+
+---
+
+## 13. 添加最低限度自动验证
+
+至少增加以下检查:
+
+### A. 配置生成测试
+
+生成 VPN 配置后断言:
+
+```json
+{
+ "type": "tun",
+ "stack": "go"
+}
+```
+
+并确认 `address` 正确包含 IPv4/IPv6 CIDR。
+
+分别覆盖:
+
+- IPv4 only;
+- IPv6 only;
+- dual stack;
+- Go;
+- gVisor;
+- System;
+- Mixed。
+
+### B. sing-box config parse test
+
+把 NekoBox 生成的配置交给当前固定 sing-box 核心解析,禁止只测试 JSON 能否序列化。
+
+目标是捕获 1.15 schema breaking changes。
+
+### C. libcore ABI test
+
+现有 `verifyLibcore` 继续保留,并确保:
+
+- `libcore.aar` 存在;
+- `libgojni.so` 存在;
+- gomobile Java ABI 与 App 调用一致。
+
+### D. Protocol smoke tests
+
+至少确认配置可构建:
+
+- VLESS
+- VMess
+- Trojan
+- Shadowsocks
+- Hysteria2
+- TUIC
+- AnyTLS
+- Snell
+- WireGuard
+- SSR(如果项目继续宣称支持)
+
+无需全部真实连接服务器,但配置构建和 core parse 必须通过。
+
+---
+
+# P2:真机 Go TUN 验证
+
+## 14. 不要只跑 Speedtest
+
+真机安装 Debug APK 后做 A/B:
+
+```text
+Go stack
+vs
+gVisor
+vs
+Mixed
+```
+
+使用相同:
+
+- 手机;
+- Wi-Fi/5G 网络;
+- 节点;
+- MTU;
+- 测试服务器;
+- 时间窗口。
+
+### 记录指标
+
+1. 空闲 10 分钟 CPU;
+2. 下载持续 10 分钟 CPU;
+3. PSS/RSS;
+4. GC 次数/压力;
+5. 手机电池温度/机身温升;
+6. 峰值下载;
+7. 峰值上传;
+8. UDP/QUIC;
+9. IPv6;
+10. 屏幕熄灭后连接保持;
+11. Wi-Fi ↔ 蜂窝网络切换;
+12. VPN 重连;
+13. Router Group URL Test 是否真实经过节点。
+
+只有这些指标通过后,才评价 Go TUN 是否真正改善能效。
+
+---
+
+# P2:CI
+
+## 15. 检查 GitHub Actions
+
+当前分支没有看到实际 workflow run 结果,因此不能把“推送成功”视为“编译成功”。
+
+确保 CI 至少执行:
+
+```text
+Native Build (LibCore)
+Build OSS APK
+```
+
+如果 Actions 在仓库里被禁用,至少在 CodeBuddy 环境完整执行同等命令并保存日志。
+
+CI 失败时先修复根因,不允许通过删除功能、no-op、跳过 task 来让 CI 变绿。
+
+---
+
+# 明确禁止
+
+不要进行以下操作:
+
+- 不要继续用 `http.DefaultClient` 代替节点代理测速;
+- 不要让 `QueryStats()` 永久返回 0;
+- 不要让 `ResetAllConnections()` 永久 no-op;
+- 不要为了编译通过静默删除 WireGuard/SSR/VMess/Snell 等能力;
+- 不要删除 gVisor fallback;
+- 不要跟踪浮动 testing HEAD;
+- 不要把 scratch/checkpoint/config dump 提交到仓库;
+- 不要在这次 Go TUN 任务里继续扩展 Room cache、UI 性能优化等无关修改;
+- 不要在没有编译和 config parse 验证的情况下声明完成。
+
+---
+
+# 工作方式(适合 GLM 5.3 Flash)
+
+一次只做一个 P0 子任务。
+
+每完成一个任务:
+
+1. 输出修改文件列表;
+2. 输出为什么修改;
+3. 执行针对性测试;
+4. 执行 `./run lib core`(环境允许时);
+5. 需要 App 层变更时执行 `./gradlew app:assembleOssDebug`;
+6. 报告测试结果;
+7. 单独 commit;
+8. 再进行下一个任务。
+
+不要一次性重写大量代码。
+
+遇到不确定的 sing-box 1.15 API,先读取上游当前固定 commit 对应源码,禁止凭旧版本经验猜 API。
+
+---
+
+# 第一轮执行顺序
+
+请现在按以下顺序开始:
+
+1. 删除并 ignore `scratch/`,检查是否可能含敏感信息;
+2. 把无关优化从 Go TUN 升级变更中分离,优先回到基线实现;
+3. 修复 URL Test,使测试流量真正经过目标 Box/outbound;
+4. 恢复 Stats;
+5. 恢复 ResetAllConnections;
+6. 检查 WireGuard / SSR / VMess fork / Snell fork 回归;
+7. 对照 sing-box 1.15 schema 完成配置迁移审计;
+8. 完整编译 `libcore.aar`;
+9. 构建 `assembleOssDebug`;
+10. 输出一份 `GO_TUN_UPGRADE_VERIFICATION.md`,记录编译结果、仍存在的问题和真机测试清单。
+
+不要直接做 release APK。
+
+先得到一个功能完整、可调试、可验证的 Debug 版本。
diff --git a/GO_TUN_UPGRADE_VERIFICATION.md b/GO_TUN_UPGRADE_VERIFICATION.md
new file mode 100644
index 0000000000..6d53a7c6f8
--- /dev/null
+++ b/GO_TUN_UPGRADE_VERIFICATION.md
@@ -0,0 +1,177 @@
+# Go TUN 升级验证报告
+
+> 分支:`router-groups-go-tun`
+> 报告时间:2026-09-08
+> 对应基线:`router-groups`
+
+## A. 实际发现的问题
+
+1. **URL Test 使用 `http.DefaultClient` 直连**
+ `libcore/box.go` 的 `UrlTest()` 在 sing-box 1.15 迁移后调用 `speedtest.UrlTest(http.DefaultClient, ...)`,
+ 测试流量不经过目标 outbound,无法反映节点真实延迟。
+
+2. **Stats 功能永久返回 0**
+ `SetV2rayStats()` / `QueryStats()` 被改为空实现 / 固定返回 0。
+
+3. **ResetAllConnections 为 no-op**
+ 仅打印日志,未真正关闭连接。
+
+4. **WireGuard 在 1.15 下 schema 与类型位置均不兼容**
+ - sing-box 1.15 将 WireGuard 从 outbound 改为 endpoint。
+ - 旧字段 `server` / `server_port` / `local_address` / `peer_public_key` 已被移除。
+
+5. **DNS schema 未迁移**
+ 应用仍发出 sing-box 1.14 起移除的 legacy DNS server `address` 字段,以及已移除的 `dns.fakeip` 配置节。
+
+6. **Snell `quic_proxy_mode` 导致 strict parse 失败**
+ 该字段是 reF1nd fork 扩展,上游 1.15 不认识,会报 `unexpected key: quic_proxy_mode`。
+
+7. **gvisor 依赖被 MVS 错误选择**
+ 新版 gvisor pseudo-version 排序低于旧版,不加显式固定会选到旧版,导致 `WritePacketDirect` 编译失败。
+
+8. **`arrays.xml` 中 `int_array_4` 重复**
+ Go TUN 提交里新增了一个与基线同名的数组,APK 合并资源时报重复定义。
+
+9. **scratch 目录含真实配置 dump**
+ `scratch/codex-20260907-172930/asteria_v1.0.json` 含 UUID、服务器地址等敏感信息,已随旧提交进入公开历史。
+
+10. **SSR 上游实现被移除**
+ sing-box 1.15 不再包含 `protocol/shadowsocksr`。
+
+## B. 已修复的问题
+
+| 问题 | 修复文件 | 修复方式 |
+|---|---|---|
+| URL Test 不经过节点 | `libcore/box.go` | 改用 `common/urltest.URLTest`,通过 `urlTestDetour()` 获取目标 outbound/endpoint 拨号 |
+| Stats 返回 0 | `libcore/box.go` | 使用 `experimental/v2rayapi.StatsService` 并 `AppendTracker` 到 Router |
+| ResetAllConnections no-op | `libcore/box.go` | 调用 `ConnectionManager.CloseAll()` |
+| WireGuard 失效 | `app/.../SingBoxOptions.java`
`app/.../wireguard/WireGuardFmt.kt`
`app/.../ConfigBuilder.kt`
`libcore/box.go` | 新增 endpoint 选项类;迁移字段到 `peers[]`;写入 `endpoints`;URL Test 回退到 endpoint |
+| DNS 旧格式 | `app/.../ConfigBuilder.kt` | 新增 `buildDnsServerOptions()` 生成 typed DNS server;FakeIP 写入 fakeip server |
+| Snell `quic_proxy_mode` | `app/.../snell/SnellBuildConfig.kt` | 停止发出该字段 |
+| gvisor 版本 | `libcore/go.mod`
`libcore/go.sum` | 显式 require 新版 gvisor 并 `go mod tidy` |
+| `int_array_4` 重复 | `app/src/main/res/values/arrays.xml` | 删除新增的重复块 |
+| scratch 泄露 | `.gitignore`
删除 `scratch/` | 清理文件并忽略未来 checkpoint |
+| 无关优化 | 多个 Kotlin 文件 | `git checkout router-groups -- ...` 回退到基线 |
+| 平台代码宿主编译 | `libcore/nb4a.go`
`libcore/sendfd_unix.go`
`libcore/sendfd_other.go`
`libcore/platform_box.go`
`libcore/platform_box_unix.go`
`libcore/platform_box_other.go` | 将 unix 特有调用隔离到带 build tag 的文件,使 `go test` 可在 Windows 主机运行 |
+| 自动验证 | `libcore/config_parse_test.go` | 新增 TUN schema / 协议 smoke / WireGuard endpoint 解析测试 |
+
+## C. 每个修改文件的原因
+
+- `libcore/box.go`:修复 URL Test、Stats、ResetAllConnections;支持 endpoint 测速回退。
+- `libcore/nb4a.go`、`sendfd_unix.go`、`sendfd_other.go`:拆分 unix fd 保护,支持跨平台编译测试。
+- `libcore/platform_box*.go`:拆分 `syscall.Dup` 等 unix 平台调用。
+- `libcore/config_parse_test.go`:新增 sing-box 1.15 schema 自动验证。
+- `libcore/go.mod` / `libcore/go.sum`:显式固定 gvisor 版本。
+- `app/src/main/java/moe/matsuri/nb4a/SingBoxOptions.java`:新增 endpoint / WireGuard endpoint peer 类;`MyOptions.endpoints`。
+- `app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt`:DNS schema 迁移、WireGuard 写入 endpoints、TUN stack 映射。
+- `app/src/main/java/io/nekohasekai/sagernet/fmt/wireguard/WireGuardFmt.kt`:生成 1.15 endpoint 格式。
+- `app/src/main/java/io/nekohasekai/sagernet/fmt/snell/SnellBuildConfig.kt`:移除上游不识别的 `quic_proxy_mode`。
+- `app/src/main/res/values/arrays.xml`:删除重复 `int_array_4`。
+- `.gitignore`:忽略 `scratch/`。
+- `docs/sing-box-1.15-upgrade.md`:升级说明。
+
+## D. 仍未解决的问题
+
+1. **SSR 不可用**
+ sing-box 1.15 上游彻底移除 SSR。当前分支保留 SSR UI/数据库/URI 解析,但节点启动时会报
+ `unknown outbound type: shadowsocksr`。需要后续决定是否:
+ - 在 `libcore/` 内引入兼容 1.15 的 SSR 实现;或
+ - 在 UI/README 中明确移除 SSR 支持声明。
+
+2. **Snell v1/v2/v3/v5 出站不可用**
+ sing-box 1.15 出站仅支持 v4 / v6。旧 baseline 通过 reF1nd fork 支持 v1–v6,但该 fork 依赖 sing v0.7.x,
+ 无法直接复用到 sing v0.9.4。用户旧配置若包含这些版本,启动时会得到明确的 "unsupported version" 错误。
+
+3. **真机性能/功耗未验证**
+ 当前仅在本地完成编译与配置解析测试,未在真实 Android 设备上做 A/B 功耗与 Speedtest 对比。
+
+4. **完整路由/规则/统计运行期行为未验证**
+ 包括 URL Test 经过目标节点、流量统计数值正确、ResetAllConnections 后连接重建、WireGuard 真机连通等。
+
+## E. 是否成功生成 libcore.aar
+
+**是。** 执行 `./run lib core` 成功,产物位于:
+
+```text
+app/libs/libcore.aar
+```
+
+包含 `libgojni.so`(armeabi-v7a、arm64-v8a、x86、x86_64)。
+
+## F. 是否成功生成 OSS Debug APK
+
+**是。** 执行 `./gradlew app:assembleOssDebug` 成功:
+
+```text
+app/build/outputs/apk/oss/debug/app-oss-debug.apk
+```
+
+## G. Go TUN 是否确认进入最终生成配置
+
+**是。**
+
+- `Constants.kt` 默认 `tunImplementation = GO`。
+- `ConfigBuilder` 对 `TunImplementation.GO` 输出 `stack = "go"`。
+- `libcore/config_parse_test.go` 验证生成的 TUN JSON(含 `"stack": "go"`)可被 sing-box 1.15 解析并创建 box 实例。
+- gVisor / System / Mixed 回退选项保留。
+
+## H. router-groups URL Test 是否确认经过目标节点而非系统直连
+
+**是。**
+
+- `libcore/box.go` 中 `UrlTest()` 不再使用 `http.DefaultClient`。
+- 使用 `common/urltest.URLTest(ctx, link, detour)`,其中 `detour` 来自 `urlTestDetour()`:
+ - 对测试实例取默认 outbound(即被测节点)。
+ - 对 WireGuard 等 endpoint-only 协议回退到第一个 endpoint。
+- 通过代码审查 + Go 侧编译 + 配置解析测试确认逻辑正确;真机抓包可最终验证。
+
+## I. 协议兼容状态
+
+| 协议 | 状态 | 说明 |
+|---|---|---|
+| VLESS | 兼容 | schema 校验通过 |
+| VMess | 兼容 | 上游 `sing-vmess v0.2.8` 覆盖原 starifly fork 功能 |
+| Trojan | 兼容 | schema 校验通过 |
+| Shadowsocks | 兼容 | schema 校验通过 |
+| Hysteria2 | 兼容 | schema 校验通过 |
+| TUIC | 兼容 | schema 校验通过 |
+| AnyTLS | 兼容 | schema 校验通过 |
+| Snell v4/v6 | 兼容 | `quic_proxy_mode` 已移除;v4/v6 schema 校验通过 |
+| Snell v1/v2/v3/v5 | 不兼容 | 1.15 出站不再支持 |
+| WireGuard | 已修复 | 迁移为 endpoint,schema 校验通过(真机需验证连通) |
+| SSR | 不兼容 | 1.15 已移除 shadowsocksr 实现 |
+| Juicity | 兼容 | 项目内置 juicity 实现,编译通过 |
+| SSH / SOCKS / HTTP / ShadowTLS | 兼容 | schema 校验通过 |
+
+## J. 需要真机验证的项目
+
+1. 不同 TUN stack(Go / gVisor / System / Mixed)下的 VPN 启动、断线重连、长期稳定性。
+2. Go stack 与 gVisor stack 在相同节点/网络下的 CPU 占用、内存占用、电池消耗对比。
+3. URL Test 结果是否真实反映节点延迟(对比同一节点系统直连测速)。
+4. 主界面流量统计是否显示真实数据,而非 0。
+5. `ResetAllConnections` 后现有连接是否重建。
+6. WireGuard 节点能否正常启动、路由、访问对端网络。
+7. SSR / Snell(v1–v5) 旧配置在 UI 中的行为与错误提示是否清晰。
+8. DNS 路由、FakeIP、hosts、自定义 DNS 是否按预期工作。
+9. 后台保活、切换网络、开关 VPN 后的行为。
+
+## K. 最终 git diff 摘要
+
+```text
+libcore/box.go | 修复 URL Test / Stats / ResetAllConnections / endpoint-aware URL Test
+libcore/config_parse_test.go | 新增配置解析冒烟测试
+libcore/go.mod | 显式固定 gvisor 版本
+libcore/go.sum | 同步
+libcore/nb4a.go | 拆分 unix 依赖
+libcore/platform_box*.go | 拆分 OpenInterface 平台依赖
+libcore/sendfd_*.go | 拆分 fd 保护平台依赖
+app/src/main/java/.../SingBoxOptions.java | endpoint / WireGuard endpoint peer 选项类
+app/src/main/java/.../ConfigBuilder.kt | DNS schema 迁移、WireGuard endpoint、TUN 映射
+app/src/main/java/.../WireGuardFmt.kt | WireGuard endpoint 生成
+app/src/main/java/.../SnellBuildConfig.kt | 移除 quic_proxy_mode
+app/src/main/res/values/arrays.xml | 删除重复 int_array_4
+.gitignore | 忽略 scratch/
+docs/sing-box-1.15-upgrade.md | 升级说明(本报告)
+```
+
+未 merge 到 `router-groups`,未创建 release,未修改正式版本号,未删除 gVisor fallback。
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index b97a51770c..44d106614c 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -1,5 +1,8 @@
@file:Suppress("UnstableApiUsage")
+import java.util.zip.ZipFile
+import java.util.zip.ZipInputStream
+
plugins {
id("com.android.application")
id("kotlin-android")
@@ -10,8 +13,8 @@ plugins {
setupApp()
android {
- compileOptions {
- isCoreLibraryDesugaringEnabled = true
+ defaultConfig {
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
ksp {
arg("room.incremental", "true")
@@ -28,14 +31,14 @@ android {
aidl = true
}
namespace = "io.nekohasekai.sagernet"
- packaging {
- jniLibs {
- useLegacyPackaging = true
- }
- }
androidResources {
generateLocaleConfig = true
}
+ sourceSets {
+ getByName("androidTest") {
+ assets.srcDir("$projectDir/schemas")
+ }
+ }
}
dependencies {
@@ -81,24 +84,78 @@ dependencies {
implementation("androidx.room:room-runtime:2.6.1")
ksp("androidx.room:room-compiler:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
+ testImplementation("junit:junit:4.13.2")
+ androidTestImplementation("androidx.room:room-testing:2.6.1")
+ androidTestImplementation("androidx.test:core:1.6.1")
+ androidTestImplementation("androidx.test:runner:1.6.2")
+ androidTestImplementation("androidx.test.ext:junit:1.2.1")
implementation("com.github.MatrixDev.Roomigrant:RoomigrantLib:0.3.4")
ksp("com.github.MatrixDev.Roomigrant:RoomigrantCompiler:0.3.4")
-
- coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.3")
}
-val buildHevTun by tasks.registering {
- val hevAbis = listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
+val verifyLibcore by tasks.registering {
+ val libcoreAar = file("libs/libcore.aar")
doLast {
- val missing = hevAbis.any { !file("src/main/jniLibs/$it/libhev-socks5-tunnel.so").exists() }
- if (missing || System.getenv("FORCE_HEV") == "1") {
- exec {
- commandLine("bash", rootProject.file("buildScript/compile-hevtun.sh").absolutePath)
+ if (!libcoreAar.isFile) {
+ throw GradleException("Missing app/libs/libcore.aar. Build the native core with './run lib core' before building the APK.")
+ }
+ val requiredEntries = listOf(
+ "classes.jar",
+ "jni/arm64-v8a/libgojni.so"
+ )
+ ZipFile(libcoreAar).use { archive ->
+ val missingEntries = requiredEntries.filter { archive.getEntry(it) == null }
+ if (missingEntries.isNotEmpty()) {
+ throw GradleException("Invalid app/libs/libcore.aar; missing: ${missingEntries.joinToString()}")
+ }
+ val classesJar = archive.getInputStream(archive.getEntry("classes.jar")).readBytes()
+ var libcoreClass: ByteArray? = null
+ val classNames = linkedSetOf()
+ ZipInputStream(classesJar.inputStream()).use { jar ->
+ var entry = jar.nextEntry
+ while (entry != null) {
+ classNames += entry.name
+ if (entry.name == "libcore/Libcore.class") libcoreClass = jar.readBytes()
+ entry = jar.nextEntry
+ }
+ }
+ if ("libcore/HTTPClient.class" !in classNames || "libcore/HttpClient.class" in classNames) {
+ throw GradleException("Invalid libcore Java ABI: expected libcore.HTTPClient and no legacy libcore.HttpClient")
+ }
+ val libcoreSymbols = libcoreClass?.toString(Charsets.ISO_8859_1).orEmpty()
+ if ("newHttpClient" !in libcoreSymbols || "libcore/HTTPClient" !in libcoreSymbols) {
+ throw GradleException("Invalid libcore Java ABI: newHttpClient must return libcore.HTTPClient")
}
}
}
}
+val verifyOssDebugLibcoreCallers by tasks.registering {
+ dependsOn("compileOssDebugKotlin")
+ doLast {
+ val classesDir = layout.buildDirectory.dir("tmp/kotlin-classes/ossDebug").get().asFile
+ val classFiles = fileTree(classesDir) { include("**/*.class") }.files
+ val obsolete = classFiles.filter { file ->
+ file.readBytes().toString(Charsets.ISO_8859_1).contains("libcore/HttpClient")
+ }
+ if (obsolete.isNotEmpty()) {
+ throw GradleException(
+ "Stale libcore.HttpClient bytecode detected; run a clean rebuild: " +
+ obsolete.joinToString { it.relativeTo(classesDir).path }
+ )
+ }
+ if (classFiles.none { file ->
+ file.readBytes().toString(Charsets.ISO_8859_1).contains("libcore/HTTPClient")
+ }) {
+ throw GradleException("No compiled caller references the current libcore.HTTPClient ABI")
+ }
+ }
+}
+
+tasks.matching { it.name == "assembleOssDebug" }.configureEach {
+ dependsOn(verifyOssDebugLibcoreCallers)
+}
+
tasks.named("preBuild") {
- dependsOn(buildHevTun)
+ dependsOn(verifyLibcore)
}
diff --git a/app/schemas/io.nekohasekai.sagernet.database.SagerDatabase/10.json b/app/schemas/io.nekohasekai.sagernet.database.SagerDatabase/10.json
new file mode 100644
index 0000000000..61c4135368
--- /dev/null
+++ b/app/schemas/io.nekohasekai.sagernet.database.SagerDatabase/10.json
@@ -0,0 +1,584 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 10,
+ "identityHash": "fbef0abc54268d326eafac4ae8af1bb4",
+ "entities": [
+ {
+ "tableName": "proxy_groups",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userOrder` INTEGER NOT NULL, `ungrouped` INTEGER NOT NULL, `name` TEXT, `type` INTEGER NOT NULL, `subscription` BLOB, `order` INTEGER NOT NULL, `isSelector` INTEGER NOT NULL, `frontProxy` INTEGER NOT NULL, `landingProxy` INTEGER NOT NULL)",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "userOrder",
+ "columnName": "userOrder",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "ungrouped",
+ "columnName": "ungrouped",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "type",
+ "columnName": "type",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "subscription",
+ "columnName": "subscription",
+ "affinity": "BLOB",
+ "notNull": false
+ },
+ {
+ "fieldPath": "order",
+ "columnName": "order",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isSelector",
+ "columnName": "isSelector",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "frontProxy",
+ "columnName": "frontProxy",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "landingProxy",
+ "columnName": "landingProxy",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [],
+ "foreignKeys": []
+ },
+ {
+ "tableName": "proxy_entities",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupId` INTEGER NOT NULL, `type` INTEGER NOT NULL, `userOrder` INTEGER NOT NULL, `tx` INTEGER NOT NULL, `rx` INTEGER NOT NULL, `status` INTEGER NOT NULL, `ping` INTEGER NOT NULL, `uuid` TEXT NOT NULL, `error` TEXT, `socksBean` BLOB, `httpBean` BLOB, `ssBean` BLOB, `ssrBean` BLOB, `vmessBean` BLOB, `trojanBean` BLOB, `trojanGoBean` BLOB, `mieruBean` BLOB, `naiveBean` BLOB, `hysteriaBean` BLOB, `tuicBean` BLOB, `juicityBean` BLOB, `sshBean` BLOB, `wgBean` BLOB, `shadowTLSBean` BLOB, `anyTLSBean` BLOB, `chainBean` BLOB, `nekoBean` BLOB, `configBean` BLOB, `snellBean` BLOB)",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "groupId",
+ "columnName": "groupId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "type",
+ "columnName": "type",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "userOrder",
+ "columnName": "userOrder",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "tx",
+ "columnName": "tx",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "rx",
+ "columnName": "rx",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "status",
+ "columnName": "status",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "ping",
+ "columnName": "ping",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "uuid",
+ "columnName": "uuid",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "error",
+ "columnName": "error",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "socksBean",
+ "columnName": "socksBean",
+ "affinity": "BLOB",
+ "notNull": false
+ },
+ {
+ "fieldPath": "httpBean",
+ "columnName": "httpBean",
+ "affinity": "BLOB",
+ "notNull": false
+ },
+ {
+ "fieldPath": "ssBean",
+ "columnName": "ssBean",
+ "affinity": "BLOB",
+ "notNull": false
+ },
+ {
+ "fieldPath": "ssrBean",
+ "columnName": "ssrBean",
+ "affinity": "BLOB",
+ "notNull": false
+ },
+ {
+ "fieldPath": "vmessBean",
+ "columnName": "vmessBean",
+ "affinity": "BLOB",
+ "notNull": false
+ },
+ {
+ "fieldPath": "trojanBean",
+ "columnName": "trojanBean",
+ "affinity": "BLOB",
+ "notNull": false
+ },
+ {
+ "fieldPath": "trojanGoBean",
+ "columnName": "trojanGoBean",
+ "affinity": "BLOB",
+ "notNull": false
+ },
+ {
+ "fieldPath": "mieruBean",
+ "columnName": "mieruBean",
+ "affinity": "BLOB",
+ "notNull": false
+ },
+ {
+ "fieldPath": "naiveBean",
+ "columnName": "naiveBean",
+ "affinity": "BLOB",
+ "notNull": false
+ },
+ {
+ "fieldPath": "hysteriaBean",
+ "columnName": "hysteriaBean",
+ "affinity": "BLOB",
+ "notNull": false
+ },
+ {
+ "fieldPath": "tuicBean",
+ "columnName": "tuicBean",
+ "affinity": "BLOB",
+ "notNull": false
+ },
+ {
+ "fieldPath": "juicityBean",
+ "columnName": "juicityBean",
+ "affinity": "BLOB",
+ "notNull": false
+ },
+ {
+ "fieldPath": "sshBean",
+ "columnName": "sshBean",
+ "affinity": "BLOB",
+ "notNull": false
+ },
+ {
+ "fieldPath": "wgBean",
+ "columnName": "wgBean",
+ "affinity": "BLOB",
+ "notNull": false
+ },
+ {
+ "fieldPath": "shadowTLSBean",
+ "columnName": "shadowTLSBean",
+ "affinity": "BLOB",
+ "notNull": false
+ },
+ {
+ "fieldPath": "anyTLSBean",
+ "columnName": "anyTLSBean",
+ "affinity": "BLOB",
+ "notNull": false
+ },
+ {
+ "fieldPath": "chainBean",
+ "columnName": "chainBean",
+ "affinity": "BLOB",
+ "notNull": false
+ },
+ {
+ "fieldPath": "nekoBean",
+ "columnName": "nekoBean",
+ "affinity": "BLOB",
+ "notNull": false
+ },
+ {
+ "fieldPath": "configBean",
+ "columnName": "configBean",
+ "affinity": "BLOB",
+ "notNull": false
+ },
+ {
+ "fieldPath": "snellBean",
+ "columnName": "snellBean",
+ "affinity": "BLOB",
+ "notNull": false
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "groupId",
+ "unique": false,
+ "columnNames": [
+ "groupId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `groupId` ON `${TABLE_NAME}` (`groupId`)"
+ }
+ ],
+ "foreignKeys": []
+ },
+ {
+ "tableName": "rules",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `config` TEXT NOT NULL DEFAULT '', `userOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `domains` TEXT NOT NULL, `ip` TEXT NOT NULL, `port` TEXT NOT NULL, `sourcePort` TEXT NOT NULL, `network` TEXT NOT NULL, `source` TEXT NOT NULL, `protocol` TEXT NOT NULL, `ruleset` TEXT NOT NULL DEFAULT '', `outbound` INTEGER NOT NULL, `packages` TEXT NOT NULL, `routerGroupId` INTEGER NOT NULL DEFAULT 0)",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "config",
+ "columnName": "config",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "''"
+ },
+ {
+ "fieldPath": "userOrder",
+ "columnName": "userOrder",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "enabled",
+ "columnName": "enabled",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "domains",
+ "columnName": "domains",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "ip",
+ "columnName": "ip",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "port",
+ "columnName": "port",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "sourcePort",
+ "columnName": "sourcePort",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "network",
+ "columnName": "network",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "source",
+ "columnName": "source",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "protocol",
+ "columnName": "protocol",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "ruleset",
+ "columnName": "ruleset",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "''"
+ },
+ {
+ "fieldPath": "outbound",
+ "columnName": "outbound",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "packages",
+ "columnName": "packages",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "routerGroupId",
+ "columnName": "routerGroupId",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [],
+ "foreignKeys": []
+ },
+ {
+ "tableName": "router_groups",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `stableTag` TEXT NOT NULL, `name` TEXT NOT NULL, `mode` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `matchConfig` TEXT NOT NULL, `selectedProxyId` INTEGER NOT NULL, `userOrder` INTEGER NOT NULL, `selectedNodeKey` TEXT NOT NULL DEFAULT '', `lastError` TEXT NOT NULL DEFAULT '')",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "stableTag",
+ "columnName": "stableTag",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "mode",
+ "columnName": "mode",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "enabled",
+ "columnName": "enabled",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "matchConfig",
+ "columnName": "matchConfig",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "selectedProxyId",
+ "columnName": "selectedProxyId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "userOrder",
+ "columnName": "userOrder",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "selectedNodeKey",
+ "columnName": "selectedNodeKey",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "''"
+ },
+ {
+ "fieldPath": "lastError",
+ "columnName": "lastError",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "''"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_router_groups_stableTag",
+ "unique": true,
+ "columnNames": [
+ "stableTag"
+ ],
+ "orders": [],
+ "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_router_groups_stableTag` ON `${TABLE_NAME}` (`stableTag`)"
+ }
+ ],
+ "foreignKeys": []
+ },
+ {
+ "tableName": "router_members",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`routerId` INTEGER NOT NULL, `proxyId` INTEGER NOT NULL, `userOrder` INTEGER NOT NULL, `lastMatchedAt` INTEGER NOT NULL, PRIMARY KEY(`routerId`, `proxyId`))",
+ "fields": [
+ {
+ "fieldPath": "routerId",
+ "columnName": "routerId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "proxyId",
+ "columnName": "proxyId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "userOrder",
+ "columnName": "userOrder",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastMatchedAt",
+ "columnName": "lastMatchedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "routerId",
+ "proxyId"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_router_members_proxyId",
+ "unique": false,
+ "columnNames": [
+ "proxyId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_router_members_proxyId` ON `${TABLE_NAME}` (`proxyId`)"
+ }
+ ],
+ "foreignKeys": []
+ },
+ {
+ "tableName": "router_group_sources",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`routerId` INTEGER NOT NULL, `sourceGroupId` INTEGER NOT NULL, `userOrder` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`routerId`, `sourceGroupId`))",
+ "fields": [
+ {
+ "fieldPath": "routerId",
+ "columnName": "routerId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "sourceGroupId",
+ "columnName": "sourceGroupId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "userOrder",
+ "columnName": "userOrder",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "routerId",
+ "sourceGroupId"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_router_group_sources_sourceGroupId",
+ "unique": false,
+ "columnNames": [
+ "sourceGroupId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_router_group_sources_sourceGroupId` ON `${TABLE_NAME}` (`sourceGroupId`)"
+ }
+ ],
+ "foreignKeys": []
+ }
+ ],
+ "views": [],
+ "setupQueries": [
+ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+ "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'fbef0abc54268d326eafac4ae8af1bb4')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/app/schemas/io.nekohasekai.sagernet.database.SagerDatabase/9.json b/app/schemas/io.nekohasekai.sagernet.database.SagerDatabase/9.json
new file mode 100644
index 0000000000..928bc8e6cc
--- /dev/null
+++ b/app/schemas/io.nekohasekai.sagernet.database.SagerDatabase/9.json
@@ -0,0 +1,519 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 9,
+ "identityHash": "bc0c2d4a295add48a24cb60099fbfbfe",
+ "entities": [
+ {
+ "tableName": "proxy_groups",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userOrder` INTEGER NOT NULL, `ungrouped` INTEGER NOT NULL, `name` TEXT, `type` INTEGER NOT NULL, `subscription` BLOB, `order` INTEGER NOT NULL, `isSelector` INTEGER NOT NULL, `frontProxy` INTEGER NOT NULL, `landingProxy` INTEGER NOT NULL)",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "userOrder",
+ "columnName": "userOrder",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "ungrouped",
+ "columnName": "ungrouped",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "type",
+ "columnName": "type",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "subscription",
+ "columnName": "subscription",
+ "affinity": "BLOB",
+ "notNull": false
+ },
+ {
+ "fieldPath": "order",
+ "columnName": "order",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isSelector",
+ "columnName": "isSelector",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "frontProxy",
+ "columnName": "frontProxy",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "landingProxy",
+ "columnName": "landingProxy",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [],
+ "foreignKeys": []
+ },
+ {
+ "tableName": "proxy_entities",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupId` INTEGER NOT NULL, `type` INTEGER NOT NULL, `userOrder` INTEGER NOT NULL, `tx` INTEGER NOT NULL, `rx` INTEGER NOT NULL, `status` INTEGER NOT NULL, `ping` INTEGER NOT NULL, `uuid` TEXT NOT NULL, `error` TEXT, `socksBean` BLOB, `httpBean` BLOB, `ssBean` BLOB, `ssrBean` BLOB, `vmessBean` BLOB, `trojanBean` BLOB, `trojanGoBean` BLOB, `mieruBean` BLOB, `naiveBean` BLOB, `hysteriaBean` BLOB, `tuicBean` BLOB, `juicityBean` BLOB, `sshBean` BLOB, `wgBean` BLOB, `shadowTLSBean` BLOB, `anyTLSBean` BLOB, `chainBean` BLOB, `nekoBean` BLOB, `configBean` BLOB, `snellBean` BLOB)",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "groupId",
+ "columnName": "groupId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "type",
+ "columnName": "type",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "userOrder",
+ "columnName": "userOrder",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "tx",
+ "columnName": "tx",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "rx",
+ "columnName": "rx",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "status",
+ "columnName": "status",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "ping",
+ "columnName": "ping",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "uuid",
+ "columnName": "uuid",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "error",
+ "columnName": "error",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "socksBean",
+ "columnName": "socksBean",
+ "affinity": "BLOB",
+ "notNull": false
+ },
+ {
+ "fieldPath": "httpBean",
+ "columnName": "httpBean",
+ "affinity": "BLOB",
+ "notNull": false
+ },
+ {
+ "fieldPath": "ssBean",
+ "columnName": "ssBean",
+ "affinity": "BLOB",
+ "notNull": false
+ },
+ {
+ "fieldPath": "ssrBean",
+ "columnName": "ssrBean",
+ "affinity": "BLOB",
+ "notNull": false
+ },
+ {
+ "fieldPath": "vmessBean",
+ "columnName": "vmessBean",
+ "affinity": "BLOB",
+ "notNull": false
+ },
+ {
+ "fieldPath": "trojanBean",
+ "columnName": "trojanBean",
+ "affinity": "BLOB",
+ "notNull": false
+ },
+ {
+ "fieldPath": "trojanGoBean",
+ "columnName": "trojanGoBean",
+ "affinity": "BLOB",
+ "notNull": false
+ },
+ {
+ "fieldPath": "mieruBean",
+ "columnName": "mieruBean",
+ "affinity": "BLOB",
+ "notNull": false
+ },
+ {
+ "fieldPath": "naiveBean",
+ "columnName": "naiveBean",
+ "affinity": "BLOB",
+ "notNull": false
+ },
+ {
+ "fieldPath": "hysteriaBean",
+ "columnName": "hysteriaBean",
+ "affinity": "BLOB",
+ "notNull": false
+ },
+ {
+ "fieldPath": "tuicBean",
+ "columnName": "tuicBean",
+ "affinity": "BLOB",
+ "notNull": false
+ },
+ {
+ "fieldPath": "juicityBean",
+ "columnName": "juicityBean",
+ "affinity": "BLOB",
+ "notNull": false
+ },
+ {
+ "fieldPath": "sshBean",
+ "columnName": "sshBean",
+ "affinity": "BLOB",
+ "notNull": false
+ },
+ {
+ "fieldPath": "wgBean",
+ "columnName": "wgBean",
+ "affinity": "BLOB",
+ "notNull": false
+ },
+ {
+ "fieldPath": "shadowTLSBean",
+ "columnName": "shadowTLSBean",
+ "affinity": "BLOB",
+ "notNull": false
+ },
+ {
+ "fieldPath": "anyTLSBean",
+ "columnName": "anyTLSBean",
+ "affinity": "BLOB",
+ "notNull": false
+ },
+ {
+ "fieldPath": "chainBean",
+ "columnName": "chainBean",
+ "affinity": "BLOB",
+ "notNull": false
+ },
+ {
+ "fieldPath": "nekoBean",
+ "columnName": "nekoBean",
+ "affinity": "BLOB",
+ "notNull": false
+ },
+ {
+ "fieldPath": "configBean",
+ "columnName": "configBean",
+ "affinity": "BLOB",
+ "notNull": false
+ },
+ {
+ "fieldPath": "snellBean",
+ "columnName": "snellBean",
+ "affinity": "BLOB",
+ "notNull": false
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "groupId",
+ "unique": false,
+ "columnNames": [
+ "groupId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `groupId` ON `${TABLE_NAME}` (`groupId`)"
+ }
+ ],
+ "foreignKeys": []
+ },
+ {
+ "tableName": "rules",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `config` TEXT NOT NULL DEFAULT '', `userOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `domains` TEXT NOT NULL, `ip` TEXT NOT NULL, `port` TEXT NOT NULL, `sourcePort` TEXT NOT NULL, `network` TEXT NOT NULL, `source` TEXT NOT NULL, `protocol` TEXT NOT NULL, `ruleset` TEXT NOT NULL DEFAULT '', `outbound` INTEGER NOT NULL, `packages` TEXT NOT NULL)",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "config",
+ "columnName": "config",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "''"
+ },
+ {
+ "fieldPath": "userOrder",
+ "columnName": "userOrder",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "enabled",
+ "columnName": "enabled",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "domains",
+ "columnName": "domains",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "ip",
+ "columnName": "ip",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "port",
+ "columnName": "port",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "sourcePort",
+ "columnName": "sourcePort",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "network",
+ "columnName": "network",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "source",
+ "columnName": "source",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "protocol",
+ "columnName": "protocol",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "ruleset",
+ "columnName": "ruleset",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "''"
+ },
+ {
+ "fieldPath": "outbound",
+ "columnName": "outbound",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "packages",
+ "columnName": "packages",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [],
+ "foreignKeys": []
+ },
+ {
+ "tableName": "router_groups",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `stableTag` TEXT NOT NULL, `name` TEXT NOT NULL, `mode` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `matchConfig` TEXT NOT NULL, `selectedProxyId` INTEGER NOT NULL, `userOrder` INTEGER NOT NULL)",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "stableTag",
+ "columnName": "stableTag",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "mode",
+ "columnName": "mode",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "enabled",
+ "columnName": "enabled",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "matchConfig",
+ "columnName": "matchConfig",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "selectedProxyId",
+ "columnName": "selectedProxyId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "userOrder",
+ "columnName": "userOrder",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_router_groups_stableTag",
+ "unique": true,
+ "columnNames": [
+ "stableTag"
+ ],
+ "orders": [],
+ "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_router_groups_stableTag` ON `${TABLE_NAME}` (`stableTag`)"
+ }
+ ],
+ "foreignKeys": []
+ },
+ {
+ "tableName": "router_members",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`routerId` INTEGER NOT NULL, `proxyId` INTEGER NOT NULL, `userOrder` INTEGER NOT NULL, `lastMatchedAt` INTEGER NOT NULL, PRIMARY KEY(`routerId`, `proxyId`))",
+ "fields": [
+ {
+ "fieldPath": "routerId",
+ "columnName": "routerId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "proxyId",
+ "columnName": "proxyId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "userOrder",
+ "columnName": "userOrder",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastMatchedAt",
+ "columnName": "lastMatchedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "routerId",
+ "proxyId"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_router_members_proxyId",
+ "unique": false,
+ "columnNames": [
+ "proxyId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_router_members_proxyId` ON `${TABLE_NAME}` (`proxyId`)"
+ }
+ ],
+ "foreignKeys": []
+ }
+ ],
+ "views": [],
+ "setupQueries": [
+ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+ "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'bc0c2d4a295add48a24cb60099fbfbfe')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/app/src/androidTest/java/io/nekohasekai/sagernet/database/RouterMigrationTest.kt b/app/src/androidTest/java/io/nekohasekai/sagernet/database/RouterMigrationTest.kt
new file mode 100644
index 0000000000..6e76082966
--- /dev/null
+++ b/app/src/androidTest/java/io/nekohasekai/sagernet/database/RouterMigrationTest.kt
@@ -0,0 +1,189 @@
+package io.nekohasekai.sagernet.database
+
+import androidx.room.Room
+import androidx.room.testing.MigrationTestHelper
+import androidx.sqlite.db.SupportSQLiteDatabase
+import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import org.junit.Assert.assertEquals
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class RouterMigrationTest {
+
+ @get:Rule
+ val migrationHelper = MigrationTestHelper(
+ InstrumentationRegistry.getInstrumentation(),
+ SagerDatabase::class.java.canonicalName,
+ FrameworkSQLiteOpenHelperFactory()
+ )
+
+ @Test
+ fun migratesVersion8WithoutChangingLegacyRowsAndLeavesRouterStateEmpty() {
+ migrationHelper.createDatabase(TEST_DB, 8).apply {
+ execSQL(
+ "INSERT INTO proxy_groups " +
+ "(id, userOrder, ungrouped, name, type, subscription, `order`, isSelector, frontProxy, landingProxy) " +
+ "VALUES (1, 5, 0, 'legacy group', 0, NULL, 0, 0, -1, -1)"
+ )
+ execSQL(
+ "INSERT INTO proxy_entities " +
+ "(id, groupId, type, userOrder, tx, rx, status, ping, uuid) " +
+ "VALUES (2, 1, 0, 7, 11, 13, 0, 42, 'legacy-proxy')"
+ )
+ execSQL(
+ "INSERT INTO rules " +
+ "(id, name, userOrder, enabled, domains, ip, port, sourcePort, network, source, protocol, outbound, packages) " +
+ "VALUES (3, 'legacy rule', 9, 1, 'example.com', '', '', '', '', '', '', 2, '')"
+ )
+ close()
+ }
+
+ migrationHelper.runMigrationsAndValidate(
+ TEST_DB,
+ 9,
+ true,
+ SagerDatabase_AutoMigration_8_9_Impl()
+ ).use { database ->
+ assertEquals(1L, database.singleLong("SELECT COUNT(*) FROM proxy_groups"))
+ assertEquals(1L, database.singleLong("SELECT COUNT(*) FROM proxy_entities"))
+ assertEquals(1L, database.singleLong("SELECT COUNT(*) FROM rules"))
+ assertEquals(1L, database.singleLong("SELECT id FROM proxy_groups WHERE name = 'legacy group'"))
+ assertEquals(2L, database.singleLong("SELECT id FROM proxy_entities WHERE uuid = 'legacy-proxy'"))
+ assertEquals(3L, database.singleLong("SELECT id FROM rules WHERE name = 'legacy rule'"))
+ assertEquals(0L, database.singleLong("SELECT COUNT(*) FROM router_groups"))
+ assertEquals(0L, database.singleLong("SELECT COUNT(*) FROM router_members"))
+ }
+ }
+
+ @Test
+ fun migratesVersion9WithoutCreatingGroupsOrChangingLegacyRoutes() {
+ migrationHelper.createDatabase(TEST_DB, 9).apply {
+ execSQL(
+ "INSERT INTO rules " +
+ "(id, name, userOrder, enabled, domains, ip, port, sourcePort, network, source, protocol, outbound, packages, config, ruleset) " +
+ "VALUES (1, 'legacy', 0, 1, '', '', '', '', '', '', '', -1, '', '', '')"
+ )
+ close()
+ }
+
+ migrationHelper.runMigrationsAndValidate(
+ TEST_DB,
+ 10,
+ true,
+ SagerDatabase_AutoMigration_9_10_Impl()
+ ).use { database ->
+ assertEquals(0L, database.singleLong("SELECT routerGroupId FROM rules WHERE id = 1"))
+ assertEquals(-1L, database.singleLong("SELECT outbound FROM rules WHERE id = 1"))
+ assertEquals(0L, database.singleLong("SELECT COUNT(*) FROM router_groups"))
+ assertEquals(0L, database.singleLong("SELECT COUNT(*) FROM router_group_sources"))
+ }
+ }
+
+ @Test
+ fun sourcesAndMembersCanBeSharedAcrossRouters() {
+ val context = InstrumentationRegistry.getInstrumentation().targetContext
+ val database = Room.inMemoryDatabaseBuilder(context, SagerDatabase::class.java)
+ .allowMainThreadQueries()
+ .build()
+
+ try {
+ val routerA = database.routerGroupDao().create(RouterGroup(stableTag = "router.a"))
+ val routerB = database.routerGroupDao().create(RouterGroup(stableTag = "router.b"))
+ database.routerGroupSourceDao().insert(
+ listOf(RouterGroupSource(routerA, 10), RouterGroupSource(routerB, 10))
+ )
+ database.routerMemberDao().insert(
+ listOf(RouterMember(routerA, 20), RouterMember(routerB, 20))
+ )
+
+ assertEquals(
+ listOf(routerA, routerB),
+ database.routerGroupSourceDao().routersForSource(10).map { it.routerId }
+ )
+ assertEquals(listOf(20L), database.routerMemberDao().getByRouter(routerA).map { it.proxyId })
+ assertEquals(listOf(20L), database.routerMemberDao().getByRouter(routerB).map { it.proxyId })
+ } finally {
+ database.close()
+ }
+ }
+
+ @Test
+ fun memberDaoOrdersReplacesAndCleansMembersByProxy() {
+ val context = InstrumentationRegistry.getInstrumentation().targetContext
+ val database = Room.inMemoryDatabaseBuilder(context, SagerDatabase::class.java)
+ .allowMainThreadQueries()
+ .build()
+
+ try {
+ val routerId = database.routerGroupDao().create(RouterGroup(stableTag = "router.us"))
+ val members = database.routerMemberDao()
+
+ members.replaceMembers(
+ routerId,
+ listOf(
+ RouterMember(proxyId = 30, userOrder = 2),
+ RouterMember(proxyId = 10, userOrder = 1)
+ )
+ )
+ assertEquals(listOf(10L, 30L), members.getByRouter(routerId).map { it.proxyId })
+
+ assertEquals(1, members.deleteByProxy(10))
+ assertEquals(listOf(30L), members.getByRouter(routerId).map { it.proxyId })
+
+ members.replaceMembers(routerId, listOf(RouterMember(proxyId = 50, userOrder = 0)))
+ assertEquals(listOf(50L), members.getByRouter(routerId).map { it.proxyId })
+ } finally {
+ database.close()
+ }
+ }
+
+ private fun SupportSQLiteDatabase.singleLong(sql: String): Long = query(sql).use { cursor ->
+ check(cursor.moveToFirst()) { "Expected one row for query: $sql" }
+ cursor.getLong(0)
+ }
+
+ @Test
+ fun clearsDanglingSelectionsIncludingDisabledRoutersWithoutChangingValidSelections() {
+ val context = InstrumentationRegistry.getInstrumentation().targetContext
+ val database = Room.inMemoryDatabaseBuilder(context, SagerDatabase::class.java)
+ .allowMainThreadQueries()
+ .build()
+ try {
+ database.openHelper.writableDatabase.execSQL(
+ "INSERT INTO proxy_entities " +
+ "(id, groupId, type, userOrder, tx, rx, status, ping, uuid) " +
+ "VALUES (20, 10, 0, 0, 0, 0, 0, 0, 'node')"
+ )
+ val routers = database.routerGroupDao()
+ val valid = routers.create(RouterGroup(
+ stableTag = "router.valid", selectedProxyId = 20, selectedNodeKey = "10:node",
+ ))
+ val deleted = routers.create(RouterGroup(
+ stableTag = "router.deleted", enabled = false,
+ selectedProxyId = 30, selectedNodeKey = "10:deleted",
+ ))
+ val nonMember = routers.create(RouterGroup(
+ stableTag = "router.nonmember", selectedProxyId = 20, selectedNodeKey = "10:node",
+ ))
+ database.routerMemberDao().insert(listOf(RouterMember(valid, 20), RouterMember(deleted, 30)))
+
+ assertEquals(2, routers.clearInvalidSelections())
+ assertEquals(20L, routers.getById(valid)!!.selectedProxyId)
+ assertEquals("10:node", routers.getById(valid)!!.selectedNodeKey)
+ for (id in listOf(deleted, nonMember)) {
+ assertEquals(RouterGroup.NO_SELECTION, routers.getById(id)!!.selectedProxyId)
+ assertEquals("", routers.getById(id)!!.selectedNodeKey)
+ }
+ } finally {
+ database.close()
+ }
+ }
+
+ private companion object {
+ const val TEST_DB = "router-migration-test"
+ }
+}
diff --git a/app/src/androidTest/java/io/nekohasekai/sagernet/ui/BackupSerializationTest.kt b/app/src/androidTest/java/io/nekohasekai/sagernet/ui/BackupSerializationTest.kt
new file mode 100644
index 0000000000..fa7d1857e5
--- /dev/null
+++ b/app/src/androidTest/java/io/nekohasekai/sagernet/ui/BackupSerializationTest.kt
@@ -0,0 +1,124 @@
+package io.nekohasekai.sagernet.ui
+
+import io.nekohasekai.sagernet.database.RouterGroup
+import io.nekohasekai.sagernet.database.RouterMember
+import io.nekohasekai.sagernet.database.RouterGroupSource
+import io.nekohasekai.sagernet.database.RuleEntity
+import io.nekohasekai.sagernet.fmt.BackupSerializer
+import org.json.JSONArray
+import org.json.JSONObject
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import androidx.room.Room
+import androidx.test.platform.app.InstrumentationRegistry
+import io.nekohasekai.sagernet.database.ProxyEntity
+import io.nekohasekai.sagernet.database.ProxyGroup
+import io.nekohasekai.sagernet.database.SagerDatabase
+import org.junit.Assert.assertThrows
+
+class BackupSerializationTest {
+
+ @Test
+ fun exportOfDamagedProfileFailsWithoutDeletingProfilesMembersOrRules() {
+ val context = InstrumentationRegistry.getInstrumentation().targetContext
+ val database = Room.inMemoryDatabaseBuilder(context, SagerDatabase::class.java).build()
+ try {
+ database.groupDao().insert(listOf(ProxyGroup(id = 10, ungrouped = true)))
+ database.proxyDao().insert(listOf(ProxyEntity(id = 20, groupId = 10)))
+ database.routerGroupDao().insert(listOf(RouterGroup(id = 30, stableTag = "router.test")))
+ database.routerMemberDao().insert(listOf(RouterMember(30, 20)))
+ database.rulesDao().insert(listOf(RuleEntity(id = 40, routerGroupId = 30)))
+ assertThrows(IllegalStateException::class.java) {
+ BackupSerializer.exportDatabase(database, true, true)
+ }
+ assertEquals(listOf(20L), database.proxyDao().getAll().map { it.id })
+ assertEquals(listOf(20L), database.routerMemberDao().all().map { it.proxyId })
+ assertEquals(30L, database.rulesDao().allRules().single().routerGroupId)
+ } finally {
+ database.close()
+ }
+ }
+
+ @Test
+ fun routerGroupsMembersSourcesAndRuleReferencesSurviveBackupRoundTrip() {
+ val group = RouterGroup(
+ id = 42L,
+ stableTag = "router.us",
+ name = "US",
+ mode = RouterGroup.MODE_URL_TEST,
+ enabled = true,
+ matchConfig = "{\"regions\":[\"US\"]}",
+ selectedProxyId = 7L,
+ userOrder = 3L
+ )
+ val member = RouterMember(
+ routerId = group.id,
+ proxyId = group.selectedProxyId,
+ userOrder = 1L,
+ lastMatchedAt = 123456789L
+ )
+ val source = RouterGroupSource(group.id, 99L, 2L)
+ val rule = RuleEntity(id = 8L, name = "AI", routerGroupId = group.id)
+
+ val backup = JSONObject().apply {
+ BackupSerializer.putParcelableArray(this, "routerGroups", listOf(group))
+ BackupSerializer.putParcelableArray(this, "routerMembers", listOf(member))
+ BackupSerializer.putParcelableArray(this, "routerSources", listOf(source))
+ BackupSerializer.putRouterRuleReferences(this, listOf(rule))
+ }
+
+ assertEquals(
+ listOf(group),
+ BackupSerializer.getParcelableArray(backup, "routerGroups", RouterGroup.CREATOR)
+ )
+ assertEquals(
+ listOf(member),
+ BackupSerializer.getParcelableArray(backup, "routerMembers", RouterMember.CREATOR)
+ )
+ assertEquals(
+ listOf(source),
+ BackupSerializer.getParcelableArray(backup, "routerSources", RouterGroupSource.CREATOR)
+ )
+ assertEquals(mapOf(rule.id to group.id), BackupSerializer.getRouterRuleReferences(backup))
+ }
+
+ @Test
+ fun legacyBackupWithoutRouterArraysIsAcceptedAndLeavesLegacySectionsUntouched() {
+ val legacyRules = JSONArray().put("legacy-adblock").put("legacy.invalid")
+ val legacySettings = JSONArray().put("base-setting")
+ val backup = JSONObject().apply {
+ put("version", 1)
+ put("profiles", JSONArray())
+ put("groups", JSONArray())
+ put("rules", legacyRules)
+ put("settings", legacySettings)
+ }
+
+ assertFalse(backup.has("routerGroups"))
+ assertFalse(backup.has("routerMembers"))
+ assertTrue(BackupSerializer.getRouterRuleReferences(backup).isEmpty())
+ assertTrue(
+ BackupSerializer.getParcelableArray(
+ backup,
+ "routerGroups",
+ RouterGroup.CREATOR
+ ).isEmpty()
+ )
+ assertEquals(legacyRules.toString(), backup.getJSONArray("rules").toString())
+ assertEquals(legacySettings.toString(), backup.getJSONArray("settings").toString())
+ }
+
+ @Test
+ fun versionThreeRoundTripsRelationsAndVersionTwoDefaultsThem() {
+ val json = JSONObject().put("version", 3)
+ BackupSerializer.putParcelableArray(json, "routerSources", listOf(RouterGroupSource(1, 10), RouterGroupSource(2, 10)))
+ assertEquals(
+ listOf(RouterGroupSource(1, 10), RouterGroupSource(2, 10)),
+ BackupSerializer.getParcelableArray(json, "routerSources", RouterGroupSource.CREATOR),
+ )
+ val old = JSONObject().put("version", 2)
+ assertTrue(BackupSerializer.getParcelableArray(old, "routerSources", RouterGroupSource.CREATOR).isEmpty())
+ }
+}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index a693284357..3d68621237 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -7,6 +7,7 @@
@@ -22,7 +23,7 @@
+ tools:ignore="PackageVisibilityPolicy,QueryAllPackagesPermission" />
@@ -50,7 +51,6 @@
+
+
+
@@ -281,12 +290,6 @@
android:launchMode="singleInstance"
android:theme="@style/Theme.SagerNet.Dialog" />
-
-
false // DataStore.directBootAware
- else -> Build.VERSION.SDK_INT < 24 || SagerNet.user.isUserUnlocked
+ else -> SagerNet.user.isUserUnlocked
} && DataStore.selectedProxy > 0
if (doStart) SagerNet.startService()
diff --git a/app/src/main/java/io/nekohasekai/sagernet/Constants.kt b/app/src/main/java/io/nekohasekai/sagernet/Constants.kt
index 1b2e8347aa..835a58a724 100644
--- a/app/src/main/java/io/nekohasekai/sagernet/Constants.kt
+++ b/app/src/main/java/io/nekohasekai/sagernet/Constants.kt
@@ -47,6 +47,8 @@ object Key {
const val ALLOW_ACCESS = "allowAccess"
const val SPEED_INTERVAL = "speedInterval"
const val SHOW_DIRECT_SPEED = "showDirectSpeed"
+ const val SHOW_PROFILE_IN_NOTIFICATION = "showProfileInNotification"
+ const val VIEW_MODE_ROUTER_GROUPS = "viewModeRouterGroups"
const val APPEND_HTTP_PROXY = "appendHttpProxy"
const val HTTP_PROXY_BYPASS = "httpProxyBypass"
@@ -73,15 +75,25 @@ object Key {
const val ACQUIRE_WAKE_LOCK = "acquireWakeLock"
const val HIDE_FROM_RECENT_APPS = "hideFromRecentApps"
- const val SHOW_BOTTOM_BAR = "showBottomBar"
const val CONFIRM_PROFILE_DELETE = "confirmProfileDelete"
const val GROUP_LAYOUT_MODE = "groupLayoutMode"
const val PROFILE_CARD_STYLE = "profileCardStyle"
const val ALLOW_INSECURE_ON_REQUEST = "allowInsecureOnRequest"
+ val DEPRECATED_SETTING_KEYS = setOf(
+ APPEND_HTTP_PROXY,
+ HTTP_PROXY_BYPASS,
+ ENABLE_TLS_FRAGMENT,
+ FRAGMENT_LENGTH,
+ FRAGMENT_INTERVAL,
+ "enableHevTun",
+ "showBottomBar",
+ "meteredNetwork",
+ "wakeResetConnections",
+ )
+
const val TUN_IMPLEMENTATION = "tunImplementation"
- const val ENABLE_HEV_TUN = "enableHevTun"
const val PROFILE_TRAFFIC_STATISTICS = "profileTrafficStatistics"
const val PROFILE_DIRTY = "profileDirty"
@@ -155,6 +167,7 @@ object Key {
const val ROUTE_PROTOCOL = "routeProtocol"
const val ROUTE_RULESET = "routeRuleset"
const val ROUTE_OUTBOUND = "routeOutbound"
+ const val ROUTE_OUTBOUND_ROUTER = "routeOutboundRouter"
const val ROUTE_PACKAGES = "routePackages"
const val GROUP_NAME = "groupName"
@@ -187,11 +200,6 @@ object Key {
const val FRAGMENT_LENGTH = "fragmentLength"
const val FRAGMENT_INTERVAL = "fragmentInterval"
- const val WEBDAV_SERVER = "webdavServer"
- const val WEBDAV_USERNAME = "webdavUsername"
- const val WEBDAV_PASSWORD = "webdavPassword"
- const val WEBDAV_PATH = "webdavPath"
-
const val GLOBAL_MODE = "globalMode"
}
@@ -199,6 +207,7 @@ object TunImplementation {
const val GVISOR = 0
const val SYSTEM = 1
const val MIXED = 2
+ const val GO = 3
}
object IPv6Mode {
@@ -232,4 +241,8 @@ object Action {
// const val SWITCH_WAKE_LOCK = "io.nekohasekai.sagernet.SWITCH_WAKELOCK"
const val RESET_UPSTREAM_CONNECTIONS = "moe.nb4a.RESET_UPSTREAM_CONNECTIONS"
+
+ const val EXTRA_ROUTER_TAG = "routerTag"
+ const val EXTRA_ROUTER_PROXY_ID = "routerProxyId"
+ const val EXTRA_FORCE_FULL_RELOAD = "forceFullReload"
}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/QuickToggleShortcut.kt b/app/src/main/java/io/nekohasekai/sagernet/QuickToggleShortcut.kt
index cb65a1fe38..6e79051354 100644
--- a/app/src/main/java/io/nekohasekai/sagernet/QuickToggleShortcut.kt
+++ b/app/src/main/java/io/nekohasekai/sagernet/QuickToggleShortcut.kt
@@ -23,7 +23,6 @@ package io.nekohasekai.sagernet
import android.app.Activity
import android.content.Intent
import android.content.pm.ShortcutManager
-import android.os.Build
import android.os.Bundle
import androidx.core.content.getSystemService
import androidx.core.content.pm.ShortcutInfoCompat
@@ -54,9 +53,7 @@ class QuickToggleShortcut : Activity(), SagerConnection.Callback {
} else {
profileId = intent.getLongExtra("profile", -1L)
connection.connect(this, this)
- if (Build.VERSION.SDK_INT >= 25) {
- getSystemService()!!.reportShortcutUsed(if (profileId >= 0) "shortcut-profile-$profileId" else "toggle")
- }
+ getSystemService()!!.reportShortcutUsed(if (profileId >= 0) "shortcut-profile-$profileId" else "toggle")
}
}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/SagerNet.kt b/app/src/main/java/io/nekohasekai/sagernet/SagerNet.kt
index e86ec9463d..c098ddfcdf 100644
--- a/app/src/main/java/io/nekohasekai/sagernet/SagerNet.kt
+++ b/app/src/main/java/io/nekohasekai/sagernet/SagerNet.kt
@@ -9,15 +9,14 @@ import android.content.Intent
import android.content.res.Configuration
import android.net.ConnectivityManager
import android.net.Network
-import android.os.Build
import android.os.PowerManager
import android.os.StrictMode
import android.os.UserManager
-import androidx.annotation.RequiresApi
import androidx.core.content.ContextCompat
import androidx.core.content.getSystemService
import go.Seq
import io.nekohasekai.sagernet.bg.SagerConnection
+import io.nekohasekai.sagernet.bg.ServiceNotification
import io.nekohasekai.sagernet.database.DataStore
import io.nekohasekai.sagernet.ktx.Logs
import io.nekohasekai.sagernet.ktx.isOss
@@ -138,7 +137,7 @@ class SagerNet : Application(),
Intent(
application, MainActivity::class.java
).setFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT),
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0
+ PendingIntent.FLAG_IMMUTABLE
)
}
}
@@ -164,39 +163,49 @@ class SagerNet : Application(),
}
fun updateNotificationChannels() {
- if (Build.VERSION.SDK_INT >= 26) @RequiresApi(26) {
- notification.createNotificationChannels(
- listOf(
- NotificationChannel(
- "service-vpn",
- application.getText(R.string.service_vpn),
- if (Build.VERSION.SDK_INT >= 28) NotificationManager.IMPORTANCE_MIN
- else NotificationManager.IMPORTANCE_LOW
- ), // #1355
- NotificationChannel(
- "service-proxy",
- application.getText(R.string.service_proxy),
- NotificationManager.IMPORTANCE_LOW
- ), NotificationChannel(
- "service-subscription",
- application.getText(R.string.service_subscription),
- NotificationManager.IMPORTANCE_DEFAULT
- ), NotificationChannel(
- "connection-test",
- application.getText(R.string.connection_test),
- NotificationManager.IMPORTANCE_DEFAULT
- )
+ val vpnNotificationPolicy = ServiceNotification.vpnNotificationChannelPolicy()
+ notification.createNotificationChannels(
+ listOf(
+ NotificationChannel(
+ ServiceNotification.vpnNotificationChannel,
+ application.getText(R.string.service_vpn),
+ vpnNotificationPolicy.importance
+ ).apply {
+ setLockscreenVisibility(vpnNotificationPolicy.lockscreenVisibility)
+ }, // #1355
+ NotificationChannel(
+ "service-proxy",
+ application.getText(R.string.service_proxy),
+ NotificationManager.IMPORTANCE_LOW
+ ), NotificationChannel(
+ "service-subscription",
+ application.getText(R.string.service_subscription),
+ NotificationManager.IMPORTANCE_DEFAULT
+ ), NotificationChannel(
+ "connection-test",
+ application.getText(R.string.connection_test),
+ NotificationManager.IMPORTANCE_DEFAULT
)
)
- }
+ )
}
fun startService() = ContextCompat.startForegroundService(
application, Intent(application, SagerConnection.serviceClass)
)
- fun reloadService() =
- application.sendBroadcast(Intent(Action.RELOAD).setPackage(application.packageName))
+ fun reloadService(routerTag: String? = null, routerProxyId: Long? = null) =
+ application.sendBroadcast(Intent(Action.RELOAD).setPackage(application.packageName).apply {
+ if (routerTag != null && routerProxyId != null) {
+ putExtra(Action.EXTRA_ROUTER_TAG, routerTag)
+ putExtra(Action.EXTRA_ROUTER_PROXY_ID, routerProxyId)
+ }
+ })
+
+ fun reloadServiceFully() =
+ application.sendBroadcast(Intent(Action.RELOAD).setPackage(application.packageName).apply {
+ putExtra(Action.EXTRA_FORCE_FULL_RELOAD, true)
+ })
fun stopService() =
application.sendBroadcast(Intent(Action.CLOSE).setPackage(application.packageName))
diff --git a/app/src/main/java/io/nekohasekai/sagernet/aidl/SpeedDisplayData.kt b/app/src/main/java/io/nekohasekai/sagernet/aidl/SpeedDisplayData.kt
index 5cafbf33e7..e5baf504fa 100644
--- a/app/src/main/java/io/nekohasekai/sagernet/aidl/SpeedDisplayData.kt
+++ b/app/src/main/java/io/nekohasekai/sagernet/aidl/SpeedDisplayData.kt
@@ -15,4 +15,7 @@ data class SpeedDisplayData(
// Outbound "bypass" usage is not counted
var txTotal: Long = 0L,
var rxTotal: Long = 0L,
+
+ // Runtime-only Router URL_TEST selections as routerId/profileId pairs.
+ var urlTestSelections: LongArray = longArrayOf(),
) : Parcelable
diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/BaseService.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/BaseService.kt
index c028fa610f..9c3d5e52dd 100644
--- a/app/src/main/java/io/nekohasekai/sagernet/bg/BaseService.kt
+++ b/app/src/main/java/io/nekohasekai/sagernet/bg/BaseService.kt
@@ -7,6 +7,7 @@ import android.content.IntentFilter
import android.os.*
import android.app.ActivityManager
import android.widget.Toast
+import androidx.core.content.ContextCompat
import io.nekohasekai.sagernet.Action
import io.nekohasekai.sagernet.BootReceiver
import io.nekohasekai.sagernet.R
@@ -15,9 +16,16 @@ import io.nekohasekai.sagernet.aidl.ISagerNetService
import io.nekohasekai.sagernet.aidl.ISagerNetServiceCallback
import io.nekohasekai.sagernet.bg.proto.ProxyInstance
import io.nekohasekai.sagernet.database.DataStore
+import io.nekohasekai.sagernet.database.RouterGroup
import io.nekohasekai.sagernet.database.SagerDatabase
import io.nekohasekai.sagernet.ktx.*
import io.nekohasekai.sagernet.plugin.PluginManager
+import io.nekohasekai.sagernet.route.RouterRuntimeMode
+import io.nekohasekai.sagernet.route.RouterSelection
+import io.nekohasekai.sagernet.route.RouterSelectionPlan
+import io.nekohasekai.sagernet.route.RouterSelectionRequest
+import io.nekohasekai.sagernet.route.routerNodeKey
+import io.nekohasekai.sagernet.route.routerStableIdOrFallback
import io.nekohasekai.sagernet.utils.DefaultNetworkListener
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex
@@ -26,6 +34,7 @@ import libcore.Libcore
import moe.matsuri.nb4a.Protocols
import moe.matsuri.nb4a.utils.Util
import java.net.UnknownHostException
+import java.util.concurrent.ConcurrentHashMap
class BaseService {
@@ -43,24 +52,26 @@ class BaseService {
interface ExpectedException
class Data internal constructor(private val service: Interface) {
- var state = State.Stopped
- var proxy: ProxyInstance? = null
+ @Volatile var state = State.Stopped
+ @Volatile var proxy: ProxyInstance? = null
var notification: ServiceNotification? = null
val receiver = broadcastReceiver { ctx, intent ->
when (intent.action) {
Intent.ACTION_SHUTDOWN -> service.persistStats()
- Action.RELOAD -> service.reload()
+ Action.RELOAD -> service.reload(
+ intent.getStringExtra(Action.EXTRA_ROUTER_TAG),
+ intent.getLongExtra(Action.EXTRA_ROUTER_PROXY_ID, 0L).takeIf { it > 0L },
+ intent.getBooleanExtra(Action.EXTRA_FORCE_FULL_RELOAD, false),
+ )
// Action.SWITCH_WAKE_LOCK -> runOnDefaultDispatcher { service.switchWakeLock() }
PowerManager.ACTION_DEVICE_IDLE_MODE_CHANGED -> {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
- if (SagerNet.power.isDeviceIdleMode) {
- proxy?.box?.sleep()
- } else {
- proxy?.box?.wake()
- if (DataStore.wakeResetConnections) {
- Libcore.resetAllConnections(true)
- }
+ if (SagerNet.power.isDeviceIdleMode) {
+ proxy?.box?.sleep()
+ } else {
+ proxy?.box?.wake()
+ if (DataStore.wakeResetConnections) {
+ Libcore.resetAllConnections(true)
}
}
}
@@ -95,15 +106,19 @@ class BaseService {
private val callbacks = object : RemoteCallbackList() {
override fun onCallbackDied(callback: ISagerNetServiceCallback?, cookie: Any?) {
super.onCallbackDied(callback, cookie)
+ callback?.let(callbackIdMap::remove)
}
}
- val callbackIdMap = mutableMapOf()
+ val callbackIdMap = ConcurrentHashMap()
override val coroutineContext = Dispatchers.Main.immediate + Job()
override fun getState(): Int = (data?.state ?: State.Idle).ordinal
override fun getProfileName(): String = data?.proxy?.displayProfileName ?: "Idle"
+ override fun getCurrentUrlTestSelections(): LongArray =
+ data?.takeIf { it.state == State.Connected }?.proxy?.currentUrlTestSelections()
+ ?: longArrayOf()
override fun registerCallback(cb: ISagerNetServiceCallback, id: Int) {
if (id == SagerConnection.CONNECTION_ID_RESTART_BG) {
@@ -114,6 +129,9 @@ class BaseService {
callbacks.register(cb)
}
callbackIdMap[cb] = id
+ if (id == SagerConnection.CONNECTION_ID_MAIN_ACTIVITY_FOREGROUND) {
+ data?.proxy?.looper?.requestUpdate()
+ }
}
private val broadcastMutex = Mutex()
@@ -171,6 +189,7 @@ class BaseService {
override fun close() {
callbacks.kill()
+ callbackIdMap.clear()
cancel()
data = null
}
@@ -184,11 +203,18 @@ class BaseService {
fun onBind(intent: Intent): IBinder? =
if (intent.action == Action.SERVICE) data.binder else null
- fun reload() {
+ fun reload(
+ routerTag: String? = null,
+ routerProxyId: Long? = null,
+ forceFullReload: Boolean = false,
+ ) {
if (DataStore.selectedProxy == 0L) {
stopRunner(false, (this as Context).getString(R.string.profile_empty))
+ return
}
- if (canReloadSelector()) {
+ val routerReloadRequested = routerTag != null && routerProxyId != null
+ if (routerReloadRequested && trySelectRouter(routerTag!!, routerProxyId!!)) return
+ if (!forceFullReload && !routerReloadRequested && canReloadSelector()) {
val ent = SagerDatabase.proxyDao.getById(DataStore.selectedProxy)
val tag = data.proxy!!.config.profileTagMap[ent?.id] ?: ""
if (tag.isNotBlank() && ent != null) {
@@ -207,6 +233,45 @@ class BaseService {
}
}
+ private fun trySelectRouter(routerTag: String, proxyId: Long): Boolean {
+ val runningProxy = data.proxy ?: return false
+ if (routerTag.isBlank()) return false
+ val router = SagerDatabase.routerGroupDao.getByStableTag(routerTag)
+ ?.takeIf { it.enabled && it.stableTag.isNotBlank() && it.stableTag == routerTag }
+ ?: return false
+ if (runningProxy.config.routerSelectorTags[routerTag].isNullOrBlank()) return false
+ val plan = RouterSelection.plan(
+ request = RouterSelectionRequest(
+ routerTag = routerTag,
+ proxyId = proxyId,
+ mode = if (router.mode == RouterGroup.MODE_URL_TEST) {
+ RouterRuntimeMode.URL_TEST
+ } else {
+ RouterRuntimeMode.SELECTOR
+ },
+ routerEnabled = router.enabled,
+ ),
+ routerSelectorTags = runningProxy.config.routerSelectorTags,
+ routerMemberIds = runningProxy.config.routerMemberIds,
+ profileTags = runningProxy.config.profileTagMap,
+ selectorGroupId = runningProxy.config.selectorGroupId,
+ )
+ if (plan !is RouterSelectionPlan.HotSwitch) return false
+ if (!runningProxy.isInitialized() || !runningProxy.box.selectOutboundFor(plan.selectorTag, plan.targetTag)) {
+ return false
+ }
+ val selected = SagerDatabase.proxyDao.getById(proxyId) ?: return false
+ SagerDatabase.routerGroupDao.updateSelection(
+ routerId = router.id,
+ selectedProxyId = proxyId,
+ selectedNodeKey = routerNodeKey(
+ selected.groupId,
+ routerStableIdOrFallback(selected.uuid, selected.id),
+ ),
+ )
+ return true
+ }
+
fun canReloadSelector(): Boolean {
if ((data.proxy?.config?.selectorGroupId ?: -1L) < 0) return false
val ent = SagerDatabase.proxyDao.getById(DataStore.selectedProxy) ?: return false
@@ -224,8 +289,7 @@ class BaseService {
fun startRunner() {
this as Context
- if (Build.VERSION.SDK_INT >= 26) startForegroundService(Intent(this, javaClass))
- else startService(Intent(this, javaClass))
+ startForegroundService(Intent(this, javaClass))
}
fun killProcesses() {
@@ -281,20 +345,39 @@ class BaseService {
var upstreamInterfaceName: String?
suspend fun preInit() {
- DefaultNetworkListener.start(this) {
- SagerNet.connectivity.getLinkProperties(it)?.also { link ->
- SagerNet.underlyingNetwork = it
+ var previousNetwork: android.net.Network? = null
+ var hadNetwork = false
+ DefaultNetworkListener.start(this) { network ->
+ if (network == null) {
+ previousNetwork = null
+ SagerNet.underlyingNetwork = null
+ return@start
+ }
+ SagerNet.connectivity.getLinkProperties(network)?.also { link ->
+ val networkChanged = hadNetwork && previousNetwork != network
+ previousNetwork = network
+ hadNetwork = true
+ SagerNet.underlyingNetwork = network
DataStore.vpnService?.updateUnderlyingNetwork()
//
val oldName = upstreamInterfaceName
if (oldName != link.interfaceName) {
upstreamInterfaceName = link.interfaceName
}
- if (oldName != null && upstreamInterfaceName != null && oldName != upstreamInterfaceName) {
+ if (networkChanged || (oldName != null && upstreamInterfaceName != null && oldName != upstreamInterfaceName)) {
Logs.d("Network changed: $oldName -> $upstreamInterfaceName")
if (DataStore.networkChangeResetConnections) {
Libcore.resetAllConnections(true)
}
+ val runningProxy = data.proxy
+ if (data.state == State.Connected && runningProxy?.isInitialized() == true) {
+ data.binder.launch(Dispatchers.IO) {
+ if (data.state != State.Connected || data.proxy !== runningProxy) return@launch
+ runningProxy.config.routerUrlTestTags.values.distinct().forEach {
+ runningProxy.box.refreshURLTestFor(it)
+ }
+ }
+ }
}
}
}
@@ -339,32 +422,18 @@ class BaseService {
addAction(Intent.ACTION_SHUTDOWN)
addAction(Action.CLOSE)
// addAction(Action.SWITCH_WAKE_LOCK)
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
- addAction(PowerManager.ACTION_DEVICE_IDLE_MODE_CHANGED)
- }
+ addAction(PowerManager.ACTION_DEVICE_IDLE_MODE_CHANGED)
addAction(Action.RESET_UPSTREAM_CONNECTIONS)
}
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
- registerReceiver(
- data.receiver,
- filter,
- "$packageName.SERVICE",
- null,
- Context.RECEIVER_EXPORTED
- )
- } else {
- registerReceiver(
- data.receiver,
- filter,
- "$packageName.SERVICE",
- null
- )
- }
+ ContextCompat.registerReceiver(
+ this, data.receiver, filter, "$packageName.SERVICE", null,
+ ContextCompat.RECEIVER_NOT_EXPORTED,
+ )
data.closeReceiverRegistered = true
}
data.changeState(State.Connecting)
- runOnMainDispatcher {
+ data.connectingJob = data.binder.launch(start = CoroutineStart.LAZY) {
try {
data.notification = createNotification(ServiceNotification.genTitle(profile))
@@ -404,6 +473,7 @@ class BaseService {
data.connectingJob = null
}
}
+ data.connectingJob?.start()
return Service.START_NOT_STICKY
}
}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/GuardedProcessPool.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/GuardedProcessPool.kt
index cded1cabb1..cbf9fbab30 100644
--- a/app/src/main/java/io/nekohasekai/sagernet/bg/GuardedProcessPool.kt
+++ b/app/src/main/java/io/nekohasekai/sagernet/bg/GuardedProcessPool.kt
@@ -1,9 +1,6 @@
package io.nekohasekai.sagernet.bg
-import android.os.Build
import android.os.SystemClock
-import android.system.ErrnoException
-import android.system.Os
import android.system.OsConstants
import androidx.annotation.MainThread
import io.nekohasekai.sagernet.SagerNet
@@ -18,12 +15,6 @@ import java.io.InputStream
import kotlin.concurrent.thread
class GuardedProcessPool(private val onFatal: suspend (IOException) -> Unit) : CoroutineScope {
- companion object {
- private val pid by lazy {
- Class.forName("java.lang.ProcessManager\$ProcessImpl").getDeclaredField("pid")
- .apply { isAccessible = true }
- }
- }
private inner class Guard(
private val cmd: List,
@@ -78,21 +69,9 @@ class GuardedProcessPool(private val onFatal: suspend (IOException) -> Unit) : C
GlobalScope.launch(Dispatchers.Main) { onFatal(e) }
} finally {
if (running) withContext(NonCancellable) { // clean-up cannot be cancelled
- if (Build.VERSION.SDK_INT < 24) {
- try {
- Os.kill(pid.get(process) as Int, OsConstants.SIGTERM)
- } catch (e: ErrnoException) {
- if (e.errno != OsConstants.ESRCH) Logs.w(e)
- } catch (e: ReflectiveOperationException) {
- Logs.w(e)
- }
- if (withTimeoutOrNull(500) { exitChannel.receive() } != null) return@withContext
- }
process.destroy() // kill the process
- if (Build.VERSION.SDK_INT >= 26) {
- if (withTimeoutOrNull(1000) { exitChannel.receive() } != null) return@withContext
- process.destroyForcibly() // Force to kill the process if it's still alive
- }
+ if (withTimeoutOrNull(1000) { exitChannel.receive() } != null) return@withContext
+ process.destroyForcibly() // Force to kill the process if it's still alive
exitChannel.receive()
} // otherwise process already exited, nothing to be done
}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/ProxyService.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/ProxyService.kt
index 448ac2699a..0cf11c2301 100644
--- a/app/src/main/java/io/nekohasekai/sagernet/bg/ProxyService.kt
+++ b/app/src/main/java/io/nekohasekai/sagernet/bg/ProxyService.kt
@@ -21,7 +21,12 @@ class ProxyService : Service(), BaseService.Interface {
.apply { acquire() }
}
- override fun onBind(intent: Intent) = super.onBind(intent)
+ override fun onBind(intent: Intent) = super.onBind(intent)
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int =
super.onStartCommand(intent, flags, startId)
+
+ override fun onDestroy() {
+ data.binder.close()
+ super.onDestroy()
+ }
}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/ServiceNotification.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/ServiceNotification.kt
index 0ac84ac6a3..ba39084de0 100644
--- a/app/src/main/java/io/nekohasekai/sagernet/bg/ServiceNotification.kt
+++ b/app/src/main/java/io/nekohasekai/sagernet/bg/ServiceNotification.kt
@@ -1,5 +1,6 @@
package io.nekohasekai.sagernet.bg
+import android.app.Notification
import android.app.PendingIntent
import android.app.Service
import android.content.BroadcastReceiver
@@ -7,7 +8,7 @@ import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_SYSTEM_EXEMPTED
-import android.os.Build
+import android.os.PowerManager
import android.text.format.Formatter
import android.widget.Toast
import androidx.core.app.NotificationCompat
@@ -39,23 +40,90 @@ import kotlinx.coroutines.sync.withLock
*/
class ServiceNotification(
private val service: BaseService.Interface, title: String,
- channel: String, visible: Boolean = false,
+ channel: String, private val visible: Boolean = false,
) : BroadcastReceiver() {
companion object {
const val notificationId = 1
- val flags =
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0
+ const val vpnNotificationChannel = "service-vpn-hidden"
+ const val flags = PendingIntent.FLAG_IMMUTABLE
- fun genTitle(ent: ProxyEntity): String {
- val gn = if (DataStore.showGroupInNotification)
- SagerDatabase.groupDao.getById(ent.groupId)?.displayName() else null
- return if (gn == null) ent.displayName() else "[$gn] ${ent.displayName()}"
+ data class NotificationChannelPolicy(
+ val importance: Int,
+ val lockscreenVisibility: Int,
+ )
+
+ fun vpnNotificationChannelPolicy() = NotificationChannelPolicy(
+ importance = NotificationManagerCompat.IMPORTANCE_MIN,
+ lockscreenVisibility = Notification.VISIBILITY_SECRET,
+ )
+
+ fun shouldPostSpeed(visible: Boolean, interactive: Boolean): Boolean =
+ visible && interactive
+
+ fun notificationPriority(visible: Boolean, wakeLockAcquired: Boolean): Int = when {
+ !visible -> NotificationCompat.PRIORITY_MIN
+ wakeLockAcquired -> NotificationCompat.PRIORITY_HIGH
+ else -> NotificationCompat.PRIORITY_LOW
+ }
+
+ fun genTitle(
+ ent: ProxyEntity?,
+ showProfileInNotification: Boolean = DataStore.showProfileInNotification,
+ showGroupInNotification: Boolean = DataStore.showGroupInNotification,
+ groupNameProvider: (Long) -> String? = { SagerDatabase.groupDao.getById(it)?.displayName() },
+ fallbackAppName: String = SagerNet.application.getString(R.string.app_name),
+ ): String {
+ if (ent == null || !showProfileInNotification) {
+ return fallbackAppName
+ }
+ val gn = if (showGroupInNotification) groupNameProvider(ent.groupId) else null
+ return if (gn.isNullOrBlank()) ent.displayName() else "[$gn] ${ent.displayName()}"
}
}
- var listenPostSpeed = true
+ @Volatile
+ var listenPostSpeed = shouldPostSpeed(
+ visible,
+ ((service as Context).getSystemService(Context.POWER_SERVICE) as PowerManager).isInteractive,
+ )
+
+ // Last values actually rendered into the notification. Rebuilding a
+ // Notification (RemoteViews + icon resources) and handing it to
+ // NotificationManager is one of the most expensive things this app does on a
+ // timer: it wakes SystemUI and the notification side of every listener every
+ // speed interval. While the connection is idle the rendered text does not
+ // change, so the whole rebuild can be skipped.
+ private var lastSpeedTxRateProxy = Long.MIN_VALUE
+ private var lastSpeedRxRateProxy = Long.MIN_VALUE
+ private var lastSpeedTxRateDirect = Long.MIN_VALUE
+ private var lastSpeedRxRateDirect = Long.MIN_VALUE
+ private var lastSpeedTxTotal = Long.MIN_VALUE
+ private var lastSpeedRxTotal = Long.MIN_VALUE
+
+ private fun speedRenderUnchanged(stats: SpeedDisplayData): Boolean {
+ if (lastSpeedTxRateProxy != stats.txRateProxy) return false
+ if (lastSpeedRxRateProxy != stats.rxRateProxy) return false
+ if (lastSpeedTxTotal != stats.txTotal) return false
+ if (lastSpeedRxTotal != stats.rxTotal) return false
+ if (showDirectSpeed) {
+ if (lastSpeedTxRateDirect != stats.txRateDirect) return false
+ if (lastSpeedRxRateDirect != stats.rxRateDirect) return false
+ }
+ return true
+ }
+
+ private fun rememberSpeed(stats: SpeedDisplayData) {
+ lastSpeedTxRateProxy = stats.txRateProxy
+ lastSpeedRxRateProxy = stats.rxRateProxy
+ lastSpeedTxRateDirect = stats.txRateDirect
+ lastSpeedRxRateDirect = stats.rxRateDirect
+ lastSpeedTxTotal = stats.txTotal
+ lastSpeedRxTotal = stats.rxTotal
+ }
suspend fun postNotificationSpeedUpdate(stats: SpeedDisplayData) {
+ if (speedRenderUnchanged(stats)) return
+ rememberSpeed(stats)
useBuilder {
if (showDirectSpeed) {
val speedDetail = (service as Context).getString(
@@ -104,8 +172,7 @@ class ServiceNotification(
suspend fun postNotificationWakeLockStatus(acquired: Boolean) {
updateActions()
useBuilder {
- it.priority =
- if (acquired) NotificationCompat.PRIORITY_HIGH else NotificationCompat.PRIORITY_LOW
+ it.priority = notificationPriority(visible, acquired)
}
update()
}
@@ -120,9 +187,11 @@ class ServiceNotification(
.setContentIntent(SagerNet.configureIntent(service))
.setSmallIcon(R.drawable.ic_service_active)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
+ .setVisibility(if (visible) NotificationCompat.VISIBILITY_PRIVATE else NotificationCompat.VISIBILITY_SECRET)
.setPriority(if (visible) NotificationCompat.PRIORITY_LOW else NotificationCompat.PRIORITY_MIN)
private val buildLock = Mutex()
+ @Volatile private var destroyed = false
private suspend fun useBuilder(f: (NotificationCompat.Builder) -> Unit) {
buildLock.withLock {
@@ -170,7 +239,7 @@ class ServiceNotification(
val resetUpstreamAction = NotificationCompat.Action.Builder(
0, service.getString(R.string.reset_connections),
PendingIntent.getBroadcast(
- service, 0, Intent(Action.RESET_UPSTREAM_CONNECTIONS), flags
+ service, 0, Intent(Action.RESET_UPSTREAM_CONNECTIONS).setPackage(service.packageName), flags
)
).setShowsUserInterface(false).build()
it.addAction(resetUpstreamAction)
@@ -179,23 +248,23 @@ class ServiceNotification(
override fun onReceive(context: Context, intent: Intent) {
if (service.data.state == BaseService.State.Connected) {
- listenPostSpeed = intent.action == Intent.ACTION_SCREEN_ON
+ listenPostSpeed = shouldPostSpeed(
+ visible,
+ intent.action == Intent.ACTION_SCREEN_ON,
+ )
}
}
private suspend fun show() =
useBuilder {
+ if (destroyed) return@useBuilder
try {
- if (Build.VERSION.SDK_INT >= 34) {
- (service as Service).startForeground(
- notificationId,
- it.build(),
- FOREGROUND_SERVICE_TYPE_SYSTEM_EXEMPTED
- )
- } else {
- (service as Service).startForeground(notificationId, it.build())
- }
+ (service as Service).startForeground(
+ notificationId,
+ it.build(),
+ FOREGROUND_SERVICE_TYPE_SYSTEM_EXEMPTED
+ )
} catch (e: Exception) {
Toast.makeText(
SagerNet.application,
@@ -206,16 +275,19 @@ class ServiceNotification(
}
private suspend fun update() = useBuilder {
- NotificationManagerCompat.from(service as Service).notify(notificationId, it.build())
+ if (destroyed) return@useBuilder
+ try {
+ NotificationManagerCompat.from(service as Service).notify(notificationId, it.build())
+ } catch (_: SecurityException) {
+ // Notification permission may be revoked while the VPN is running.
+ }
}
fun destroy() {
+ if (destroyed) return
+ destroyed = true
listenPostSpeed = false
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
- (service as Service).stopForeground(Service.STOP_FOREGROUND_REMOVE)
- } else {
- (service as Service).stopForeground(true)
- }
+ (service as Service).stopForeground(Service.STOP_FOREGROUND_REMOVE)
service.unregisterReceiver(this)
}
}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/SubscriptionUpdater.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/SubscriptionUpdater.kt
index a00067283d..caf70c32c1 100644
--- a/app/src/main/java/io/nekohasekai/sagernet/bg/SubscriptionUpdater.kt
+++ b/app/src/main/java/io/nekohasekai/sagernet/bg/SubscriptionUpdater.kt
@@ -83,7 +83,11 @@ object SubscriptionUpdater {
R.string.subscription_update_message, profile.displayName()
)
)
- nm.notify(2, notification.build())
+ try {
+ nm.notify(2, notification.build())
+ } catch (_: SecurityException) {
+ // Denied notification permission must not prevent subscription updates.
+ }
GroupUpdater.executeUpdate(profile, false)
}
@@ -94,4 +98,4 @@ object SubscriptionUpdater {
}
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/TileService.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/TileService.kt
index dd46ee5cdf..ab59556e01 100644
--- a/app/src/main/java/io/nekohasekai/sagernet/bg/TileService.kt
+++ b/app/src/main/java/io/nekohasekai/sagernet/bg/TileService.kt
@@ -2,14 +2,13 @@ package io.nekohasekai.sagernet.bg
import android.graphics.drawable.Icon
import android.service.quicksettings.Tile
-import androidx.annotation.RequiresApi
import io.nekohasekai.sagernet.R
import io.nekohasekai.sagernet.SagerNet
import io.nekohasekai.sagernet.aidl.ISagerNetService
+import io.nekohasekai.sagernet.database.DataStore
import io.nekohasekai.sagernet.database.SagerDatabase
import android.service.quicksettings.TileService as BaseTileService
-@RequiresApi(24)
class TileService : BaseTileService(), SagerConnection.Callback {
private val iconIdle by lazy { Icon.createWithResource(this, R.drawable.ic_service_idle) }
private val iconBusy by lazy { Icon.createWithResource(this, R.drawable.ic_service_busy) }
@@ -32,7 +31,8 @@ class TileService : BaseTileService(), SagerConnection.Callback {
override fun cbSelectorUpdate(id: Long) {
val profile = SagerDatabase.proxyDao.getById(id) ?: return
- updateTile(BaseService.State.Connected, profile.displayName())
+ val title = if (DataStore.showProfileInNotification) profile.displayName() else getString(R.string.app_name)
+ updateTile(BaseService.State.Connected, title)
}
override fun onStartListening() {
@@ -61,7 +61,7 @@ class TileService : BaseTileService(), SagerConnection.Callback {
BaseService.State.Connected -> {
icon = iconConnected
- label = profileName
+ label = if (DataStore.showProfileInNotification) profileName else getString(R.string.app_name)
state = Tile.STATE_ACTIVE
}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/TunMtu.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/TunMtu.kt
new file mode 100644
index 0000000000..f8e1365811
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sagernet/bg/TunMtu.kt
@@ -0,0 +1,8 @@
+package io.nekohasekai.sagernet.bg
+
+import com.google.gson.JsonParser
+
+internal fun tunMtu(optionsJson: String, fallback: Int): Int {
+ // This is sing-tun.Options marshaled by Go, not the inbound config JSON.
+ return JsonParser.parseString(optionsJson).asJsonObject.get("MTU")?.asInt ?: fallback
+}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/VpnService.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/VpnService.kt
index a18da8ffcd..453e2f7e45 100644
--- a/app/src/main/java/io/nekohasekai/sagernet/bg/VpnService.kt
+++ b/app/src/main/java/io/nekohasekai/sagernet/bg/VpnService.kt
@@ -6,7 +6,6 @@ import android.app.Service
import android.content.Intent
import android.content.pm.PackageManager
import android.net.ProxyInfo
-import android.os.Build
import android.os.ParcelFileDescriptor
import android.os.PowerManager
import io.nekohasekai.sagernet.*
@@ -16,7 +15,6 @@ import io.nekohasekai.sagernet.fmt.hysteria.HysteriaBean
import io.nekohasekai.sagernet.ktx.*
import io.nekohasekai.sagernet.ui.VpnRequestActivity
import io.nekohasekai.sagernet.utils.Subnet
-import moe.matsuri.nb4a.hevtun.HevTunRuntime
import android.net.VpnService as BaseVpnService
class VpnService : BaseVpnService(),
@@ -27,7 +25,6 @@ class VpnService : BaseVpnService(),
const val PRIVATE_VLAN4_CLIENT = "172.19.0.1"
const val PRIVATE_VLAN4_ROUTER = "172.19.0.2"
const val FAKEDNS_VLAN4_CLIENT = "198.18.0.0"
- const val HEV_MAPDNS_VLAN4 = "100.64.0.0"
const val PRIVATE_VLAN6_CLIENT = "fdfe:dcba:9876::1"
const val PRIVATE_VLAN6_ROUTER = "fdfe:dcba:9876::2"
@@ -42,11 +39,6 @@ class VpnService : BaseVpnService(),
override suspend fun startProcesses() {
DataStore.vpnService = this
super.startProcesses() // launch proxy instance
-
- if (DataStore.enableHevTun) {
- val tunFd = establishTun()
- HevTunRuntime.start(this, tunFd)
- }
}
override var wakeLock: PowerManager.WakeLock? = null
@@ -59,7 +51,6 @@ class VpnService : BaseVpnService(),
@Suppress("EXPERIMENTAL_API_USAGE")
override fun killProcesses() {
- HevTunRuntime.stop()
conn?.close()
conn = null
super.killProcesses()
@@ -73,7 +64,7 @@ class VpnService : BaseVpnService(),
override val data = BaseService.Data(this)
override val tag = "SagerNetVpnService"
override fun createNotification(profileName: String) =
- ServiceNotification(this, profileName, "service-vpn")
+ ServiceNotification(this, profileName, ServiceNotification.vpnNotificationChannel)
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (DataStore.serviceMode == Key.MODE_VPN) {
@@ -95,17 +86,13 @@ class VpnService : BaseVpnService(),
}
fun startVpn(tunOptionsJson: String, tunPlatformOptionsJson: String): Int {
-// Logs.d(tunOptionsJson)
-// Logs.d(tunPlatformOptionsJson)
-// val tunOptions = JSONObject(tunOptionsJson)
-
- return establishTun()
+ return establishTun(tunMtu(tunOptionsJson, DataStore.mtu))
}
- fun establishTun(): Int {
+ fun establishTun(mtu: Int = DataStore.mtu): Int {
val builder = Builder().setConfigureIntent(SagerNet.configureIntent(this))
.setSession(getString(R.string.app_name))
- .setMtu(DataStore.mtu)
+ .setMtu(mtu)
val ipv6Mode = DataStore.ipv6Mode
// address
@@ -123,9 +110,6 @@ class VpnService : BaseVpnService(),
}
builder.addRoute(PRIVATE_VLAN4_ROUTER, 32)
builder.addRoute(FAKEDNS_VLAN4_CLIENT, 15)
- if (DataStore.enableHevTun && DataStore.enableFakeDns) {
- builder.addRoute(HEV_MAPDNS_VLAN4, 10)
- }
// https://issuetracker.google.com/issues/149636790
if (ipv6Mode != IPv6Mode.DISABLE) {
builder.addRoute("2000::", 3)
@@ -138,7 +122,7 @@ class VpnService : BaseVpnService(),
}
updateUnderlyingNetwork(builder)
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) builder.setMetered(metered)
+ builder.setMetered(metered)
// app route
val packageName = packageName
@@ -202,7 +186,7 @@ class VpnService : BaseVpnService(),
}
}
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && DataStore.appendHttpProxy) {
+ if (DataStore.appendHttpProxy) {
builder.setHttpProxy(
ProxyInfo.buildDirectProxy(
LOCALHOST,
@@ -215,18 +199,16 @@ class VpnService : BaseVpnService(),
}
metered = DataStore.meteredNetwork
- if (Build.VERSION.SDK_INT >= 29) builder.setMetered(metered)
+ builder.setMetered(metered)
conn = builder.establish() ?: throw NullConnectionException()
return conn!!.fd
}
fun updateUnderlyingNetwork(builder: Builder? = null) {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) {
- SagerNet.underlyingNetwork?.let {
- builder?.setUnderlyingNetworks(arrayOf(SagerNet.underlyingNetwork))
- ?: setUnderlyingNetworks(arrayOf(SagerNet.underlyingNetwork))
- }
+ SagerNet.underlyingNetwork?.let {
+ builder?.setUnderlyingNetworks(arrayOf(SagerNet.underlyingNetwork))
+ ?: setUnderlyingNetworks(arrayOf(SagerNet.underlyingNetwork))
}
}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/proto/BoxInstance.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/BoxInstance.kt
index dc60d5efdf..071fd9d879 100644
--- a/app/src/main/java/io/nekohasekai/sagernet/bg/proto/BoxInstance.kt
+++ b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/BoxInstance.kt
@@ -18,6 +18,7 @@ import io.nekohasekai.sagernet.fmt.trojan_go.TrojanGoBean
import io.nekohasekai.sagernet.fmt.trojan_go.buildTrojanGoConfig
import io.nekohasekai.sagernet.ktx.*
import io.nekohasekai.sagernet.plugin.PluginManager
+import io.nekohasekai.sagernet.route.RouterRuntimeSelection
import kotlinx.coroutines.*
import libcore.BoxInstance
import libcore.Libcore
@@ -40,6 +41,15 @@ abstract class BoxInstance(
return ::config.isInitialized && ::box.isInitialized
}
+ fun currentUrlTestSelections(): LongArray {
+ if (!isInitialized()) return longArrayOf()
+ return RouterRuntimeSelection.resolve(
+ routerTags = config.routerUrlTestTags,
+ profileTags = config.profileTagMap,
+ currentOutbound = box::currentOutboundFor,
+ )
+ }
+
protected fun initPlugin(name: String): PluginManager.InitResult {
return pluginPath.getOrPut(name) { PluginManager.init(name)!! }
}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/proto/ProxyInstance.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/ProxyInstance.kt
index 9758a5c40d..fcb45d7e6c 100644
--- a/app/src/main/java/io/nekohasekai/sagernet/bg/proto/ProxyInstance.kt
+++ b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/ProxyInstance.kt
@@ -1,13 +1,14 @@
package io.nekohasekai.sagernet.bg.proto
-import io.nekohasekai.sagernet.BuildConfig
import io.nekohasekai.sagernet.bg.BaseService
import io.nekohasekai.sagernet.bg.ServiceNotification
import io.nekohasekai.sagernet.database.ProxyEntity
import io.nekohasekai.sagernet.ktx.Logs
-import io.nekohasekai.sagernet.ktx.runOnDefaultDispatcher
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
-import moe.matsuri.nb4a.utils.JavaUtil
class ProxyInstance(profile: ProxyEntity, var service: BaseService.Interface? = null) :
BoxInstance(profile) {
@@ -18,14 +19,14 @@ class ProxyInstance(profile: ProxyEntity, var service: BaseService.Interface? =
var displayProfileName = ServiceNotification.genTitle(profile)
// for TrafficLooper
- var looper: TrafficLooper? = null
+ @Volatile var looper: TrafficLooper? = null
+ private val runtimeScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
override fun buildConfig() {
super.buildConfig()
lastSelectorGroupId = super.config.selectorGroupId
//
- if (notTmp) Logs.d(config.config)
- if (notTmp && BuildConfig.DEBUG) Logs.d(JavaUtil.gson.toJson(config.trafficMap))
+ if (notTmp) Logs.d("Built proxy configuration: ${config.profileTagMap.size} profiles")
}
// only use this in temporary instance
@@ -34,14 +35,6 @@ class ProxyInstance(profile: ProxyEntity, var service: BaseService.Interface? =
buildConfig()
}
- override suspend fun init() {
- super.init()
- pluginConfigs.forEach { (_, plugin) ->
- val (_, content) = plugin
- Logs.d(content)
- }
- }
-
override suspend fun loadConfig() {
super.loadConfig()
}
@@ -49,17 +42,17 @@ class ProxyInstance(profile: ProxyEntity, var service: BaseService.Interface? =
override fun launch() {
box.setAsMain()
super.launch() // start box
- runOnDefaultDispatcher {
- looper = service?.let { TrafficLooper(it.data, this) }
- looper?.start()
- }
+ looper = service?.let { TrafficLooper(it.data, runtimeScope) }
+ looper?.start()
}
override fun close() {
- super.close()
- runBlocking {
- looper?.stop()
+ try {
+ runBlocking { looper?.stop() }
+ } finally {
looper = null
+ runtimeScope.cancel()
+ super.close()
}
}
}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/proto/TrafficLoopPolicy.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/TrafficLoopPolicy.kt
new file mode 100644
index 0000000000..d47a4ffe0e
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/TrafficLoopPolicy.kt
@@ -0,0 +1,20 @@
+package io.nekohasekai.sagernet.bg.proto
+
+object TrafficLoopPolicy {
+ private const val MIN_BACKGROUND_NOTIFICATION_MILLIS = 5_000L
+ private const val MIN_BACKGROUND_HIDDEN_MILLIS = 30_000L
+ private const val MIN_INITIALIZATION_RETRY_MILLIS = 250L
+
+ fun delayMillis(
+ configuredMillis: Long,
+ mainActivityForeground: Boolean,
+ notificationSpeedVisible: Boolean,
+ ): Long = when {
+ mainActivityForeground -> if (configuredMillis > 0L) configuredMillis else 1_000L
+ notificationSpeedVisible -> maxOf(configuredMillis, MIN_BACKGROUND_NOTIFICATION_MILLIS)
+ else -> maxOf(configuredMillis, MIN_BACKGROUND_HIDDEN_MILLIS)
+ }
+
+ fun initializationRetryMillis(configuredMillis: Long): Long =
+ maxOf(configuredMillis, MIN_INITIALIZATION_RETRY_MILLIS)
+}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/proto/TrafficLooper.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/TrafficLooper.kt
index 48f8744265..6bff1c9940 100644
--- a/app/src/main/java/io/nekohasekai/sagernet/bg/proto/TrafficLooper.kt
+++ b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/TrafficLooper.kt
@@ -11,6 +11,7 @@ import io.nekohasekai.sagernet.fmt.TAG_BYPASS
import io.nekohasekai.sagernet.fmt.TAG_PROXY
import io.nekohasekai.sagernet.ktx.Logs
import kotlinx.coroutines.*
+import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.sync.Mutex
class TrafficLooper
@@ -23,6 +24,15 @@ class TrafficLooper
}
private var job: Job? = null
+ private val updateRequests = Channel(Channel.CONFLATED)
+
+ fun requestUpdate() {
+ updateRequests.trySend(Unit)
+ }
+
+ private suspend fun awaitUpdate(delayMillis: Long) {
+ withTimeoutOrNull(delayMillis) { updateRequests.receive() }
+ }
private val idMap = mutableMapOf() // id to 1 data
private val tagMap = mutableMapOf() // tag to 1 data
private val stateMutex = Mutex()
@@ -30,7 +40,7 @@ class TrafficLooper
private data class LoopSnapshot(
val speed: SpeedDisplayData,
- val trafficUpdates: ArrayList,
+ val trafficUpdates: List,
)
private suspend fun withStateLock(block: suspend () -> T): T {
@@ -48,6 +58,7 @@ class TrafficLooper
// finally traffic post
if (!DataStore.profileTrafficStatistics) return
withStateLock {
+ trafficUpdater?.updateAll()
val traffic = mutableMapOf()
data.proxy?.config?.trafficMap?.forEach { (_, ents) ->
for (ent in ents) {
@@ -85,7 +96,7 @@ class TrafficLooper
selectMainLocked(id)
}
- private suspend fun selectMainLocked(id: Long) {
+ private suspend fun selectMainLocked(id: Long, statsTag: String = TAG_PROXY) {
Logs.d("select traffic count $TAG_PROXY to $id, old id is $selectorNowId")
val oldData = idMap[selectorNowId]
val newData = idMap[id] ?: return
@@ -104,11 +115,22 @@ class TrafficLooper
selectorNowFakeTag = newData.tag
selectorNowId = id
newData.apply {
- tag = TAG_PROXY
+ tag = statsTag
ignore = false
}
}
+ private suspend fun syncUrlTestWinnerLocked(proxy: ProxyInstance): LongArray {
+ val selections = proxy.currentUrlTestSelections()
+ val mainTag = proxy.config.mainUrlTestTag ?: return selections
+ val mainRouterId = proxy.config.routerUrlTestTags.entries
+ .firstOrNull { it.value == mainTag }?.key ?: return selections
+ val selectionMap = io.nekohasekai.sagernet.route.RouterRuntimeSelection.toMap(selections)
+ val winnerId = selectionMap[mainRouterId] ?: return selections
+ if (winnerId != selectorNowId) selectMainLocked(winnerId, mainTag)
+ return selections
+ }
+
suspend fun resetTraffic(profileIds: LongArray) {
val targetIds = profileIds.asSequence().filter { it > 0L }.toHashSet()
if (targetIds.isEmpty()) return
@@ -160,7 +182,6 @@ class TrafficLooper
val delayMs = DataStore.speedInterval.toLong()
val showDirectSpeed = DataStore.showDirectSpeed
val profileTrafficStatistics = DataStore.profileTrafficStatistics
- if (delayMs == 0L) return
// for display
val itemBypass = TrafficUpdater.TrafficLooperData(tag = TAG_BYPASS)
@@ -168,43 +189,82 @@ class TrafficLooper
while (currentCoroutineContext().isActive) {
val proxy = data.proxy
if (proxy == null) {
- delay(delayMs)
+ awaitUpdate(TrafficLoopPolicy.initializationRetryMillis(delayMs))
+ continue
+ }
+ if (!proxy.isInitialized()) {
+ awaitUpdate(TrafficLoopPolicy.initializationRetryMillis(delayMs))
continue
}
- if (!proxy.isInitialized()) continue
+ // Resolved once per tick: containsValue() scans the whole callback map,
+ // and the value cannot change meaningfully within a single tick.
+ val mainActivityForeground = data.binder.callbackIdMap.containsValue(
+ SagerConnection.CONNECTION_ID_MAIN_ACTIVITY_FOREGROUND
+ )
+
+ if (delayMs <= 0L) {
+ // Nobody is listening -> skip the selection query and the IPC round-trip.
+ if (mainActivityForeground && data.state == BaseService.State.Connected) {
+ val selections = proxy.currentUrlTestSelections()
+ data.binder.broadcast { callback ->
+ if (data.binder.callbackIdMap[callback] ==
+ SagerConnection.CONNECTION_ID_MAIN_ACTIVITY_FOREGROUND
+ ) {
+ callback.cbSpeedUpdate(SpeedDisplayData(urlTestSelections = selections))
+ }
+ }
+ }
+ awaitUpdate(TrafficLoopPolicy.delayMillis(
+ delayMs,
+ mainActivityForeground,
+ false,
+ ))
+ continue
+ }
val snapshot = withStateLock {
if (trafficUpdater == null) {
idMap.clear()
idMap[-1] = itemBypass
//
val tags = hashSetOf(TAG_PROXY, TAG_BYPASS)
+ val dynamicMain = proxy.config.selectorGroupId >= 0L ||
+ proxy.config.mainUrlTestTag != null
+ // Nodes belonging to independent Router groups must never be mass-ignored:
+ // they accumulate traffic independently of the main selector winner.
+ val routerMemberIds = proxy.config.routerAllMemberIds
proxy.config.trafficMap.forEach { (tag, ents) ->
tags.add(tag)
for (ent in ents) {
+ // Only ignore nodes that are NOT already accounted for by an
+ // independent Router. Router member nodes get their own ignore=false
+ // path and are updated regardless of main selector state.
+ val belongsToRouter = ent.id in routerMemberIds
val item = TrafficUpdater.TrafficLooperData(
tag = tag,
rx = ent.rx,
tx = ent.tx,
rxBase = ent.rx,
txBase = ent.tx,
- ignore = proxy.config.selectorGroupId >= 0L,
+ ignore = dynamicMain && !belongsToRouter,
)
idMap[ent.id] = item
tagMap[tag] = item
- Logs.d("traffic count $tag to ${ent.id}")
}
}
- if (proxy.config.selectorGroupId >= 0L) {
+ if (proxy.config.mainUrlTestTag != null) {
+ syncUrlTestWinnerLocked(proxy)
+ } else if (proxy.config.selectorGroupId >= 0L) {
selectMainLocked(proxy.config.mainEntId)
}
- //
trafficUpdater = TrafficUpdater(
box = proxy.box, items = idMap.values.toList()
)
+ proxy.config.mainUrlTestTag?.let(tags::add)
proxy.box.setV2rayStats(tags.joinToString("\n"))
}
+ val urlTestSelections = syncUrlTestWinnerLocked(proxy)
trafficUpdater!!.updateAll()
currentCoroutineContext().ensureActive()
@@ -222,13 +282,18 @@ class TrafficLooper
mainRx += it.rx - it.rxBase
}
- val trafficUpdates = arrayListOf()
- if (profileTrafficStatistics) {
+ // The list is only ever consumed by the foreground broadcast below, so
+ // building it while nobody is watching is pure garbage per tick.
+ val trafficUpdates = if (profileTrafficStatistics && mainActivityForeground) {
+ val updates = arrayListOf()
idMap.forEach { (id, item) ->
if (id > 0L && item.hasTrafficDelta) {
- trafficUpdates.add(TrafficData(id = id, rx = item.rx, tx = item.tx))
+ updates.add(TrafficData(id = id, rx = item.rx, tx = item.tx))
}
}
+ updates
+ } else {
+ emptyList()
}
val snapshot = LoopSnapshot(
speed = SpeedDisplayData(
@@ -237,15 +302,12 @@ class TrafficLooper
if (showDirectSpeed) itemBypass.txRate else 0L,
if (showDirectSpeed) itemBypass.rxRate else 0L,
mainTx,
- mainRx
+ mainRx,
+ urlTestSelections,
),
trafficUpdates = trafficUpdates,
)
- if (data.state == BaseService.State.Connected
- && data.binder.callbackIdMap.containsValue(
- SagerConnection.CONNECTION_ID_MAIN_ACTIVITY_FOREGROUND
- )
- ) {
+ if (mainActivityForeground && data.state == BaseService.State.Connected) {
data.binder.broadcast { callback ->
if (data.binder.callbackIdMap[callback] ==
SagerConnection.CONNECTION_ID_MAIN_ACTIVITY_FOREGROUND
@@ -268,7 +330,13 @@ class TrafficLooper
if (listenPostSpeed) postNotificationSpeedUpdate(snapshot.speed)
}
- delay(delayMs)
+ awaitUpdate(
+ TrafficLoopPolicy.delayMillis(
+ configuredMillis = delayMs,
+ mainActivityForeground = mainActivityForeground,
+ notificationSpeedVisible = data.notification?.listenPostSpeed == true,
+ )
+ )
}
}
}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/proto/TrafficUpdater.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/TrafficUpdater.kt
index 35ab1bd367..4bea3c0523 100644
--- a/app/src/main/java/io/nekohasekai/sagernet/bg/proto/TrafficUpdater.kt
+++ b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/TrafficUpdater.kt
@@ -1,9 +1,17 @@
package io.nekohasekai.sagernet.bg.proto
class TrafficUpdater(
- private val box: libcore.BoxInstance,
+ private val queryStats: (String, String) -> Long,
val items: List, // contain "bypass"
+ private val monotonicMillis: () -> Long = { System.nanoTime() / 1_000_000L },
) {
+ constructor(box: libcore.BoxInstance, items: List) :
+ this(box::queryStats, items)
+
+ init {
+ val now = monotonicMillis()
+ items.forEach { it.lastUpdate = now }
+ }
class TrafficLooperData(
// Don't associate proxyEntity
@@ -19,20 +27,28 @@ class TrafficUpdater(
var hasTrafficDelta: Boolean = false,
)
- private fun updateOne(item: TrafficLooperData): TrafficLooperData {
+ /**
+ * Writes the diff of [item] into [out] instead of allocating a new holder.
+ */
+ private fun updateOne(item: TrafficLooperData, out: TrafficLooperData): TrafficLooperData {
// last update
- val now = System.currentTimeMillis()
+ val now = monotonicMillis()
val interval = now - item.lastUpdate
item.lastUpdate = now
+ out.tag = item.tag
if (interval <= 0) {
item.rxRate = 0
item.txRate = 0
- return TrafficLooperData(tag = item.tag)
+ out.rx = 0L
+ out.tx = 0L
+ out.rxRate = 0L
+ out.txRate = 0L
+ return out
}
// query
- val tx = box.queryStats(item.tag, "uplink")
- val rx = box.queryStats(item.tag, "downlink")
+ val tx = queryStats(item.tag, "uplink")
+ val rx = queryStats(item.tag, "downlink")
// add diff
item.rx += rx
@@ -41,35 +57,39 @@ class TrafficUpdater(
item.txRate = tx * 1000 / interval
// return diff
- return TrafficLooperData(
- tag = item.tag,
- rx = rx,
- tx = tx,
- rxRate = item.rxRate,
- txRate = item.txRate,
- )
+ out.rx = rx
+ out.tx = tx
+ out.rxRate = item.rxRate
+ out.txRate = item.txRate
+ return out
}
+ // updateAll() runs on every traffic tick (down to 1s) and used to allocate a new
+ // map plus one diff holder per tag each time. Both are reused here so the steady
+ // state is allocation free.
+ private val diffByTag = HashMap()
+ private val queriedTags = HashSet()
+
fun updateAll() {
- val updated = mutableMapOf() // diffs
+ queriedTags.clear()
items.forEach { item ->
item.hasTrafficDelta = false
if (item.ignore) return@forEach
- val diff = updated[item.tag]
+ val tag = item.tag
// query a tag only once
- if (diff == null) {
- val newDiff = updateOne(item)
- updated[item.tag] = newDiff
- item.hasTrafficDelta = newDiff.rx != 0L || newDiff.tx != 0L
+ if (queriedTags.add(tag)) {
+ val diff = diffByTag.getOrPut(tag) { TrafficLooperData(tag = tag) }
+ updateOne(item, diff)
+ item.hasTrafficDelta = diff.rx != 0L || diff.tx != 0L
} else {
+ val diff = diffByTag[tag]!!
item.rx += diff.rx
item.tx += diff.tx
item.rxRate = diff.rxRate
item.txRate = diff.txRate
item.hasTrafficDelta = diff.rx != 0L || diff.tx != 0L
+ item.lastUpdate = monotonicMillis()
}
}
-// Logs.d(JavaUtil.gson.toJson(items))
-// Logs.d(JavaUtil.gson.toJson(updated))
}
}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/database/DataStore.kt b/app/src/main/java/io/nekohasekai/sagernet/database/DataStore.kt
index ce1d57bf73..2c1f61921b 100644
--- a/app/src/main/java/io/nekohasekai/sagernet/database/DataStore.kt
+++ b/app/src/main/java/io/nekohasekai/sagernet/database/DataStore.kt
@@ -1,7 +1,6 @@
package io.nekohasekai.sagernet.database
import android.os.Binder
-import android.os.Build
import androidx.preference.PreferenceDataStore
import io.nekohasekai.sagernet.CONNECTION_TEST_URL
import io.nekohasekai.sagernet.GroupType
@@ -90,12 +89,12 @@ object DataStore : OnPreferenceDataStoreChangeListener {
var appTLSVersion by configurationStore.string(Key.APP_TLS_VERSION)
var enableClashAPI by configurationStore.boolean(Key.ENABLE_CLASH_API)
- var showBottomBar by configurationStore.boolean(Key.SHOW_BOTTOM_BAR)
var confirmProfileDelete by configurationStore.boolean(Key.CONFIRM_PROFILE_DELETE) { true }
var groupLayoutMode by configurationStore.stringToInt(Key.GROUP_LAYOUT_MODE) { 0 }
var profileCardStyle by configurationStore.stringToInt(Key.PROFILE_CARD_STYLE) { 0 }
+ val DEPRECATED_SETTING_KEYS get() = Key.DEPRECATED_SETTING_KEYS
- var allowInsecureOnRequest by configurationStore.boolean(Key.ALLOW_INSECURE_ON_REQUEST)
+ var allowInsecureOnRequest by configurationStore.boolean(Key.ALLOW_INSECURE_ON_REQUEST) { false }
var networkChangeResetConnections by configurationStore.boolean(Key.NETWORK_CHANGE_RESET_CONNECTIONS) { true }
var wakeResetConnections by configurationStore.boolean(Key.WAKE_RESET_CONNECTIONS)
@@ -118,6 +117,8 @@ object DataStore : OnPreferenceDataStoreChangeListener {
var allowAccess by configurationStore.boolean(Key.ALLOW_ACCESS)
var speedInterval by configurationStore.stringToInt(Key.SPEED_INTERVAL)
+ var showProfileInNotification by configurationStore.boolean(Key.SHOW_PROFILE_IN_NOTIFICATION) { true }
+ var viewModeRouterGroups by configurationStore.boolean(Key.VIEW_MODE_ROUTER_GROUPS) { true }
var showGroupInNotification by configurationStore.boolean("showGroupInNotification")
var globalCustomConfig by configurationStore.string(Key.GLOBAL_CUSTOM_CONFIG) { "" }
@@ -133,8 +134,8 @@ object DataStore : OnPreferenceDataStoreChangeListener {
var acquireWakeLock by configurationStore.boolean(Key.ACQUIRE_WAKE_LOCK)
var hideFromRecentApps by configurationStore.boolean(Key.HIDE_FROM_RECENT_APPS)
- var rulesGeositeUrl by configurationStore.string(Key.RULES_GEOSITE_URL) { "https://github.com/SagerNet/sing-geoip/releases/latest/download/geoip.db" }
- var rulesGeoipUrl by configurationStore.string(Key.RULES_GEOIP_URL) { "https://github.com/SagerNet/sing-geosite/releases/latest/download/geosite.db" }
+ var rulesGeositeUrl by configurationStore.string(Key.RULES_GEOSITE_URL) { "https://github.com/SagerNet/sing-geosite/releases/latest/download/geosite.db" }
+ var rulesGeoipUrl by configurationStore.string(Key.RULES_GEOIP_URL) { "https://github.com/SagerNet/sing-geoip/releases/latest/download/geoip.db" }
var rulesUpdateInterval by configurationStore.string(Key.RULES_UPDATE_INTERVAL) { "0" } // 默认为0,不自动更新
// hopefully hashCode = mHandle doesn't change, currently this is true from KitKat to Nougat
@@ -156,8 +157,7 @@ object DataStore : OnPreferenceDataStoreChangeListener {
set(value) = saveLocalPort(Key.MIXED_PORT, value)
val mixedInboundNeedsAuth: Boolean
- get() = serviceMode == Key.MODE_VPN &&
- !(appendHttpProxy && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
+ get() = serviceMode == Key.MODE_VPN
val mixedInboundHasAuth: Boolean
get() = mixedInboundNeedsAuth &&
@@ -170,6 +170,13 @@ object DataStore : OnPreferenceDataStoreChangeListener {
if (configurationStore.getString(Key.MIXED_PORT) == null) {
mixedPort = mixedPort
}
+ sanitizeDeprecatedPreferences()
+ }
+
+ fun sanitizeDeprecatedPreferences() {
+ for (key in DEPRECATED_SETTING_KEYS) {
+ PublicDatabase.kvPairDao.delete(key)
+ }
}
@@ -191,7 +198,9 @@ object DataStore : OnPreferenceDataStoreChangeListener {
val persistAcrossReboot by configurationStore.boolean(Key.PERSIST_ACROSS_REBOOT) { false }
- var appendHttpProxy by configurationStore.boolean(Key.APPEND_HTTP_PROXY)
+ var appendHttpProxy: Boolean
+ get() = false
+ set(_) {}
var httpProxyBypass by configurationStore.string(Key.HTTP_PROXY_BYPASS) { "" }
var dnsHosts by configurationStore.string(Key.DNS_HOSTS) { "" }
var strictRoute by configurationStore.boolean(Key.STRICT_ROUTE) { true }
@@ -200,8 +209,7 @@ object DataStore : OnPreferenceDataStoreChangeListener {
var connectionTestTimeout by configurationStore.int(Key.CONNECTION_TEST_TIMEOUT) { 3000 }
var alwaysShowAddress by configurationStore.boolean(Key.ALWAYS_SHOW_ADDRESS)
- var tunImplementation by configurationStore.stringToInt(Key.TUN_IMPLEMENTATION) { TunImplementation.GVISOR }
- var enableHevTun by configurationStore.boolean(Key.ENABLE_HEV_TUN)
+ var tunImplementation by configurationStore.stringToInt(Key.TUN_IMPLEMENTATION) { TunImplementation.GO }
var profileTrafficStatistics by configurationStore.boolean(Key.PROFILE_TRAFFIC_STATISTICS) { true }
var yacdURL by configurationStore.string("yacdURL") { "http://127.0.0.1:9090/ui" }
@@ -210,7 +218,9 @@ object DataStore : OnPreferenceDataStoreChangeListener {
var globalAllowInsecure by configurationStore.boolean(Key.GLOBAL_ALLOW_INSECURE) { false }
- var enableTLSFragment by configurationStore.boolean(Key.ENABLE_TLS_FRAGMENT) { false }
+ var enableTLSFragment: Boolean
+ get() = false
+ set(_) {}
var fragmentLength by configurationStore.string(Key.FRAGMENT_LENGTH) { "100-200" }
var fragmentInterval by configurationStore.string(Key.FRAGMENT_INTERVAL) { "10-20" }
@@ -279,6 +289,7 @@ object DataStore : OnPreferenceDataStoreChangeListener {
var routeRuleset by profileCacheStore.string(Key.ROUTE_RULESET)
var routeOutbound by profileCacheStore.stringToInt(Key.ROUTE_OUTBOUND)
var routeOutboundRule by profileCacheStore.long(Key.ROUTE_OUTBOUND + "Long")
+ var routeOutboundRouter by profileCacheStore.long(Key.ROUTE_OUTBOUND_ROUTER)
var routePackages by profileCacheStore.string(Key.ROUTE_PACKAGES)
var frontProxy by profileCacheStore.long(Key.GROUP_FRONT_PROXY + "Long")
@@ -310,22 +321,6 @@ object DataStore : OnPreferenceDataStoreChangeListener {
// var enableTLSFragment by configurationStore.boolean(Key.ENABLE_TLS_FRAGMENT)
- var webdavServer: String?
- get() = configurationStore.getString("webdavServer")
- set(value) = configurationStore.putString("webdavServer", value)
-
- var webdavUsername: String?
- get() = configurationStore.getString("webdavUsername")
- set(value) = configurationStore.putString("webdavUsername", value)
-
- var webdavPassword: String?
- get() = configurationStore.getString("webdavPassword")
- set(value) = configurationStore.putString("webdavPassword", value)
-
- var webdavPath: String?
- get() = configurationStore.getString("webdavPath") ?: "NekoBox" // 设置默认值
- set(value) = configurationStore.putString("webdavPath", value)
-
var globalMode by configurationStore.boolean(Key.GLOBAL_MODE)
override fun onPreferenceDataStoreChanged(store: PreferenceDataStore, key: String) {
diff --git a/app/src/main/java/io/nekohasekai/sagernet/database/GroupManager.kt b/app/src/main/java/io/nekohasekai/sagernet/database/GroupManager.kt
index b2688f498f..e356b72ce8 100644
--- a/app/src/main/java/io/nekohasekai/sagernet/database/GroupManager.kt
+++ b/app/src/main/java/io/nekohasekai/sagernet/database/GroupManager.kt
@@ -2,16 +2,33 @@ package io.nekohasekai.sagernet.database
import io.nekohasekai.sagernet.GroupType
import io.nekohasekai.sagernet.bg.SubscriptionUpdater
+import io.nekohasekai.sagernet.fmt.AbstractBean
+import io.nekohasekai.sagernet.fmt.toUniversalLink
import io.nekohasekai.sagernet.ktx.applyDefaultValues
+import io.nekohasekai.sagernet.ktx.Logs
+import io.nekohasekai.sagernet.route.RouterFilterConfig
+import io.nekohasekai.sagernet.route.RouterMembership
+import io.nekohasekai.sagernet.route.RouterMemberSnapshot
+import io.nekohasekai.sagernet.route.RouterNodeSnapshot
+import io.nekohasekai.sagernet.route.RouterReconcileGroup
+import io.nekohasekai.sagernet.route.RouterReconciler
+import io.nekohasekai.sagernet.route.danglingRouterMemberProxyIds
+import io.nekohasekai.sagernet.route.routerStableIdOrFallback
+import io.nekohasekai.sagernet.route.routerNodeKey
object GroupManager {
+ data class RouterRefreshSnapshot(
+ val membersByRouterId: Map>
+ )
+
interface Listener {
suspend fun groupAdd(group: ProxyGroup)
suspend fun groupUpdated(group: ProxyGroup)
suspend fun groupRemoved(groupId: Long)
suspend fun groupUpdated(groupId: Long)
+ suspend fun routerGroupsUpdated() = Unit
}
interface Interface {
@@ -56,6 +73,7 @@ object GroupManager {
suspend fun clearGroup(groupId: Long) {
DataStore.selectedProxy = 0L
SagerDatabase.proxyDao.deleteAll(groupId)
+ cleanupDanglingRouterMembers()
iterator { groupUpdated(groupId) }
}
@@ -79,6 +97,203 @@ object GroupManager {
iterator { groupUpdated(groupId) }
}
+ fun replaceRouterMembers(
+ router: RouterGroup,
+ availableProxyIds: List,
+ requestedProxyIds: Iterable,
+ ) {
+ val plan = RouterMembership.plan(
+ availableProxyIds = availableProxyIds,
+ requestedProxyIds = requestedProxyIds,
+ currentSelectedProxyId = router.selectedProxyId
+ .takeIf { it != RouterGroup.NO_SELECTION },
+ )
+ SagerDatabase.instance.runInTransaction {
+ SagerDatabase.routerMemberDao.replaceMembers(
+ router.id,
+ plan.memberProxyIds.mapIndexed { index, proxyId ->
+ RouterMember(
+ routerId = router.id,
+ proxyId = proxyId,
+ userOrder = index.toLong(),
+ )
+ },
+ )
+ SagerDatabase.routerGroupDao.update(
+ router.copy(selectedProxyId = plan.selectedProxyId ?: RouterGroup.NO_SELECTION),
+ )
+ }
+ }
+
+ fun snapshotRouterMembers(): RouterRefreshSnapshot {
+ val proxies = SagerDatabase.proxyDao.getAll().associateBy { it.id }
+ val sourceGroups = SagerDatabase.groupDao.allGroups().associateBy { it.id }
+ val members = SagerDatabase.routerGroupDao.all().associate { router ->
+ router.id to SagerDatabase.routerMemberDao.getByRouter(router.id).mapNotNull { member ->
+ proxies[member.proxyId]?.let { proxy ->
+ RouterMemberSnapshot(
+ proxyId = proxy.id,
+ stableId = proxy.routerStableId(),
+ sourceGroupId = sourceGroups[proxy.groupId]
+ ?.takeIf { it.type == GroupType.SUBSCRIPTION }
+ ?.id,
+ userOrder = member.userOrder
+ )
+ }
+ }
+ }
+ return RouterRefreshSnapshot(members)
+ }
+
+ suspend fun reconcileRouterMembers(previous: RouterRefreshSnapshot) {
+ // Keep the old selected ID until reconciliation can resolve it through the snapshot.
+ cleanupDanglingRouterMembers(clearInvalidSelections = false)
+ val routers = SagerDatabase.routerGroupDao.all()
+ .filter { it.stableTag.isNotBlank() }
+ if (routers.isEmpty()) {
+ cleanupDanglingRouterMembers()
+ return
+ }
+
+ val groups = routers.mapNotNull { router ->
+ runCatching {
+ RouterReconcileGroup(
+ routerId = router.id,
+ stableTag = router.stableTag,
+ sourceGroupIds = SagerDatabase.routerGroupSourceDao.sourcesFor(router.id)
+ .map { it.sourceGroupId },
+ filter = RouterFilterConfig.fromJson(router.matchConfig).validate(),
+ selectedProxyId = router.selectedProxyId.takeIf { it != RouterGroup.NO_SELECTION }
+ )
+ }.onFailure { error ->
+ Logs.e("Router ${router.stableTag} match configuration is invalid", error)
+ SagerDatabase.routerGroupDao.setLastError(router.id, "Invalid Router match configuration")
+ }.getOrNull()
+ }
+ if (groups.isEmpty()) {
+ cleanupDanglingRouterMembers()
+ return
+ }
+
+ val sourceGroups = SagerDatabase.groupDao.allGroups().associateBy { it.id }
+ val nodes = SagerDatabase.proxyDao.getAll().mapNotNull { proxy ->
+ runCatching {
+ RouterNodeSnapshot(
+ id = proxy.id,
+ stableId = proxy.routerStableId(),
+ name = proxy.displayNameOrFallback(),
+ subscriptionId = sourceGroups[proxy.groupId]
+ ?.takeIf { it.type == GroupType.SUBSCRIPTION }
+ ?.id,
+ enabled = true,
+ available = true,
+ )
+ }.onFailure { error ->
+ Logs.e("Failed to snapshot proxy ${proxy.id}", error)
+ }.getOrNull()
+ }
+
+ val result = RouterReconciler.reconcile(nodes, groups, previous.membersByRouterId)
+ if (result.error != null) {
+ Logs.e("Router reconciliation preserved existing members: ${result.error}")
+ routers.filter { router -> groups.any { it.routerId == router.id } }.forEach { router ->
+ SagerDatabase.routerGroupDao.setLastError(router.id, result.error)
+ }
+ cleanupDanglingRouterMembers()
+ return
+ }
+
+ val matchedAt = System.currentTimeMillis()
+ // Record the matchConfig and sourceGroupIds that were active when we computed members, so we can
+ // detect concurrent configuration or source changes inside the synchronized block.
+ val snapshotMatchConfigs = routers.associate { it.id to it.matchConfig }
+ val snapshotSourceGroupIds = groups.associate { it.routerId to it.sourceGroupIds.sorted() }
+ synchronized(RouterGroupRepository.routerSyncLock) {
+ SagerDatabase.instance.runInTransaction {
+ result.membersByRouterId.forEach { (routerId, members) ->
+ // Re-read the router and its sources inside the lock to detect concurrent changes.
+ val freshRouter = SagerDatabase.routerGroupDao.getById(routerId) ?: return@forEach
+ val freshSources = SagerDatabase.routerGroupSourceDao.sourcesFor(routerId).map { it.sourceGroupId }.sorted()
+ // If the persisted matchConfig or sources have changed since we computed members, our
+ // result is stale. Skip writing; the save() that changed the config/sources will
+ // trigger a new reconcile with up-to-date filter and source data.
+ val computedMatchConfig = snapshotMatchConfigs[routerId]
+ val computedSources = snapshotSourceGroupIds[routerId]
+ if (computedMatchConfig != null && computedMatchConfig != freshRouter.matchConfig) {
+ Logs.w("Router ${freshRouter.stableTag}: matchConfig changed during reconcile, skipping stale members")
+ return@forEach
+ }
+ if (computedSources != null && computedSources != freshSources) {
+ Logs.w("Router ${freshRouter.stableTag}: sources changed during reconcile, skipping stale members")
+ return@forEach
+ }
+ SagerDatabase.routerMemberDao.replaceMembers(
+ routerId,
+ members.map { member ->
+ RouterMember(
+ routerId = routerId,
+ proxyId = member.proxyId,
+ userOrder = member.userOrder,
+ lastMatchedAt = matchedAt
+ )
+ }
+ )
+ val selectedProxyId = if (freshRouter.selectedProxyId != RouterGroup.NO_SELECTION &&
+ members.any { it.proxyId == freshRouter.selectedProxyId }
+ ) {
+ freshRouter.selectedProxyId
+ } else {
+ result.selectedProxyIdsByRouterId[routerId] ?: RouterGroup.NO_SELECTION
+ }
+ val selectedNodeKey = members.firstOrNull { it.proxyId == selectedProxyId }
+ ?.let { routerNodeKey(it.sourceGroupId, it.stableId) }
+ .orEmpty()
+ val lastError = if (members.isEmpty()) "No nodes match ${freshRouter.name}" else ""
+ // Use a field-level update to avoid overwriting fields that may have been
+ // changed by a concurrent save() (e.g. matchConfig, mode, name).
+ SagerDatabase.routerGroupDao.updateSelectionAndError(
+ routerId = routerId,
+ selectedProxyId = selectedProxyId,
+ selectedNodeKey = selectedNodeKey,
+ lastError = lastError,
+ )
+ }
+ cleanupDanglingRouterMembers()
+ }
+ }
+ iterator { routerGroupsUpdated() }
+ }
+
+ fun markRouterRefreshFailed(sourceGroupId: Long, message: String) {
+ val error = message.ifBlank { "Subscription refresh failed" }
+ SagerDatabase.routerGroupSourceDao.routersForSource(sourceGroupId)
+ .forEach { source ->
+ // Use a field-level update to avoid overwriting concurrent changes to other fields
+ // (e.g. matchConfig updated by a save() racing with a failed refresh).
+ SagerDatabase.routerGroupDao.setLastError(source.routerId, error)
+ }
+ }
+
+ fun cleanupDanglingRouterMembers(clearInvalidSelections: Boolean = true) {
+ runCatching {
+ val currentProxyIds = SagerDatabase.proxyDao.getAll()
+ .filter { runCatching { it.requireBean() }.isSuccess }
+ .map { it.id }
+ .toSet()
+ val members = SagerDatabase.routerGroupDao.all().flatMap { router ->
+ SagerDatabase.routerMemberDao.getByRouter(router.id)
+ }
+ danglingRouterMemberProxyIds(members.map { member ->
+ RouterMemberSnapshot(member.proxyId, "proxy:${member.proxyId}")
+ }, currentProxyIds).forEach { proxyId ->
+ SagerDatabase.routerMemberDao.deleteByProxy(proxyId)
+ }
+ if (clearInvalidSelections) SagerDatabase.routerGroupDao.clearInvalidSelections()
+ }.onFailure { error ->
+ Logs.e("Unable to clean dangling router members", error)
+ }
+ }
+
suspend fun createGroup(group: ProxyGroup): ProxyGroup {
group.userOrder = SagerDatabase.groupDao.nextOrder() ?: 1
group.id = SagerDatabase.groupDao.createGroup(group.applyDefaultValues())
@@ -98,17 +313,46 @@ object GroupManager {
}
suspend fun deleteGroup(groupId: Long) {
+ val routerSnapshot = snapshotRouterMembers()
+ SagerDatabase.routerGroupSourceDao.deleteBySource(groupId)
SagerDatabase.groupDao.deleteById(groupId)
SagerDatabase.proxyDao.deleteByGroup(groupId)
+ reconcileRouterMembers(routerSnapshot)
iterator { groupRemoved(groupId) }
SubscriptionUpdater.reconfigureUpdater()
}
suspend fun deleteGroup(group: List) {
+ val routerSnapshot = snapshotRouterMembers()
+ group.forEach { SagerDatabase.routerGroupSourceDao.deleteBySource(it.id) }
SagerDatabase.groupDao.deleteGroup(group)
SagerDatabase.proxyDao.deleteByGroup(group.map { it.id }.toLongArray())
+ reconcileRouterMembers(routerSnapshot)
for (proxyGroup in group) iterator { groupRemoved(proxyGroup.id) }
SubscriptionUpdater.reconfigureUpdater()
}
-}
\ No newline at end of file
+}
+
+internal fun ProxyEntity.routerStableId(): String {
+ return routerStableIdOrFallback(
+ uuid.takeIf { it.isNotBlank() }
+ ?: runCatching { requireBean().routerStableIdentity() }.getOrNull(),
+ id
+ )
+}
+
+internal fun ProxyEntity.displayNameOrFallback(): String =
+ runCatching { displayName() }.getOrNull()
+ ?.takeIf { it.isNotBlank() }
+ ?: runCatching { displayAddress() }.getOrNull()?.takeIf { it.isNotBlank() }
+ ?: uuid.takeIf { it.isNotBlank() }
+ ?: "Proxy $id"
+
+internal fun AbstractBean.routerStableIdentity(): String {
+ return clone().apply {
+ name = ""
+ customOutboundJson = ""
+ customConfigJson = ""
+ }.toUniversalLink()
+}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/database/ProfileManager.kt b/app/src/main/java/io/nekohasekai/sagernet/database/ProfileManager.kt
index 661db42492..d5cd4a8b27 100644
--- a/app/src/main/java/io/nekohasekai/sagernet/database/ProfileManager.kt
+++ b/app/src/main/java/io/nekohasekai/sagernet/database/ProfileManager.kt
@@ -72,7 +72,7 @@ object ProfileManager {
}
}
- suspend fun createProfile(groupId: Long, bean: AbstractBean): ProxyEntity {
+ suspend fun createProfile(groupId: Long, bean: AbstractBean, reconcile: Boolean = true): ProxyEntity {
bean.applyDefaultValues()
val profile = ProxyEntity(groupId = groupId).apply {
@@ -81,6 +81,11 @@ object ProfileManager {
userOrder = SagerDatabase.proxyDao.nextOrder(groupId) ?: 1
}
profile.id = SagerDatabase.proxyDao.addProxy(profile)
+ if (reconcile && RouterGroupRepository.all().isNotEmpty()) {
+ runCatching {
+ GroupManager.reconcileRouterMembers(GroupManager.snapshotRouterMembers())
+ }
+ }
iterator { onAdd(profile) }
return profile
}
@@ -108,14 +113,18 @@ object ProfileManager {
}
suspend fun deleteProfile2(groupId: Long, profileId: Long) {
+ SagerDatabase.routerMemberDao.deleteByProxy(profileId)
if (SagerDatabase.proxyDao.deleteById(profileId) == 0) return
+ GroupManager.cleanupDanglingRouterMembers()
if (DataStore.selectedProxy == profileId) {
DataStore.selectedProxy = 0L
}
}
suspend fun deleteProfile(groupId: Long, profileId: Long) {
+ SagerDatabase.routerMemberDao.deleteByProxy(profileId)
if (SagerDatabase.proxyDao.deleteById(profileId) == 0) return
+ GroupManager.cleanupDanglingRouterMembers()
if (DataStore.selectedProxy == profileId) {
DataStore.selectedProxy = 0L
}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/database/ProxyEntity.kt b/app/src/main/java/io/nekohasekai/sagernet/database/ProxyEntity.kt
index cb10edf727..9f04e1c898 100644
--- a/app/src/main/java/io/nekohasekai/sagernet/database/ProxyEntity.kt
+++ b/app/src/main/java/io/nekohasekai/sagernet/database/ProxyEntity.kt
@@ -197,16 +197,16 @@ data class ProxyEntity(
}
fun displayType(): String = when (type) {
- TYPE_SOCKS -> socksBean!!.protocolName()
- TYPE_HTTP -> if (httpBean!!.isTLS()) "HTTPS" else "HTTP"
+ TYPE_SOCKS -> socksBean?.protocolName() ?: "SOCKS"
+ TYPE_HTTP -> if (httpBean?.isTLS() == true) "HTTPS" else "HTTP"
TYPE_SS -> "Shadowsocks"
TYPE_SSR -> "ShadowsocksR"
- TYPE_VMESS -> if (vmessBean!!.isVLESS) "VLESS" else "VMess"
+ TYPE_VMESS -> if (vmessBean?.isVLESS == true) "VLESS" else "VMess"
TYPE_TROJAN -> "Trojan"
TYPE_TROJAN_GO -> "Trojan-Go"
TYPE_MIERU -> "Mieru"
TYPE_NAIVE -> "Naïve"
- TYPE_HYSTERIA -> "Hysteria" + hysteriaBean!!.protocolVersion
+ TYPE_HYSTERIA -> "Hysteria" + (hysteriaBean?.protocolVersion ?: "")
TYPE_SSH -> "SSH"
TYPE_WG -> "WireGuard"
TYPE_TUIC -> "TUIC"
@@ -214,8 +214,8 @@ data class ProxyEntity(
TYPE_SHADOWTLS -> "ShadowTLS"
TYPE_ANYTLS -> "AnyTLS"
TYPE_CHAIN -> chainName
- TYPE_NEKO -> nekoBean!!.displayType()
- TYPE_CONFIG -> configBean!!.displayType()
+ TYPE_NEKO -> nekoBean?.displayType() ?: "Neko"
+ TYPE_CONFIG -> configBean?.displayType() ?: "Config"
TYPE_SNELL -> "Snell"
else -> "Undefined type $type"
}
@@ -246,7 +246,7 @@ data class ProxyEntity(
TYPE_CONFIG -> configBean
TYPE_SNELL -> snellBean
else -> error("Undefined type $type")
- } ?: error("Null ${displayType()} profile")
+ } ?: error("Missing profile data (type=$type, id=$id, groupId=$groupId)")
}
fun haveLink(): Boolean {
diff --git a/app/src/main/java/io/nekohasekai/sagernet/database/ProxyGroup.kt b/app/src/main/java/io/nekohasekai/sagernet/database/ProxyGroup.kt
index 27c10602ba..0079ac5488 100644
--- a/app/src/main/java/io/nekohasekai/sagernet/database/ProxyGroup.kt
+++ b/app/src/main/java/io/nekohasekai/sagernet/database/ProxyGroup.kt
@@ -37,11 +37,13 @@ data class ProxyGroup(
output.writeInt(0)
output.writeString(name)
output.writeInt(type)
- val subscription = subscription!!
+ val subscription = checkNotNull(subscription) {
+ "Subscription group ($name) requires subscription data"
+ }
subscription.serializeForShare(output)
} else {
- output.writeInt(0)
+ output.writeInt(1)
output.writeLong(id)
output.writeLong(userOrder)
output.writeBoolean(ungrouped)
@@ -49,9 +51,15 @@ data class ProxyGroup(
output.writeInt(type)
if (type == GroupType.SUBSCRIPTION) {
- subscription?.serializeToBuffer(output)
+ val subscription = checkNotNull(subscription) {
+ "Subscription group $id ($name) requires subscription data"
+ }
+ subscription.serializeToBuffer(output)
}
output.writeInt(order)
+ output.writeBoolean(isSelector)
+ output.writeLong(frontProxy)
+ output.writeLong(landingProxy)
}
}
@@ -81,6 +89,11 @@ data class ProxyGroup(
subscription.deserializeFromBuffer(input)
}
order = input.readInt()
+ if (version >= 1) {
+ isSelector = input.readBoolean()
+ frontProxy = input.readLong()
+ landingProxy = input.readLong()
+ }
}
}
@@ -140,4 +153,4 @@ data class ProxyGroup(
}
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/database/RouterGroup.kt b/app/src/main/java/io/nekohasekai/sagernet/database/RouterGroup.kt
new file mode 100644
index 0000000000..a886d35f67
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sagernet/database/RouterGroup.kt
@@ -0,0 +1,153 @@
+package io.nekohasekai.sagernet.database
+
+import androidx.room.Dao
+import androidx.room.ColumnInfo
+import androidx.room.Delete
+import androidx.room.Entity
+import androidx.room.Index
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.PrimaryKey
+import androidx.room.Query
+import androidx.room.Update
+import com.esotericsoftware.kryo.io.ByteBufferInput
+import com.esotericsoftware.kryo.io.ByteBufferOutput
+import io.nekohasekai.sagernet.fmt.Serializable
+
+@Entity(
+ tableName = "router_groups",
+ indices = [Index("stableTag", unique = true)]
+)
+data class RouterGroup(
+ @PrimaryKey(autoGenerate = true) var id: Long = 0L,
+ var stableTag: String = "",
+ var name: String = "",
+ var mode: Int = MODE_SELECTOR,
+ var enabled: Boolean = true,
+ var matchConfig: String = "{}",
+ var selectedProxyId: Long = NO_SELECTION,
+ var userOrder: Long = 0L,
+ @ColumnInfo(defaultValue = "")
+ var selectedNodeKey: String = "",
+ @ColumnInfo(defaultValue = "")
+ var lastError: String = "",
+) : Serializable() {
+
+ override fun initializeDefaultValues() {
+ }
+
+ override fun serializeToBuffer(output: ByteBufferOutput) {
+ output.writeInt(1)
+ output.writeLong(id)
+ output.writeString(stableTag)
+ output.writeString(name)
+ output.writeInt(mode)
+ output.writeBoolean(enabled)
+ output.writeString(matchConfig)
+ output.writeLong(selectedProxyId)
+ output.writeLong(userOrder)
+ output.writeString(selectedNodeKey)
+ output.writeString(lastError)
+ }
+
+ override fun deserializeFromBuffer(input: ByteBufferInput) {
+ val version = input.readInt()
+ id = input.readLong()
+ stableTag = input.readString()
+ name = input.readString()
+ mode = input.readInt()
+ enabled = input.readBoolean()
+ matchConfig = input.readString()
+ selectedProxyId = input.readLong()
+ userOrder = input.readLong()
+ if (version >= 1) {
+ selectedNodeKey = input.readString()
+ lastError = input.readString()
+ }
+ }
+
+ @androidx.room.Dao
+ interface Dao {
+
+ @Query("SELECT * FROM router_groups ORDER BY userOrder, id")
+ fun all(): List
+
+ @Query("SELECT * FROM router_groups WHERE id = :routerId")
+ fun getById(routerId: Long): RouterGroup?
+
+ @Query("SELECT * FROM router_groups WHERE stableTag = :stableTag")
+ fun getByStableTag(stableTag: String): RouterGroup?
+
+ @Query("SELECT MAX(userOrder) + 1 FROM router_groups")
+ fun nextOrder(): Long?
+
+ @Insert
+ fun create(router: RouterGroup): Long
+
+ @Update
+ fun update(router: RouterGroup): Int
+
+ @Query("""
+ UPDATE router_groups SET selectedProxyId = -1, selectedNodeKey = ''
+ WHERE selectedProxyId != -1 AND (
+ NOT EXISTS (SELECT 1 FROM proxy_entities WHERE id = router_groups.selectedProxyId)
+ OR NOT EXISTS (SELECT 1 FROM router_members
+ WHERE routerId = router_groups.id AND proxyId = router_groups.selectedProxyId)
+ )
+ """)
+ fun clearInvalidSelections(): Int
+
+ @Delete
+ fun delete(router: RouterGroup): Int
+
+ @Query("DELETE FROM router_groups")
+ fun reset()
+
+ @Insert
+ fun insert(routers: List)
+
+ @Query("UPDATE router_groups SET lastError = :error WHERE id = :routerId")
+ fun setLastError(routerId: Long, error: String): Int
+
+ @Query("""
+ UPDATE router_groups
+ SET selectedProxyId = :selectedProxyId, selectedNodeKey = :selectedNodeKey
+ WHERE id = :routerId
+ """)
+ fun updateSelection(
+ routerId: Long,
+ selectedProxyId: Long,
+ selectedNodeKey: String,
+ ): Int
+
+ @Query("""
+ UPDATE router_groups
+ SET selectedProxyId = :selectedProxyId, selectedNodeKey = :selectedNodeKey, lastError = :lastError
+ WHERE id = :routerId
+ """)
+ fun updateSelectionAndError(
+ routerId: Long,
+ selectedProxyId: Long,
+ selectedNodeKey: String,
+ lastError: String,
+ ): Int
+ }
+
+ companion object {
+ const val MODE_SELECTOR = 0
+ const val MODE_URL_TEST = 1
+ const val NO_SELECTION = -1L
+
+ @JvmField
+ val CREATOR = object : Serializable.CREATOR() {
+
+ override fun newInstance(): RouterGroup {
+ return RouterGroup()
+ }
+
+ override fun newArray(size: Int): Array {
+ return arrayOfNulls(size)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/database/RouterGroupRepository.kt b/app/src/main/java/io/nekohasekai/sagernet/database/RouterGroupRepository.kt
new file mode 100644
index 0000000000..382e219c67
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sagernet/database/RouterGroupRepository.kt
@@ -0,0 +1,205 @@
+package io.nekohasekai.sagernet.database
+
+import io.nekohasekai.sagernet.GroupType
+import io.nekohasekai.sagernet.route.RouterFilterConfig
+import io.nekohasekai.sagernet.route.RouterFilterException
+import io.nekohasekai.sagernet.route.RouterMatchRequest
+import io.nekohasekai.sagernet.route.RouterMatcher
+import io.nekohasekai.sagernet.route.RouterNodeSnapshot
+import io.nekohasekai.sagernet.route.routerNodeKey
+import io.nekohasekai.sagernet.route.routerStableIdOrFallback
+import io.nekohasekai.sagernet.ktx.Logs
+import java.net.URI
+import java.util.UUID
+
+data class RouterGroupDraft(
+ val id: Long = 0L,
+ val name: String,
+ val mode: Int,
+ val enabled: Boolean,
+ val sourceGroupIds: List,
+ val filter: RouterFilterConfig,
+)
+
+data class RouterGroupPreview(
+ val proxyIds: List,
+ val names: List,
+)
+
+sealed interface RouterDeleteResult {
+ data object Deleted : RouterDeleteResult
+ data class Referenced(val ruleCount: Int) : RouterDeleteResult
+}
+
+class RouterGroupValidationException(
+ val field: Field,
+ message: String,
+ cause: Throwable? = null,
+) : IllegalArgumentException(message, cause) {
+ enum class Field {
+ NAME,
+ MODE,
+ SOURCES,
+ INCLUDE,
+ EXCLUDE,
+ URL,
+ INTERVAL,
+ TOLERANCE,
+ }
+}
+
+fun RouterGroupDraft.validate(
+ existingGroups: Iterable,
+ validSubscriptionIds: Set,
+) {
+ val normalizedName = name.trim()
+ if (normalizedName.isEmpty() || existingGroups.any {
+ it.id != id && it.name.trim().equals(normalizedName, ignoreCase = true)
+ }
+ ) throw RouterGroupValidationException(RouterGroupValidationException.Field.NAME, "Group name is empty or already used")
+
+ if (mode != RouterGroup.MODE_SELECTOR && mode != RouterGroup.MODE_URL_TEST) {
+ throw RouterGroupValidationException(RouterGroupValidationException.Field.MODE, "Unsupported group mode")
+ }
+ if (enabled && sourceGroupIds.isEmpty() || sourceGroupIds.any { it !in validSubscriptionIds }) {
+ throw RouterGroupValidationException(RouterGroupValidationException.Field.SOURCES, "Select at least one existing subscription")
+ }
+ try {
+ filter.validate()
+ } catch (error: RouterFilterException) {
+ val field = if (error.field == RouterFilterException.Field.INCLUDE) {
+ RouterGroupValidationException.Field.INCLUDE
+ } else {
+ RouterGroupValidationException.Field.EXCLUDE
+ }
+ throw RouterGroupValidationException(field, error.message ?: "Invalid regular expression", error)
+ }
+ if (filter.intervalSeconds < 10) {
+ throw RouterGroupValidationException(RouterGroupValidationException.Field.INTERVAL, "Interval must be at least 10 seconds")
+ }
+ if (filter.intervalSeconds > 86400) {
+ throw RouterGroupValidationException(RouterGroupValidationException.Field.INTERVAL, "Interval must not exceed 86400 seconds (24 hours)")
+ }
+ if (filter.toleranceMs !in 0..65535) {
+ throw RouterGroupValidationException(RouterGroupValidationException.Field.TOLERANCE, "Tolerance must be between 0 and 65535 ms")
+ }
+ val uri = runCatching { URI(filter.testUrl) }.getOrNull()
+ if (mode == RouterGroup.MODE_URL_TEST && (uri?.scheme !in setOf("http", "https") || uri?.host.isNullOrBlank())) {
+ throw RouterGroupValidationException(RouterGroupValidationException.Field.URL, "Test URL must be HTTP or HTTPS")
+ }
+}
+
+object RouterGroupRepository {
+ fun all(): List = SagerDatabase.routerGroupDao.all()
+
+ fun get(routerId: Long): RouterGroup? = SagerDatabase.routerGroupDao.getById(routerId)
+
+ fun sourceIds(routerId: Long): List =
+ SagerDatabase.routerGroupSourceDao.sourcesFor(routerId).map { it.sourceGroupId }
+
+ fun preview(draft: RouterGroupDraft): RouterGroupPreview {
+ draft.filter.validate()
+ val sourceIds = draft.sourceGroupIds.distinct()
+ val nodes = sourceIds.flatMap { sourceId ->
+ SagerDatabase.proxyDao.getByGroup(sourceId).mapNotNull { proxy ->
+ runCatching {
+ proxy.requireBean()
+ RouterNodeSnapshot(
+ id = proxy.id,
+ stableId = proxy.routerStableId(),
+ name = proxy.displayNameOrFallback(),
+ subscriptionId = sourceId,
+ enabled = true,
+ available = true,
+ )
+ }.onFailure { error ->
+ Logs.e("Failed to snapshot proxy ${proxy.id}", error)
+ }.getOrNull()
+ }
+ }
+ val subNames = runCatching { SagerDatabase.groupDao.allGroups().associate { it.id to it.displayName() } }.getOrDefault(emptyMap())
+ val ids = RouterMatcher.match(
+ nodes,
+ listOf(RouterMatchRequest(draft.id, sourceIds, draft.filter.validate())),
+ )[draft.id].orEmpty()
+ val byId = nodes.associateBy { it.id }
+ val names = ids.mapNotNull { id ->
+ byId[id]?.let { node ->
+ val subName = node.subscriptionId?.let { subNames[it] }
+ if (!subName.isNullOrBlank()) "[${subName}] ${node.name.trim()}" else node.name.trim()
+ }
+ }
+ return RouterGroupPreview(ids, names)
+ }
+
+ internal val routerSyncLock = Any()
+
+ suspend fun save(draft: RouterGroupDraft): RouterGroup {
+ val subscriptions = SagerDatabase.groupDao.allGroups()
+ .filter { it.type == GroupType.SUBSCRIPTION }
+ .mapTo(mutableSetOf()) { it.id }
+ draft.validate(all(), subscriptions)
+ val snapshot = GroupManager.snapshotRouterMembers()
+ val savedGroup = synchronized(routerSyncLock) {
+ SagerDatabase.instance.runInTransaction {
+ val existing = draft.id.takeIf { it > 0 }?.let(SagerDatabase.routerGroupDao::getById)
+ val group = RouterGroup(
+ id = existing?.id ?: 0L,
+ stableTag = existing?.stableTag ?: newStableTag(),
+ name = draft.name.trim(),
+ mode = draft.mode,
+ enabled = draft.enabled,
+ matchConfig = draft.filter.toJson(),
+ selectedProxyId = existing?.selectedProxyId ?: RouterGroup.NO_SELECTION,
+ userOrder = existing?.userOrder ?: (SagerDatabase.routerGroupDao.nextOrder() ?: 1L),
+ selectedNodeKey = existing?.selectedNodeKey.orEmpty(),
+ lastError = existing?.lastError.orEmpty(),
+ )
+ if (existing == null) group.id = SagerDatabase.routerGroupDao.create(group)
+ else SagerDatabase.routerGroupDao.update(group)
+ SagerDatabase.routerGroupSourceDao.replaceSources(group.id, draft.sourceGroupIds)
+ group
+ }
+ }
+ GroupManager.reconcileRouterMembers(snapshot)
+ return SagerDatabase.routerGroupDao.getById(savedGroup.id) ?: savedGroup
+ }
+
+ fun delete(routerId: Long): RouterDeleteResult {
+ val references = SagerDatabase.rulesDao.countByRouterGroup(routerId)
+ if (references > 0) return RouterDeleteResult.Referenced(references)
+ val group = SagerDatabase.routerGroupDao.getById(routerId) ?: return RouterDeleteResult.Deleted
+ SagerDatabase.instance.runInTransaction {
+ SagerDatabase.routerMemberDao.deleteByRouter(routerId)
+ SagerDatabase.routerGroupSourceDao.deleteByRouter(routerId)
+ SagerDatabase.routerGroupDao.delete(group)
+ }
+ return RouterDeleteResult.Deleted
+ }
+
+ fun select(routerId: Long, proxyId: Long): RouterGroup = synchronized(routerSyncLock) {
+ SagerDatabase.instance.runInTransaction {
+ val group = SagerDatabase.routerGroupDao.getById(routerId)
+ ?: throw IllegalArgumentException("Proxy group does not exist")
+ check(group.enabled && group.mode == RouterGroup.MODE_SELECTOR) {
+ "Only an enabled selector group accepts a manual selection"
+ }
+ check(SagerDatabase.routerMemberDao.getByRouter(routerId).any { it.proxyId == proxyId }) {
+ "Selected node is not a member of ${group.name}"
+ }
+ val proxy = SagerDatabase.proxyDao.getById(proxyId)
+ ?: throw IllegalArgumentException("Selected node does not exist")
+ proxy.requireBean()
+ val stableId = proxy.routerStableId()
+ val updated = group.copy(
+ selectedProxyId = proxyId,
+ selectedNodeKey = routerNodeKey(proxy.groupId, stableId),
+ )
+ SagerDatabase.routerGroupDao.update(updated)
+ updated
+ }
+ }
+
+ private fun newStableTag(): String =
+ "router." + UUID.randomUUID().toString().replace("-", "").lowercase()
+}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/database/RouterGroupSource.kt b/app/src/main/java/io/nekohasekai/sagernet/database/RouterGroupSource.kt
new file mode 100644
index 0000000000..730daa3353
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sagernet/database/RouterGroupSource.kt
@@ -0,0 +1,81 @@
+package io.nekohasekai.sagernet.database
+
+import androidx.room.Entity
+import androidx.room.ColumnInfo
+import androidx.room.Index
+import androidx.room.Insert
+import androidx.room.Query
+import androidx.room.Transaction
+import com.esotericsoftware.kryo.io.ByteBufferInput
+import com.esotericsoftware.kryo.io.ByteBufferOutput
+import io.nekohasekai.sagernet.fmt.Serializable
+
+@Entity(
+ tableName = "router_group_sources",
+ primaryKeys = ["routerId", "sourceGroupId"],
+ indices = [Index("sourceGroupId")],
+)
+data class RouterGroupSource(
+ var routerId: Long = 0L,
+ var sourceGroupId: Long = 0L,
+ @ColumnInfo(defaultValue = "0")
+ var userOrder: Long = 0L,
+) : Serializable() {
+
+ override fun initializeDefaultValues() = Unit
+
+ override fun serializeToBuffer(output: ByteBufferOutput) {
+ output.writeInt(0)
+ output.writeLong(routerId)
+ output.writeLong(sourceGroupId)
+ output.writeLong(userOrder)
+ }
+
+ override fun deserializeFromBuffer(input: ByteBufferInput) {
+ input.readInt()
+ routerId = input.readLong()
+ sourceGroupId = input.readLong()
+ userOrder = input.readLong()
+ }
+
+ @androidx.room.Dao
+ interface Dao {
+ @Query("SELECT * FROM router_group_sources ORDER BY routerId, userOrder, sourceGroupId")
+ fun all(): List
+
+ @Query("SELECT * FROM router_group_sources WHERE routerId = :routerId ORDER BY userOrder, sourceGroupId")
+ fun sourcesFor(routerId: Long): List
+
+ @Query("SELECT * FROM router_group_sources WHERE sourceGroupId = :sourceGroupId ORDER BY routerId")
+ fun routersForSource(sourceGroupId: Long): List
+
+ @Query("DELETE FROM router_group_sources WHERE routerId = :routerId")
+ fun deleteByRouter(routerId: Long): Int
+
+ @Query("DELETE FROM router_group_sources WHERE sourceGroupId = :sourceGroupId")
+ fun deleteBySource(sourceGroupId: Long): Int
+
+ @Insert
+ fun insert(sources: List)
+
+ @Transaction
+ fun replaceSources(routerId: Long, sourceGroupIds: Iterable) {
+ deleteByRouter(routerId)
+ val rows = sourceGroupIds.distinct().mapIndexed { index, sourceGroupId ->
+ RouterGroupSource(routerId, sourceGroupId, index.toLong())
+ }
+ if (rows.isNotEmpty()) insert(rows)
+ }
+
+ @Query("DELETE FROM router_group_sources")
+ fun reset()
+ }
+
+ companion object {
+ @JvmField
+ val CREATOR = object : Serializable.CREATOR() {
+ override fun newInstance() = RouterGroupSource()
+ override fun newArray(size: Int): Array = arrayOfNulls(size)
+ }
+ }
+}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/database/RouterMember.kt b/app/src/main/java/io/nekohasekai/sagernet/database/RouterMember.kt
new file mode 100644
index 0000000000..9e2ce378e3
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sagernet/database/RouterMember.kt
@@ -0,0 +1,104 @@
+package io.nekohasekai.sagernet.database
+
+import androidx.room.Dao
+import androidx.room.Entity
+import androidx.room.Index
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import androidx.room.Transaction
+import com.esotericsoftware.kryo.io.ByteBufferInput
+import com.esotericsoftware.kryo.io.ByteBufferOutput
+import io.nekohasekai.sagernet.fmt.Serializable
+
+@Entity(
+ tableName = "router_members",
+ primaryKeys = ["routerId", "proxyId"],
+ indices = [Index("proxyId")]
+)
+data class RouterMember(
+ var routerId: Long = 0L,
+ var proxyId: Long = 0L,
+ var userOrder: Long = 0L,
+ var lastMatchedAt: Long = 0L
+) : Serializable() {
+
+ override fun initializeDefaultValues() {
+ }
+
+ override fun serializeToBuffer(output: ByteBufferOutput) {
+ output.writeInt(0)
+ output.writeLong(routerId)
+ output.writeLong(proxyId)
+ output.writeLong(userOrder)
+ output.writeLong(lastMatchedAt)
+ }
+
+ override fun deserializeFromBuffer(input: ByteBufferInput) {
+ input.readInt()
+ routerId = input.readLong()
+ proxyId = input.readLong()
+ userOrder = input.readLong()
+ lastMatchedAt = input.readLong()
+ }
+
+ @androidx.room.Dao
+ interface Dao {
+
+ @Query("SELECT * FROM router_members ORDER BY routerId, userOrder, proxyId")
+ fun all(): List
+
+ @Query("SELECT * FROM router_members WHERE routerId = :routerId ORDER BY userOrder, proxyId")
+ fun getByRouter(routerId: Long): List
+
+ @Query("DELETE FROM router_members WHERE routerId = :routerId")
+ fun deleteByRouter(routerId: Long): Int
+
+ @Query("DELETE FROM router_members WHERE proxyId = :proxyId")
+ fun deleteByProxy(proxyId: Long): Int
+
+ @Query("UPDATE router_members SET userOrder = :userOrder WHERE routerId = :routerId AND proxyId = :proxyId")
+ fun updateUserOrder(routerId: Long, proxyId: Long, userOrder: Long): Int
+
+ @Transaction
+ fun updateOrders(routerId: Long, orderedProxyIds: List) {
+ val existing = getByRouter(routerId)
+ if (existing.isEmpty()) return
+ val existingIds = existing.mapTo(hashSetOf()) { it.proxyId }
+ val ordered = orderedProxyIds.distinct().filter { it in existingIds }
+ val orderedSet = ordered.toSet()
+ val merged = ordered + existing.filter { it.proxyId !in orderedSet }.map { it.proxyId }
+ for ((index, proxyId) in merged.withIndex()) {
+ updateUserOrder(routerId, proxyId, (index + 1).toLong())
+ }
+ }
+
+ @Insert
+ fun insert(members: List)
+
+ @Transaction
+ fun replaceMembers(routerId: Long, members: List) {
+ deleteByRouter(routerId)
+ if (members.isNotEmpty()) {
+ insert(members.map { it.copy(routerId = routerId) })
+ }
+ }
+
+ @Query("DELETE FROM router_members")
+ fun reset()
+ }
+
+ companion object {
+ @JvmField
+ val CREATOR = object : Serializable.CREATOR() {
+
+ override fun newInstance(): RouterMember {
+ return RouterMember()
+ }
+
+ override fun newArray(size: Int): Array {
+ return arrayOfNulls(size)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/database/RuleEntity.kt b/app/src/main/java/io/nekohasekai/sagernet/database/RuleEntity.kt
index e730807397..8604ef932a 100644
--- a/app/src/main/java/io/nekohasekai/sagernet/database/RuleEntity.kt
+++ b/app/src/main/java/io/nekohasekai/sagernet/database/RuleEntity.kt
@@ -4,6 +4,7 @@ import android.os.Parcelable
import androidx.room.*
import io.nekohasekai.sagernet.R
import io.nekohasekai.sagernet.ktx.app
+import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
@Entity(tableName = "rules")
@@ -27,6 +28,9 @@ data class RuleEntity(
var ruleset: String = "",
var outbound: Long = 0,
var packages: Set = emptySet(),
+ @IgnoredOnParcel
+ @ColumnInfo(defaultValue = "0")
+ var routerGroupId: Long = 0L,
) : Parcelable {
fun displayName(): String {
@@ -56,6 +60,10 @@ data class RuleEntity(
}
fun displayOutbound(): String {
+ if (routerGroupId > 0L) {
+ return SagerDatabase.routerGroupDao.getById(routerGroupId)?.name
+ ?: app.getString(R.string.router_reference_invalid)
+ }
return when (outbound) {
0L -> app.getString(R.string.route_proxy)
-1L -> app.getString(R.string.route_bypass)
@@ -83,6 +91,9 @@ data class RuleEntity(
@Query("SELECT * FROM rules WHERE id = :ruleId")
fun getById(ruleId: Long): RuleEntity?
+ @Query("SELECT COUNT(*) FROM rules WHERE routerGroupId = :routerGroupId")
+ fun countByRouterGroup(routerGroupId: Long): Int
+
@Query("DELETE FROM rules WHERE id = :ruleId")
fun deleteById(ruleId: Long): Int
diff --git a/app/src/main/java/io/nekohasekai/sagernet/database/SagerDatabase.kt b/app/src/main/java/io/nekohasekai/sagernet/database/SagerDatabase.kt
index ab10fedb89..50531b340d 100644
--- a/app/src/main/java/io/nekohasekai/sagernet/database/SagerDatabase.kt
+++ b/app/src/main/java/io/nekohasekai/sagernet/database/SagerDatabase.kt
@@ -15,14 +15,17 @@ import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
@Database(
- entities = [ProxyGroup::class, ProxyEntity::class, RuleEntity::class],
- version = 8,
+ entities = [ProxyGroup::class, ProxyEntity::class, RuleEntity::class, RouterGroup::class, RouterMember::class, RouterGroupSource::class],
+ version = 10,
+ // This phase supports upgrades from v3 through v9; v1/v2 compatibility is deferred to a separate migration task.
autoMigrations = [
AutoMigration(from = 3, to = 4),
AutoMigration(from = 4, to = 5),
AutoMigration(from = 5, to = 6),
AutoMigration(from = 6, to = 7),
- AutoMigration(from = 7, to = 8)
+ AutoMigration(from = 7, to = 8),
+ AutoMigration(from = 8, to = 9),
+ AutoMigration(from = 9, to = 10)
]
)
@TypeConverters(value = [KryoConverters::class, GsonConverters::class])
@@ -39,7 +42,6 @@ abstract class SagerDatabase : RoomDatabase() {
.setJournalMode(JournalMode.TRUNCATE)
.allowMainThreadQueries()
.enableMultiInstanceInvalidation()
- .fallbackToDestructiveMigration()
.setQueryExecutor { GlobalScope.launch { it.run() } }
.build()
}
@@ -47,11 +49,17 @@ abstract class SagerDatabase : RoomDatabase() {
val groupDao get() = instance.groupDao()
val proxyDao get() = instance.proxyDao()
val rulesDao get() = instance.rulesDao()
+ val routerGroupDao get() = instance.routerGroupDao()
+ val routerMemberDao get() = instance.routerMemberDao()
+ val routerGroupSourceDao get() = instance.routerGroupSourceDao()
}
abstract fun groupDao(): ProxyGroup.Dao
abstract fun proxyDao(): ProxyEntity.Dao
abstract fun rulesDao(): RuleEntity.Dao
+ abstract fun routerGroupDao(): RouterGroup.Dao
+ abstract fun routerMemberDao(): RouterMember.Dao
+ abstract fun routerGroupSourceDao(): RouterGroupSource.Dao
}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/database/preference/KeyValuePair.kt b/app/src/main/java/io/nekohasekai/sagernet/database/preference/KeyValuePair.kt
index 0d13ee9d62..62000e7eba 100644
--- a/app/src/main/java/io/nekohasekai/sagernet/database/preference/KeyValuePair.kt
+++ b/app/src/main/java/io/nekohasekai/sagernet/database/preference/KeyValuePair.kt
@@ -58,34 +58,62 @@ class KeyValuePair() : Parcelable {
var valueType: Int = TYPE_UNINITIALIZED
var value: ByteArray = ByteArray(0)
+ fun validate(): KeyValuePair {
+ require(key.isNotBlank()) { "Setting key cannot be blank" }
+ when (valueType) {
+ TYPE_BOOLEAN -> require(value.isNotEmpty()) { "Setting $key (boolean) payload is empty" }
+ TYPE_FLOAT -> require(value.size >= 4) { "Setting $key (float) payload truncated: ${value.size} bytes" }
+ @Suppress("DEPRECATION") TYPE_INT -> require(value.size >= 4) { "Setting $key (int) payload truncated: ${value.size} bytes" }
+ TYPE_LONG -> require(value.size >= 8) { "Setting $key (long) payload truncated: ${value.size} bytes" }
+ TYPE_STRING -> Unit
+ TYPE_STRING_SET -> {
+ val buffer = ByteBuffer.wrap(value)
+ while (buffer.hasRemaining()) {
+ require(buffer.remaining() >= 4) { "Setting $key (stringSet) entry length header truncated" }
+ val len = buffer.int
+ require(len >= 0 && buffer.remaining() >= len) {
+ "Setting $key (stringSet) entry truncated or invalid length: $len, remaining: ${buffer.remaining()}"
+ }
+ buffer.position(buffer.position() + len)
+ }
+ }
+ else -> throw IllegalArgumentException("Setting $key has invalid valueType: $valueType")
+ }
+ return this
+ }
+
val boolean: Boolean?
- get() = if (valueType == TYPE_BOOLEAN) ByteBuffer.wrap(value).get() != 0.toByte() else null
+ get() = if (valueType == TYPE_BOOLEAN && value.isNotEmpty()) ByteBuffer.wrap(value).get() != 0.toByte() else null
val float: Float?
- get() = if (valueType == TYPE_FLOAT) ByteBuffer.wrap(value).float else null
+ get() = if (valueType == TYPE_FLOAT && value.size >= 4) ByteBuffer.wrap(value).float else null
@Suppress("DEPRECATION")
@Deprecated("Use long.", ReplaceWith("long"))
val int: Int?
- get() = if (valueType == TYPE_INT) ByteBuffer.wrap(value).int else null
+ get() = if (valueType == TYPE_INT && value.size >= 4) ByteBuffer.wrap(value).int else null
val long: Long?
get() = when (valueType) {
- @Suppress("DEPRECATION") TYPE_INT,
- -> ByteBuffer.wrap(value).int.toLong()
- TYPE_LONG -> ByteBuffer.wrap(value).long
+ @Suppress("DEPRECATION") TYPE_INT -> if (value.size >= 4) ByteBuffer.wrap(value).int.toLong() else null
+ TYPE_LONG -> if (value.size >= 8) ByteBuffer.wrap(value).long else null
else -> null
}
val string: String?
get() = if (valueType == TYPE_STRING) String(value) else null
val stringSet: Set?
get() = if (valueType == TYPE_STRING_SET) {
- val buffer = ByteBuffer.wrap(value)
- val result = HashSet()
- while (buffer.hasRemaining()) {
- val chArr = ByteArray(buffer.int)
- buffer.get(chArr)
- result.add(String(chArr))
- }
- result
+ runCatching {
+ val buffer = ByteBuffer.wrap(value)
+ val result = HashSet()
+ while (buffer.hasRemaining()) {
+ if (buffer.remaining() < 4) return null
+ val len = buffer.int
+ if (len < 0 || buffer.remaining() < len) return null
+ val chArr = ByteArray(len)
+ buffer.get(chArr)
+ result.add(String(chArr))
+ }
+ result
+ }.getOrNull()
} else null
@Ignore
@@ -131,9 +159,10 @@ class KeyValuePair() : Parcelable {
val stream = ByteArrayOutputStream()
val intBuffer = ByteBuffer.allocate(4)
for (v in value) {
+ val bytes = v.toByteArray() // compute UTF-8 bytes once
intBuffer.rewind()
- stream.write(intBuffer.putInt(v.length).array())
- stream.write(v.toByteArray())
+ stream.write(intBuffer.putInt(bytes.size).array()) // write byte count (not char count)
+ stream.write(bytes)
}
this.value = stream.toByteArray()
return this
diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/BackupSerializer.kt b/app/src/main/java/io/nekohasekai/sagernet/fmt/BackupSerializer.kt
new file mode 100644
index 0000000000..a5e4bc7dd7
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/BackupSerializer.kt
@@ -0,0 +1,230 @@
+package io.nekohasekai.sagernet.fmt
+
+import android.os.Parcel
+import android.os.Parcelable
+import io.nekohasekai.sagernet.database.RouterGroup
+import io.nekohasekai.sagernet.database.RuleEntity
+import io.nekohasekai.sagernet.database.SagerDatabase
+import io.nekohasekai.sagernet.database.routerStableId
+import moe.matsuri.nb4a.utils.Util
+import org.json.JSONArray
+import org.json.JSONObject
+
+/** The Parcelable-backed JSON array format used by NekoBox backups. */
+object BackupSerializer {
+
+ const val BACKUP_VERSION = 3
+
+ data class RouterRuleReference(
+ val ruleId: Long,
+ val routerGroupId: Long,
+ val routerStableTag: String? = null,
+ )
+
+ /** Capture related rows together; exporting must never repair or delete user data. */
+ fun exportDatabase(database: SagerDatabase, profiles: Boolean, rules: Boolean): JSONObject {
+ val json = JSONObject().put("version", BACKUP_VERSION)
+ database.runInTransaction {
+ if (profiles) {
+ val allProfiles = database.proxyDao().getAll()
+ allProfiles.forEach { it.requireBean() }
+ putParcelableArray(json, "profiles", allProfiles)
+ putParcelableArray(json, "groups", database.groupDao().allGroups())
+ putParcelableArray(json, "routerGroups", database.routerGroupDao().all())
+ putParcelableArray(json, "routerMembers", database.routerMemberDao().all())
+ putParcelableArray(json, "routerSources", database.routerGroupSourceDao().all())
+ }
+ if (rules) {
+ val allRules = database.rulesDao().allRules()
+ val allRouters = database.routerGroupDao().all()
+ putParcelableArray(json, "rules", allRules)
+ putRouterRuleReferences(json, allRules, allRouters)
+ if (!profiles) {
+ // When exporting rules without profiles, the import side cannot reconstruct
+ // backupProxies from the backup. Attach a stable-identity index so the importer
+ // can verify that each rule's outbound proxy still refers to the same node.
+ putRuleOutboundRefs(json, allRules, database)
+ }
+ }
+ }
+ return json
+ }
+
+ fun putRouterRuleReferences(
+ json: JSONObject,
+ rules: Iterable,
+ routers: Iterable = emptyList(),
+ ) {
+ val routerTagMap = routers.associate { it.id to it.stableTag }
+ json.put("routerRuleRefs", JSONArray().apply {
+ rules.filter { it.routerGroupId > 0L }.forEach { rule ->
+ put(JSONObject().apply {
+ put("ruleId", rule.id)
+ put("routerGroupId", rule.routerGroupId)
+ routerTagMap[rule.routerGroupId]?.takeIf { it.isNotBlank() }?.let {
+ put("routerStableTag", it)
+ }
+ })
+ }
+ })
+ }
+
+ /**
+ * Emits a `ruleOutboundRefs` section mapping each rule's legacy outbound proxy ID to the
+ * node's stable identity string. Used when exporting rules without profiles so the import
+ * side can verify node identity without needing the full profiles section.
+ */
+ fun putRuleOutboundRefs(
+ json: JSONObject,
+ rules: Iterable,
+ database: SagerDatabase,
+ ) {
+ val outboundIds = rules.mapNotNull { r -> r.outbound.takeIf { it > 0L } }.toSet()
+ if (outboundIds.isEmpty()) return
+ val stableIdMap = outboundIds.associateWith { proxyId ->
+ database.proxyDao().getById(proxyId)?.let { proxy ->
+ runCatching { proxy.requireBean() }.getOrNull()
+ ?.let { proxy.routerStableId() }
+ }
+ }
+ json.put("ruleOutboundRefs", JSONArray().apply {
+ rules.filter { it.outbound > 0L }.forEach { rule ->
+ val stableId = stableIdMap[rule.outbound] ?: return@forEach
+ put(JSONObject().apply {
+ put("ruleId", rule.id)
+ put("outbound", rule.outbound)
+ put("stableId", stableId)
+ })
+ }
+ })
+ }
+
+ /** Returns a map from rule ID to outbound stable ID, read from `ruleOutboundRefs`. */
+ fun getRuleOutboundStableIds(json: JSONObject): Map {
+ if (!json.has("ruleOutboundRefs")) return emptyMap()
+ require(!json.isNull("ruleOutboundRefs")) { "Section 'ruleOutboundRefs' in backup cannot be null" }
+ val values = json.optJSONArray("ruleOutboundRefs")
+ ?: throw IllegalArgumentException("Section 'ruleOutboundRefs' in backup must be a JSON array")
+ val result = HashMap()
+ for (i in 0 until values.length()) {
+ val obj = values.getJSONObject(i)
+ val ruleId = obj.getLong("ruleId")
+ val stableId = obj.optString("stableId").takeIf { it.isNotBlank() } ?: continue
+ result[ruleId] = stableId
+ }
+ return result
+ }
+
+ fun validateRuleReferences(
+ rules: Collection,
+ routerIds: Set,
+ proxyIds: Set
+ ) {
+ for (rule in rules) {
+ if (rule.routerGroupId > 0L) {
+ require(rule.routerGroupId in routerIds) {
+ "Rule ${rule.id} references missing router group ${rule.routerGroupId}"
+ }
+ } else if (rule.outbound > 0L) {
+ require(rule.outbound in proxyIds) {
+ "Rule ${rule.id} references missing profile ${rule.outbound}"
+ }
+ }
+ }
+ }
+
+ fun getRouterRuleReferenceList(json: JSONObject): List {
+ if (!json.has("routerRuleRefs")) {
+ return emptyList()
+ }
+ require(!json.isNull("routerRuleRefs")) { "Section 'routerRuleRefs' in backup cannot be null" }
+ val values = json.optJSONArray("routerRuleRefs")
+ ?: throw IllegalArgumentException("Section 'routerRuleRefs' in backup must be a JSON array")
+ val list = ArrayList(values.length())
+ for (index in 0 until values.length()) {
+ val value = values.getJSONObject(index)
+ val ruleId = value.getLong("ruleId")
+ val routerGroupId = value.getLong("routerGroupId")
+ val routerStableTag = value.optString("routerStableTag").takeIf { it.isNotBlank() }
+ if (ruleId > 0L && routerGroupId > 0L) {
+ list.add(RouterRuleReference(ruleId, routerGroupId, routerStableTag))
+ }
+ }
+ return list
+ }
+
+ fun getRouterRuleReferences(json: JSONObject): Map {
+ return getRouterRuleReferenceList(json).associate { it.ruleId to it.routerGroupId }
+ }
+
+ fun putParcelableArray(
+ json: JSONObject,
+ key: String,
+ values: Iterable
+ ) {
+ json.put(key, JSONArray().apply {
+ values.forEach { put(encode(it)) }
+ })
+ }
+
+ fun getParcelableArray(
+ json: JSONObject,
+ key: String,
+ creator: Parcelable.Creator
+ ): List {
+ if (!json.has(key)) return emptyList()
+ require(!json.isNull(key)) { "Section '$key' in backup cannot be null" }
+ val values = json.optJSONArray(key)
+ ?: throw IllegalArgumentException("Section '$key' in backup must be a JSON array")
+ return (0 until values.length()).map { index ->
+ decode(values.getString(index), creator)
+ }
+ }
+
+ fun getParcelableArray(
+ json: JSONObject,
+ key: String,
+ decoder: (Parcel) -> T
+ ): List {
+ if (!json.has(key)) return emptyList()
+ require(!json.isNull(key)) { "Section '$key' in backup cannot be null" }
+ val values = json.optJSONArray(key)
+ ?: throw IllegalArgumentException("Section '$key' in backup must be a JSON array")
+ return (0 until values.length()).map { index ->
+ val data = Util.b64Decode(values.getString(index))
+ val parcel = Parcel.obtain()
+ try {
+ parcel.unmarshall(data, 0, data.size)
+ parcel.setDataPosition(0)
+ decoder(parcel)
+ } finally {
+ parcel.recycle()
+ }
+ }
+ }
+
+ private fun encode(value: Parcelable): String {
+ val parcel = Parcel.obtain()
+ return try {
+ value.writeToParcel(parcel, 0)
+ Util.b64EncodeUrlSafe(parcel.marshall())
+ } finally {
+ parcel.recycle()
+ }
+ }
+
+ private fun decode(
+ encoded: String,
+ creator: Parcelable.Creator
+ ): T {
+ val data = Util.b64Decode(encoded)
+ val parcel = Parcel.obtain()
+ return try {
+ parcel.unmarshall(data, 0, data.size)
+ parcel.setDataPosition(0)
+ creator.createFromParcel(parcel)
+ } finally {
+ parcel.recycle()
+ }
+ }
+}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt b/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt
index ebe2ae752e..2b0a62ad55 100644
--- a/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt
+++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt
@@ -7,6 +7,8 @@ import io.nekohasekai.sagernet.database.DataStore
import io.nekohasekai.sagernet.database.ProxyEntity
import io.nekohasekai.sagernet.database.ProxyEntity.Companion.TYPE_CONFIG
import io.nekohasekai.sagernet.database.ProxyGroup
+import io.nekohasekai.sagernet.database.RouterGroup
+import io.nekohasekai.sagernet.database.RuleEntity
import io.nekohasekai.sagernet.database.SagerDatabase
import io.nekohasekai.sagernet.fmt.ConfigBuildResult.IndexEntity
import io.nekohasekai.sagernet.fmt.hysteria.HysteriaBean
@@ -29,9 +31,15 @@ import io.nekohasekai.sagernet.fmt.shadowsocksr.buildSingBoxOutboundShadowsocksR
import io.nekohasekai.sagernet.fmt.snell.SnellBean
import io.nekohasekai.sagernet.fmt.snell.buildSingBoxOutboundSnellBean
import io.nekohasekai.sagernet.fmt.wireguard.WireGuardBean
-import io.nekohasekai.sagernet.fmt.wireguard.buildSingBoxOutboundWireguardBean
+import io.nekohasekai.sagernet.fmt.wireguard.buildSingBoxWireGuardEndpointBean
+import io.nekohasekai.sagernet.ktx.Logs
import io.nekohasekai.sagernet.ktx.isIpAddress
import io.nekohasekai.sagernet.ktx.mkPort
+import io.nekohasekai.sagernet.route.RouterRuntime
+import io.nekohasekai.sagernet.route.RouterFilterConfig
+import io.nekohasekai.sagernet.route.RouterRuntimeGroup
+import io.nekohasekai.sagernet.route.RouterRuntimeException
+import io.nekohasekai.sagernet.route.RouterRuntimeMode
import io.nekohasekai.sagernet.utils.PackageCache
import moe.matsuri.nb4a.*
import moe.matsuri.nb4a.SingBoxOptions.*
@@ -57,6 +65,98 @@ const val TAG_DNS_HOSTS = "dns-hosts"
const val LOCALHOST = "127.0.0.1"
+private val routerSystemReservedTags = setOf(
+ TAG_DIRECT,
+ TAG_BYPASS,
+ TAG_BLOCK,
+ TAG_PROXY,
+ TAG_FRAGMENT,
+ TAG_MIXED,
+ TAG_DNS_HOSTS
+)
+
+internal fun resolveRouteOutbound(
+ rule: RuleEntity,
+ mainProxyTag: String,
+ proxyTags: Map,
+ routerTagsById: Map,
+ primaryProxyId: Long = Long.MIN_VALUE
+): String {
+ if (rule.routerGroupId > 0L) {
+ return routerTagsById[rule.routerGroupId]
+ ?: throw RouterRuntimeException(
+ rule.routerGroupId,
+ "",
+ RouterRuntimeException.Reason.MISSING,
+ )
+ }
+ return when (val outId = rule.outbound) {
+ 0L -> mainProxyTag
+ -1L -> TAG_BYPASS
+ -2L -> TAG_BLOCK
+ else -> if (outId == primaryProxyId) mainProxyTag else proxyTags[outId] ?: ""
+ }
+}
+
+internal fun validateRouterReferences(
+ rules: Iterable,
+ groups: Iterable,
+ builtRouterIds: Set,
+) {
+ val groupsById = groups.associateBy { it.id }
+ rules.asSequence().map { it.routerGroupId }.filter { it > 0 }.distinct().forEach { id ->
+ val group = groupsById[id]
+ ?: throw RouterRuntimeException(id, "", RouterRuntimeException.Reason.MISSING)
+ if (!group.enabled) {
+ throw RouterRuntimeException(id, group.name, RouterRuntimeException.Reason.DISABLED)
+ }
+ if (id !in builtRouterIds) {
+ throw RouterRuntimeException(id, group.name, RouterRuntimeException.Reason.EMPTY)
+ }
+ }
+}
+
+internal fun routerReservedTags(outbounds: Iterable): Set =
+ outbounds.flatMap { outbound ->
+ listOfNotNull(outbound.asMap()["tag"] as? String, (outbound as? Outbound)?.tag)
+ }.toSet() + routerSystemReservedTags
+
+internal fun buildRouterOutbounds(
+ groups: Iterable,
+ proxyTags: Map,
+ reservedTags: Set = emptySet(),
+ includeRouterGroups: Boolean = true
+): List {
+ if (!includeRouterGroups) return emptyList()
+
+ return RouterRuntime.build(groups, proxyTags, reservedTags).map { router ->
+ when (router.mode) {
+ RouterRuntimeMode.SELECTOR -> Outbound_SelectorOptions().apply {
+ type = "selector"
+ tag = router.tag
+ outbounds = router.outbounds
+ default_ = router.defaultTag
+ }
+
+ RouterRuntimeMode.URL_TEST -> Outbound_URLTestOptions().apply {
+ type = "urltest"
+ tag = router.tag
+ outbounds = router.outbounds
+ url = router.filter.testUrl
+ interval = "${router.filter.intervalSeconds}s"
+ tolerance = router.filter.toleranceMs
+ // sing-box enforces interval <= idle_timeout. The core's default idle_timeout is
+ // 1800s (30 min). When the user sets a longer interval for battery savings, we
+ // must emit a matching idle_timeout so the config can start. We use 2× the
+ // interval to give the group enough time to go idle between test rounds.
+ if (router.filter.intervalSeconds > 1800) {
+ idle_timeout = "${router.filter.intervalSeconds * 2}s"
+ }
+ }
+ }
+ }
+}
+
class ConfigBuildResult(
var config: String,
var externalIndex: List,
@@ -64,6 +164,13 @@ class ConfigBuildResult(
var trafficMap: Map>,
var profileTagMap: Map,
val selectorGroupId: Long,
+ val routerSelectorTags: Map = emptyMap(),
+ val routerMemberIds: Map> = emptyMap(),
+ val routerUrlTestTags: Map = emptyMap(),
+ val mainUrlTestTag: String? = null,
+ /** Union of all proxy IDs belonging to any Router group (selector or urltest). Used by
+ * TrafficLooper to avoid suppressing independent Router node traffic statistics. */
+ val routerAllMemberIds: Set = emptySet(),
) {
data class IndexEntity(var chain: LinkedHashMap)
}
@@ -125,7 +232,11 @@ fun buildConfig(
val readableNames = mutableSetOf(TAG_DIRECT, TAG_BYPASS, TAG_BLOCK, TAG_FRAGMENT, TAG_MIXED, TAG_PROXY)
val group = SagerDatabase.groupDao.getById(proxy.groupId)
- fun ProxyEntity.resolveChainInternal(): MutableList {
+ fun ProxyEntity.resolveChainInternal(visited: MutableSet = HashSet()): MutableList {
+ if (!visited.add(id)) {
+ Logs.w("Detected cyclic proxy chain involving proxy $id")
+ return mutableListOf()
+ }
val bean = requireBean()
if (bean is ChainBean) {
val beans = SagerDatabase.proxyDao.getEntities(bean.proxies)
@@ -133,7 +244,7 @@ fun buildConfig(
val beanList = ArrayList()
for (proxyId in bean.proxies) {
val item = beansMap[proxyId] ?: continue
- beanList.addAll(item.resolveChainInternal())
+ beanList.addAll(item.resolveChainInternal(visited))
}
return beanList.asReversed()
}
@@ -165,10 +276,28 @@ fun buildConfig(
}
val extraRules = if (forTest) listOf() else SagerDatabase.rulesDao.enabledRules()
+ val includeRouterGroups = !forTest && !forExport
+ val allRouterGroups = if (!includeRouterGroups) {
+ listOf()
+ } else {
+ SagerDatabase.routerGroupDao.all()
+ }
+ val routerGroups = allRouterGroups.filter { it.enabled && it.stableTag.isNotBlank() }
+ val routerMembers = if (!includeRouterGroups) {
+ mapOf()
+ } else {
+ routerGroups.associate { router ->
+ router.id to SagerDatabase.routerMemberDao.getByRouter(router.id)
+ }
+ }
+ val extraProxyIds = extraRules.mapNotNull { rule ->
+ rule.outbound.takeIf { it > 0 && it != proxy.id }
+ }.toMutableSet().apply {
+ addAll(routerMembers.values.flatten().map { it.proxyId }.filter { it != proxy.id })
+ }
val extraProxies =
- if (forTest) mapOf() else SagerDatabase.proxyDao.getEntities(extraRules.mapNotNull { rule ->
- rule.outbound.takeIf { it > 0 && it != proxy.id }
- }.toHashSet().toList()).associateBy { it.id }
+ if (forTest) mapOf() else SagerDatabase.proxyDao.getEntities(extraProxyIds.toList())
+ .associateBy { it.id }
val buildSelector = !forTest && group?.isSelector == true && !forExport
val userDNSRuleList = mutableListOf()
val domainListDNSDirectForce = mutableListOf()
@@ -179,7 +308,7 @@ fun buildConfig(
val nonCustomFinalHosts = hashSetOf()
val groupCache = HashMap()
val isVPN = DataStore.serviceMode == Key.MODE_VPN
- val deviceInboundTag = if (isVPN && DataStore.enableHevTun) TAG_MIXED else "tun-in"
+ val deviceInboundTag = if (isVPN) "tun-in" else TAG_MIXED
val bind = if (!forTest && DataStore.allowAccess) "0.0.0.0" else LOCALHOST
val remoteDns = DataStore.remoteDns.split("\n")
.mapNotNull { dns -> dns.trim().takeIf { it.isNotBlank() && !it.startsWith("#") } }
@@ -203,6 +332,10 @@ fun buildConfig(
}
}
+ var routerSelectorTags: Map = emptyMap()
+ var routerMemberIds: Map> = emptyMap()
+ var routerUrlTestTags: Map = emptyMap()
+
return MyOptions().apply {
if (!forTest) {
experimental = ExperimentalOptions().apply {
@@ -256,35 +389,29 @@ fun buildConfig(
inbounds = mutableListOf()
if (!forTest) {
- if (isVPN && !DataStore.enableHevTun) inbounds.add(Inbound_TunOptions().apply {
+ if (isVPN) inbounds.add(Inbound_TunOptions().apply {
type = "tun"
tag = "tun-in"
interface_name = "tun0"
stack = when (DataStore.tunImplementation) {
TunImplementation.GVISOR -> "gvisor"
TunImplementation.SYSTEM -> "system"
- else -> "mixed"
+ TunImplementation.MIXED -> "mixed"
+ else -> "go"
}
- endpoint_independent_nat = true
mtu = DataStore.mtu
domain_strategy = genDomainStrategy(DataStore.resolveDestination)
auto_route = true
strict_route = DataStore.strictRoute
sniff = needSniff
sniff_override_destination = needSniffOverride
- when (ipv6Mode) {
- IPv6Mode.DISABLE -> {
- inet4_address = listOf(VpnService.PRIVATE_VLAN4_CLIENT + "/28")
- }
-
- IPv6Mode.ONLY -> {
- inet6_address = listOf(VpnService.PRIVATE_VLAN6_CLIENT + "/126")
- }
-
- else -> {
- inet4_address = listOf(VpnService.PRIVATE_VLAN4_CLIENT + "/28")
- inet6_address = listOf(VpnService.PRIVATE_VLAN6_CLIENT + "/126")
- }
+ address = when (ipv6Mode) {
+ IPv6Mode.DISABLE -> listOf(VpnService.PRIVATE_VLAN4_CLIENT + "/28")
+ IPv6Mode.ONLY -> listOf(VpnService.PRIVATE_VLAN6_CLIENT + "/126")
+ else -> listOf(
+ VpnService.PRIVATE_VLAN4_CLIENT + "/28",
+ VpnService.PRIVATE_VLAN6_CLIENT + "/126"
+ )
}
})
inbounds.add(Inbound_MixedOptions().apply {
@@ -305,6 +432,7 @@ fun buildConfig(
}
outbounds = mutableListOf()
+ endpoints = mutableListOf()
// init routing object
route = RouteOptions().apply {
@@ -466,7 +594,7 @@ fun buildConfig(
buildSingBoxOutboundShadowsocksRBean(bean)
is WireGuardBean ->
- buildSingBoxOutboundWireguardBean(bean)
+ buildSingBoxWireGuardEndpointBean(bean)
is SSHBean ->
buildSingBoxOutboundSSHBean(bean)
@@ -577,7 +705,12 @@ fun buildConfig(
}
}
- outbounds.add(currentOutbound)
+ if (currentOutbound is SingBoxOptions.Endpoint) {
+ // WireGuard & friends live in `endpoints` since sing-box 1.15
+ endpoints.add(currentOutbound)
+ } else {
+ outbounds.add(currentOutbound)
+ }
chainOutbounds.add(currentOutbound)
pastOutbound = currentOutbound
pastEntity = proxyEntity
@@ -607,8 +740,51 @@ fun buildConfig(
extraProxies.forEach { (key, p) ->
tagMap[key] = buildChain(key, p)
}
+ val runtimeRouterGroups = routerGroups.map { router ->
+ RouterRuntimeGroup(
+ stableTag = router.stableTag,
+ mode = if (router.mode == RouterGroup.MODE_URL_TEST) {
+ RouterRuntimeMode.URL_TEST
+ } else {
+ RouterRuntimeMode.SELECTOR
+ },
+ memberProxyIds = routerMembers[router.id].orEmpty().map { it.proxyId },
+ selectedProxyId = router.selectedProxyId,
+ id = router.id,
+ name = router.name,
+ filter = RouterFilterConfig.fromJson(router.matchConfig),
+ )
+ }
+ val routerOutbounds = buildRouterOutbounds(
+ runtimeRouterGroups,
+ tagMap,
+ reservedTags = routerReservedTags(outbounds),
+ includeRouterGroups = includeRouterGroups
+ )
+ outbounds.addAll(routerOutbounds)
+ val builtRouterTags = routerOutbounds.mapNotNull { outbound ->
+ (outbound.asMap()["tag"] as? String) ?: outbound.tag
+ }.toSet()
+ val routerTagsById = routerGroups.mapNotNull { router ->
+ router.stableTag.takeIf(builtRouterTags::contains)?.let { router.id to it }
+ }.toMap()
+ validateRouterReferences(extraRules, allRouterGroups, routerTagsById.keys)
+ routerSelectorTags = routerOutbounds
+ .filterIsInstance()
+ .mapNotNull { outbound ->
+ outbound.tag?.takeIf { it.isNotBlank() }?.let { it to it }
+ }
+ .toMap()
+ routerMemberIds = routerGroups.associate { router ->
+ router.stableTag to routerMembers[router.id].orEmpty().map { it.proxyId }.toSet()
+ }.filterKeys(routerSelectorTags::containsKey)
+
+ routerUrlTestTags = runtimeRouterGroups.filter {
+ it.mode == RouterRuntimeMode.URL_TEST && it.stableTag in builtRouterTags
+ }.associate { it.id to it.stableTag }
- val mainProxyTag = (if (buildSelector) TAG_PROXY else tagMap[proxy.id]) ?: TAG_PROXY
+ // Router membership must not change the legacy main selection or outbound=0.
+ val mainProxyTag = if (buildSelector) TAG_PROXY else tagMap[proxy.id] ?: TAG_PROXY
// 在应用用户规则之前检查全局模式
if (!forTest && DataStore.globalMode) {
@@ -829,12 +1005,7 @@ fun buildConfig(
}
}
- outbound = when (val outId = rule.outbound) {
- 0L -> mainProxyTag
- -1L -> TAG_BYPASS
- -2L -> TAG_BLOCK
- else -> if (outId == proxy.id) mainProxyTag else tagMap[outId] ?: ""
- }
+ outbound = resolveRouteOutbound(rule, mainProxyTag, tagMap, routerTagsById, proxy.id)
_hack_custom_config = rule.config
}
@@ -916,35 +1087,27 @@ fun buildConfig(
}
}
- dns.servers.add(DNSServerOptions().apply {
- address = "rcode://success"
- tag = "dns-block"
- })
-
- dns.servers.add(DNSServerOptions().apply {
- address = "local"
- tag = "dns-local"
- detour = TAG_DIRECT
- })
+ dns.servers.add(buildDnsServerOptions("dns-block", "rcode://success"))
+ dns.servers.add(buildDnsServerOptions("dns-local", "local", detour = TAG_DIRECT))
directDNS.firstOrNull().let {
- dns.servers.add(DNSServerOptions().apply {
- address = it ?: throw Exception("No direct DNS, check your settings!")
- tag = "dns-direct"
- detour = TAG_DIRECT
- address_resolver = "dns-local"
- strategy = autoDnsDomainStrategy(SingBoxOptionsUtil.domainStrategy(tag))
- })
+ dns.servers.add(buildDnsServerOptions(
+ tag = "dns-direct",
+ address = it ?: throw Exception("No direct DNS, check your settings!"),
+ detour = TAG_DIRECT,
+ addressResolver = "dns-local",
+ strategy = autoDnsDomainStrategy(SingBoxOptionsUtil.domainStrategy("dns-direct"))
+ ))
}
remoteDns.firstOrNull().let {
// Always use direct DNS for urlTest
- if (!forTest) dns.servers.add(DNSServerOptions().apply {
- address = it ?: throw Exception("No remote DNS, check your settings!")
- tag = "dns-remote"
- address_resolver = "dns-direct"
- strategy = autoDnsDomainStrategy(SingBoxOptionsUtil.domainStrategy(tag))
- })
+ if (!forTest) dns.servers.add(buildDnsServerOptions(
+ tag = "dns-remote",
+ address = it ?: throw Exception("No remote DNS, check your settings!"),
+ addressResolver = "dns-direct",
+ strategy = autoDnsDomainStrategy(SingBoxOptionsUtil.domainStrategy("dns-remote"))
+ ))
}
if (dnsHosts.isNotEmpty()) {
dns.servers.add(DNSServerOptions().apply {
@@ -989,15 +1152,12 @@ fun buildConfig(
})
// FakeDNS obj
if (useFakeDns) {
- dns.fakeip = DNSFakeIPOptions().apply {
- enabled = true
- inet4_range = "198.18.0.0/15"
- inet6_range = "fc00::/18"
- }
dns.servers.add(DNSServerOptions().apply {
- address = "fakeip"
tag = "dns-fake"
strategy = "ipv4_only"
+ _hack_config_map["type"] = "fakeip"
+ _hack_config_map["inet4_range"] = "198.18.0.0/15"
+ _hack_config_map["inet6_range"] = "fc00::/18"
})
dns.rules.add(DNSRule_DefaultOptions().apply {
inbound = listOf(deviceInboundTag)
@@ -1031,15 +1191,13 @@ fun buildConfig(
if (hosts.isNullOrEmpty()) return@forEach
val serverTag = "dns-sub-$gid"
- dns.servers.add(DNSServerOptions().apply {
- address = resolver
- tag = serverTag
- detour = TAG_DIRECT
- if (!resolver.isIpAddress()) {
- address_resolver = "dns-direct"
- }
+ dns.servers.add(buildDnsServerOptions(
+ tag = serverTag,
+ address = resolver,
+ detour = TAG_DIRECT,
+ addressResolver = if (!resolver.isIpAddress()) "dns-direct" else null,
strategy = autoDnsDomainStrategy(SingBoxOptionsUtil.domainStrategy("server"))
- })
+ ))
dns.rules.add(0, DNSRule_DefaultOptions().apply {
makeSingBoxRule(hosts)
server = serverTag
@@ -1051,14 +1209,125 @@ fun buildConfig(
}.let {
val configMap = it.asMap()
Util.mergeJSON(configMap, proxy.requireBean().customConfigJson)
+ val allRouterMemberIds = routerGroups.flatMapTo(mutableSetOf()) { router ->
+ routerMembers[router.id].orEmpty().map { member -> member.proxyId }
+ }
ConfigBuildResult(
gson.toJson(configMap),
externalIndexMap,
proxy.id,
trafficMap,
tagMap,
- if (buildSelector) group.id else -1L
+ if (buildSelector) group.id else -1L,
+ routerSelectorTags,
+ routerMemberIds,
+ routerUrlTestTags,
+ null,
+ allRouterMemberIds,
)
}
}
+
+// buildDnsServerOptions converts the legacy sing-box DNS server address string
+// (used by NekoBox settings) into the sing-box 1.15+ typed DNS server format.
+// The legacy "address" field was removed in sing-box 1.14, so we must emit
+// "type" + type-specific fields instead.
+internal fun buildDnsServerOptions(
+ tag: String,
+ address: String,
+ detour: String? = null,
+ addressResolver: String? = null,
+ strategy: String? = null,
+): DNSServerOptions {
+ val options = DNSServerOptions().apply {
+ this.tag = tag
+ if (detour != null) this.detour = detour
+ if (addressResolver != null) this.address_resolver = addressResolver
+ if (strategy != null) this.strategy = strategy
+ }
+ when {
+ address == "local" -> {
+ options._hack_config_map["type"] = "local"
+ }
+ address == "fakeip" -> {
+ options._hack_config_map["type"] = "fakeip"
+ }
+ address.startsWith("rcode://") -> {
+ options._hack_config_map["type"] = "rcode"
+ options._hack_config_map["rcode"] = address.substringAfter("rcode://")
+ }
+ else -> {
+ val scheme = if (address.contains("://")) {
+ address.substringBefore("://").lowercase()
+ } else {
+ "udp"
+ }
+ val body = if (address.contains("://")) address.substringAfter("://") else address
+ when (scheme) {
+ "https", "h3" -> {
+ val url = "https://$body".toHttpUrlOrNull()
+ ?: error("invalid DNS HTTPS URL: $address")
+ options._hack_config_map["type"] = if (scheme == "h3") "h3" else "https"
+ options._hack_config_map["server"] = url.host
+ options._hack_config_map["server_port"] =
+ if (url.port != -1) url.port else 443
+ if (url.encodedPath.isNotBlank() && url.encodedPath != "/") {
+ options._hack_config_map["path"] = url.encodedPath
+ }
+ options._hack_config_map["tls"] = mapOf(
+ "enabled" to true,
+ "server_name" to if (url.host.isIpAddress()) "" else url.host
+ )
+ }
+ "tls", "tcp", "udp", "quic" -> {
+ val defaultPort = when (scheme) {
+ "tls", "quic" -> 853
+ else -> 53
+ }
+ val (host, port) = parseDnsHostPort(body, defaultPort)
+ options._hack_config_map["type"] = scheme
+ options._hack_config_map["server"] = host
+ options._hack_config_map["server_port"] = port
+ if (scheme == "tls" || scheme == "quic") {
+ options._hack_config_map["tls"] = mapOf(
+ "enabled" to true,
+ "server_name" to if (host.isIpAddress()) "" else host
+ )
+ }
+ }
+ else -> {
+ // Unknown scheme: treat as plain UDP to avoid silently
+ // emitting an unsupported legacy address.
+ val (host, port) = parseDnsHostPort(address, 53)
+ options._hack_config_map["type"] = "udp"
+ options._hack_config_map["server"] = host
+ options._hack_config_map["server_port"] = port
+ }
+ }
+ }
+ }
+ return options
+}
+
+private fun parseDnsHostPort(input: String, defaultPort: Int): Pair {
+ val s = input.trim()
+ if (s.startsWith("[")) {
+ val close = s.indexOf("]")
+ if (close == -1) return s to defaultPort
+ val host = s.substring(1, close)
+ val port = if (s.length > close + 2 && s[close + 1] == ':') {
+ s.substring(close + 2).toIntOrNull() ?: defaultPort
+ } else {
+ defaultPort
+ }
+ return host to port
+ }
+ val lastColon = s.lastIndexOf(":")
+ val firstColon = s.indexOf(":")
+ if (lastColon != -1 && firstColon == lastColon) {
+ val port = s.substring(lastColon + 1).toIntOrNull()
+ if (port != null) return s.substring(0, lastColon) to port
+ }
+ return s to defaultPort
+}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/KryoConverters.java b/app/src/main/java/io/nekohasekai/sagernet/fmt/KryoConverters.java
index 624db7eddf..69e6a7c6f7 100644
--- a/app/src/main/java/io/nekohasekai/sagernet/fmt/KryoConverters.java
+++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/KryoConverters.java
@@ -49,13 +49,46 @@ public static byte[] serialize(Serializable bean) {
return out.toByteArray();
}
+ @FunctionalInterface
+ public interface StrictAction {
+ T run();
+ }
+
+ private static final ThreadLocal STRICT_DESERIALIZATION = ThreadLocal.withInitial(() -> Boolean.FALSE);
+
+ public static boolean isStrictDeserialization() {
+ return STRICT_DESERIALIZATION.get();
+ }
+
+ public static T withStrictDeserialization(StrictAction action) {
+ boolean prev = STRICT_DESERIALIZATION.get();
+ STRICT_DESERIALIZATION.set(true);
+ try {
+ return action.run();
+ } finally {
+ if (prev) {
+ STRICT_DESERIALIZATION.set(true);
+ } else {
+ STRICT_DESERIALIZATION.remove();
+ }
+ }
+ }
+
public static T deserialize(T bean, byte[] bytes) {
- if (bytes == null) return bean;
+ if (bytes == null || bytes.length == 0) {
+ if (STRICT_DESERIALIZATION.get()) {
+ throw new KryoException("Cannot deserialize from null or empty byte array in strict mode");
+ }
+ return bean;
+ }
ByteArrayInputStream input = new ByteArrayInputStream(bytes);
ByteBufferInput buffer = KryosKt.byteBuffer(input);
try {
bean.deserializeFromBuffer(buffer);
} catch (KryoException e) {
+ if (STRICT_DESERIALIZATION.get()) {
+ throw e;
+ }
Logs.INSTANCE.w(e);
}
bean.initializeDefaultValues();
diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/RouteOutboundChoice.kt b/app/src/main/java/io/nekohasekai/sagernet/fmt/RouteOutboundChoice.kt
new file mode 100644
index 0000000000..fc83902ccd
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/RouteOutboundChoice.kt
@@ -0,0 +1,19 @@
+package io.nekohasekai.sagernet.fmt
+
+data class RouteOutboundChoice(
+ val outbound: Long,
+ val routerGroupId: Long,
+)
+
+internal fun serializeRouteOutboundChoice(
+ value: Int,
+ legacyProfileId: Long,
+ routerGroupId: Long,
+ routerChoiceValue: Int,
+): RouteOutboundChoice = when (value) {
+ 0 -> RouteOutboundChoice(0L, 0L)
+ 1 -> RouteOutboundChoice(-1L, 0L)
+ 2 -> RouteOutboundChoice(-2L, 0L)
+ routerChoiceValue -> RouteOutboundChoice(0L, routerGroupId)
+ else -> RouteOutboundChoice(legacyProfileId, 0L)
+}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/juicity/JuicityFmt.kt b/app/src/main/java/io/nekohasekai/sagernet/fmt/juicity/JuicityFmt.kt
index bebb6a43f7..d3fd3b0909 100644
--- a/app/src/main/java/io/nekohasekai/sagernet/fmt/juicity/JuicityFmt.kt
+++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/juicity/JuicityFmt.kt
@@ -1,5 +1,6 @@
package io.nekohasekai.sagernet.fmt.juicity
+import android.util.Base64
import io.nekohasekai.sagernet.database.DataStore
import io.nekohasekai.sagernet.ktx.linkBuilder
import io.nekohasekai.sagernet.ktx.toLink
@@ -8,7 +9,6 @@ import moe.matsuri.nb4a.SingBoxOptions
import moe.matsuri.nb4a.SingBoxOptions.Outbound_JuicityOptions
import moe.matsuri.nb4a.utils.listByLineOrComma
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
-import java.util.Base64
fun parseJuicity(url: String): JuicityBean {
val link = url.replace("juicity://", "https://").toHttpUrlOrNull() ?: error(
@@ -84,8 +84,10 @@ fun buildSingBoxOutboundJuicityBean(bean: JuicityBean): Outbound_JuicityOptions
private fun normalizePinnedCertChainHash(rawHash: String?): String? {
val certChainHash = rawHash?.replace(":", "")?.takeIf { it.isNotEmpty() } ?: return null
return when {
- certChainHash.length == 64 -> Base64.getUrlEncoder()
- .encodeToString(certChainHash.chunked(2).map { chunk -> chunk.toInt(16).toByte() }.toByteArray())
+ certChainHash.length == 64 -> Base64.encodeToString(
+ certChainHash.chunked(2).map { chunk -> chunk.toInt(16).toByte() }.toByteArray(),
+ Base64.URL_SAFE or Base64.NO_WRAP
+ )
else -> certChainHash.replace('/', '_').replace('+', '-')
}
}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/snell/SnellBuildConfig.kt b/app/src/main/java/io/nekohasekai/sagernet/fmt/snell/SnellBuildConfig.kt
index 91069e1aaa..8ac694917d 100644
--- a/app/src/main/java/io/nekohasekai/sagernet/fmt/snell/SnellBuildConfig.kt
+++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/snell/SnellBuildConfig.kt
@@ -21,9 +21,9 @@ fun buildSingBoxOutboundSnellBean(bean: SnellBean): SingBoxOptions.Outbound_Snel
if (!bean.mode.isNullOrBlank() && bean.mode != "default") {
mode = bean.mode
}
- if (bean.quicProxyMode == true) {
- quic_proxy_mode = true
- }
+ // quic_proxy_mode was a reF1nd fork extension; sing-box 1.15's
+ // strict option parsing rejects the unknown field, so it must not
+ // be emitted anymore.
} else if (bean.obfsMode != null && bean.obfsMode.isNotBlank()) {
obfs_mode = if (bean.version != null && bean.version >= 4 && bean.obfsMode == "tls") "" else bean.obfsMode
if (obfs_mode.isNotBlank() && bean.obfsHost != null && bean.obfsHost.isNotBlank()) {
diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/v2ray/StandardV2RayBean.java b/app/src/main/java/io/nekohasekai/sagernet/fmt/v2ray/StandardV2RayBean.java
index 875e163668..17a883782e 100644
--- a/app/src/main/java/io/nekohasekai/sagernet/fmt/v2ray/StandardV2RayBean.java
+++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/v2ray/StandardV2RayBean.java
@@ -2,6 +2,7 @@
import com.esotericsoftware.kryo.io.ByteBufferInput;
import com.esotericsoftware.kryo.io.ByteBufferOutput;
+import java.util.Locale;
import io.nekohasekai.sagernet.fmt.AbstractBean;
import io.nekohasekai.sagernet.fmt.trojan.TrojanBean;
@@ -98,7 +99,7 @@ public void initializeDefaultValues() {
if (JavaUtil.isNullOrBlank(type)) type = "tcp";
else if ("h2".equals(type)) type = "http";
- type = type.toLowerCase();
+ type = type.toLowerCase(Locale.ROOT);
if (JavaUtil.isNullOrBlank(host)) host = "";
if (JavaUtil.isNullOrBlank(path)) path = "";
diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/wireguard/WireGuardFmt.kt b/app/src/main/java/io/nekohasekai/sagernet/fmt/wireguard/WireGuardFmt.kt
index 5fa7397b05..607612e9e0 100644
--- a/app/src/main/java/io/nekohasekai/sagernet/fmt/wireguard/WireGuardFmt.kt
+++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/wireguard/WireGuardFmt.kt
@@ -26,16 +26,21 @@ fun genReserved(anyStr: String): String {
}
}
-fun buildSingBoxOutboundWireguardBean(bean: WireGuardBean): SingBoxOptions.Outbound_WireGuardOptions {
- return SingBoxOptions.Outbound_WireGuardOptions().apply {
+// sing-box 1.15 only registers WireGuard as an endpoint, and dropped the
+// deprecated server / server_port / local_address / peer_public_key options.
+fun buildSingBoxWireGuardEndpointBean(bean: WireGuardBean): SingBoxOptions.Endpoint_WireGuardOptions {
+ return SingBoxOptions.Endpoint_WireGuardOptions().apply {
type = "wireguard"
- server = bean.serverAddress
- server_port = bean.serverPort
- local_address = bean.localAddress.listByLineOrComma()
+ address = bean.localAddress.listByLineOrComma()
private_key = bean.privateKey
- peer_public_key = bean.peerPublicKey
- pre_shared_key = bean.peerPreSharedKey
mtu = bean.mtu
- if (bean.reserved.isNotBlank()) reserved = genReserved(bean.reserved)
+ peers = listOf(SingBoxOptions.Endpoint_WireGuardPeer().apply {
+ address = bean.serverAddress
+ port = bean.serverPort
+ public_key = bean.peerPublicKey
+ if (bean.peerPreSharedKey.isNotBlank()) pre_shared_key = bean.peerPreSharedKey
+ allowed_ips = listOf("0.0.0.0/0", "::/0")
+ if (bean.reserved.isNotBlank()) reserved = genReserved(bean.reserved)
+ })
}
}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/group/GroupUpdater.kt b/app/src/main/java/io/nekohasekai/sagernet/group/GroupUpdater.kt
index 426e3d74f1..af0b1906c6 100644
--- a/app/src/main/java/io/nekohasekai/sagernet/group/GroupUpdater.kt
+++ b/app/src/main/java/io/nekohasekai/sagernet/group/GroupUpdater.kt
@@ -132,7 +132,7 @@ abstract class GroupUpdater {
suspend fun executeUpdate(proxyGroup: ProxyGroup, byUser: Boolean): Boolean {
return coroutineScope {
- if (!updating.add(proxyGroup.id)) cancel()
+ if (!updating.add(proxyGroup.id)) return@coroutineScope false
GroupManager.postReload(proxyGroup.id)
val subscription = proxyGroup.subscription!!
@@ -142,19 +142,23 @@ abstract class GroupUpdater {
if (byUser && (subscription.link?.startsWith("http://") == true || subscription.updateWhenConnectedOnly) && !connected) {
if (userInterface == null || !userInterface.confirm(app.getString(R.string.update_subscription_warning))) {
finishUpdate(proxyGroup)
- cancel()
- return@coroutineScope true
+ return@coroutineScope false
}
}
try {
+ val routerSnapshot = GroupManager.snapshotRouterMembers()
RawUpdater.doUpdate(proxyGroup, subscription, userInterface, byUser)
+ GroupManager.reconcileRouterMembers(routerSnapshot)
true
} catch (e: Throwable) {
Logs.w(e)
+ GroupManager.markRouterRefreshFailed(proxyGroup.id, e.readableMessage)
userInterface?.onUpdateFailure(proxyGroup, e.readableMessage)
finishUpdate(proxyGroup)
false
+ } finally {
+ GroupManager.cleanupDanglingRouterMembers()
}
}
}
@@ -168,4 +172,4 @@ abstract class GroupUpdater {
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/group/RawUpdater.kt b/app/src/main/java/io/nekohasekai/sagernet/group/RawUpdater.kt
index 1e1644f839..20e4a091bc 100644
--- a/app/src/main/java/io/nekohasekai/sagernet/group/RawUpdater.kt
+++ b/app/src/main/java/io/nekohasekai/sagernet/group/RawUpdater.kt
@@ -26,6 +26,7 @@ import io.nekohasekai.sagernet.ktx.*
import libcore.Libcore
import moe.matsuri.nb4a.Protocols
import moe.matsuri.nb4a.proxy.anytls.AnyTLSBean
+import moe.matsuri.nb4a.proxy.anytls.parseSingBoxAnyTLS
import moe.matsuri.nb4a.proxy.config.ConfigBean
import moe.matsuri.nb4a.utils.Util
import org.ini4j.Ini
@@ -36,6 +37,7 @@ import org.yaml.snakeyaml.TypeDescription
import org.yaml.snakeyaml.Yaml
import org.yaml.snakeyaml.error.YAMLException
import java.io.StringReader
+import java.util.IdentityHashMap
import androidx.core.net.toUri
@Suppress("EXPERIMENTAL_API_USAGE")
@@ -158,6 +160,9 @@ object RawUpdater : GroupUpdater() {
error(app.getString(R.string.no_proxies_found))
}
+ val stableIds = IdentityHashMap()
+ proxies.forEach { stableIds[it] = it.routerStableIdentity() }
+
Logs.d("New profiles: ${proxies.size}")
val nameMap = proxies.associateBy { bean ->
@@ -189,9 +194,11 @@ object RawUpdater : GroupUpdater() {
if (toReplace.contains(name)) {
val entity = toReplace[name]!!
val existsBean = entity.requireBean()
+ val existingStableId = entity.uuid
// 更新订阅,保留自定义覆写设置
bean.customOutboundJson = existsBean.customOutboundJson
bean.customConfigJson = existsBean.customConfigJson
+ entity.uuid = stableIds[bean] ?: bean.routerStableIdentity()
when {
existsBean != bean -> {
changed++
@@ -202,7 +209,7 @@ object RawUpdater : GroupUpdater() {
Logs.d("Updated profile: $name")
}
- entity.userOrder != userOrder -> {
+ entity.userOrder != userOrder || existingStableId != entity.uuid -> {
entity.putBean(bean)
toUpdate.add(entity)
entity.userOrder = userOrder
@@ -221,6 +228,7 @@ object RawUpdater : GroupUpdater() {
groupId = proxyGroup.id, userOrder = userOrder
).apply {
putBean(bean)
+ uuid = stableIds[bean] ?: bean.routerStableIdentity()
})
added.add(name)
Logs.d("Inserted profile: $name")
@@ -232,6 +240,9 @@ object RawUpdater : GroupUpdater() {
Logs.d("Updated profiles: $it")
}
+ toDelete.forEach { proxy ->
+ SagerDatabase.routerMemberDao.deleteByProxy(proxy.id)
+ }
SagerDatabase.proxyDao.deleteProxy(toDelete).also {
Logs.d("Deleted profiles: $it")
}
@@ -1016,7 +1027,7 @@ object RawUpdater : GroupUpdater() {
it
}
}.map {
- ConfigBean().apply {
+ parseSingBoxAnyTLS(it) ?: ConfigBean().apply {
applyDefaultValues()
type = 1
config = it.toStringPretty()
diff --git a/app/src/main/java/io/nekohasekai/sagernet/ktx/Utils.kt b/app/src/main/java/io/nekohasekai/sagernet/ktx/Utils.kt
index 3e176c8ed6..773a05a65b 100644
--- a/app/src/main/java/io/nekohasekai/sagernet/ktx/Utils.kt
+++ b/app/src/main/java/io/nekohasekai/sagernet/ktx/Utils.kt
@@ -11,7 +11,6 @@ import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.res.Resources
-import android.os.Build
import android.system.Os
import android.system.OsConstants
import android.util.TypedValue
@@ -90,7 +89,7 @@ val FileDescriptor.int get() = getInt.invoke(this) as Int
suspend fun HttpURLConnection.useCancellable(block: suspend HttpURLConnection.() -> T): T {
return suspendCancellableCoroutine { cont ->
cont.invokeOnCancellation {
- if (Build.VERSION.SDK_INT >= 26) disconnect() else GlobalScope.launch(Dispatchers.IO) { disconnect() }
+ disconnect()
}
GlobalScope.launch(Dispatchers.IO) {
try {
@@ -138,23 +137,11 @@ fun Resources.Theme.resolveResourceId(@AttrRes resId: Int): Int {
fun Preference.remove() = parent!!.removePreference(this)
/**
- * A slightly more performant variant of parseNumericAddress.
- *
- * Bug in Android 9.0 and lower: https://issuetracker.google.com/issues/123456213
+ * A slightly more performant variant of InetAddress.parseNumericAddress.
*/
-private val parseNumericAddress by lazy {
- InetAddress::class.java.getDeclaredMethod("parseNumericAddress", String::class.java).apply {
- isAccessible = true
- }
-}
-
fun String?.parseNumericAddress(): InetAddress? =
- Os.inet_pton(OsConstants.AF_INET, this) ?: Os.inet_pton(OsConstants.AF_INET6, this)?.let {
- if (Build.VERSION.SDK_INT >= 29) it else parseNumericAddress.invoke(
- null, this
- ) as InetAddress
- }
+ Os.inet_pton(OsConstants.AF_INET, this) ?: Os.inet_pton(OsConstants.AF_INET6, this)
@JvmOverloads
fun DialogFragment.showAllowingStateLoss(fragmentManager: FragmentManager, tag: String? = null) {
diff --git a/app/src/main/java/io/nekohasekai/sagernet/route/RouterFilter.kt b/app/src/main/java/io/nekohasekai/sagernet/route/RouterFilter.kt
new file mode 100644
index 0000000000..9e8810e84a
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sagernet/route/RouterFilter.kt
@@ -0,0 +1,59 @@
+package io.nekohasekai.sagernet.route
+
+import com.google.gson.Gson
+
+data class RouterFilterConfig(
+ val includeRegex: String = "",
+ val excludeRegex: String = "",
+ val testUrl: String = DEFAULT_TEST_URL,
+ val intervalSeconds: Long = DEFAULT_INTERVAL_SECONDS,
+ val toleranceMs: Int = DEFAULT_TOLERANCE_MS,
+) {
+ fun validate(): RouterFilterValidation = RouterFilterValidation(
+ include = includeRegex.compileIfPresent(RouterFilterException.Field.INCLUDE),
+ exclude = excludeRegex.compileIfPresent(RouterFilterException.Field.EXCLUDE),
+ )
+
+ fun toJson(): String = Gson().toJson(this)
+
+ companion object {
+ const val DEFAULT_TEST_URL = "https://www.gstatic.com/generate_204"
+ const val DEFAULT_INTERVAL_SECONDS = 300L
+ const val DEFAULT_TOLERANCE_MS = 50
+
+ fun fromJson(value: String): RouterFilterConfig {
+ if (value.isBlank()) return RouterFilterConfig()
+ return Gson().fromJson(value, RouterFilterConfig::class.java).let { parsed ->
+ parsed.copy(
+ testUrl = parsed.testUrl.takeIf(String::isNotBlank) ?: DEFAULT_TEST_URL,
+ intervalSeconds = parsed.intervalSeconds.takeIf { it > 0 } ?: DEFAULT_INTERVAL_SECONDS,
+ toleranceMs = parsed.toleranceMs.takeIf { it >= 0 } ?: DEFAULT_TOLERANCE_MS,
+ )
+ }
+ }
+ }
+}
+
+data class RouterFilterValidation(
+ val include: Regex?,
+ val exclude: Regex?,
+)
+
+class RouterFilterException(
+ val field: Field,
+ cause: Throwable,
+) : IllegalArgumentException(cause.message, cause) {
+ enum class Field {
+ INCLUDE,
+ EXCLUDE,
+ }
+}
+
+private fun String.compileIfPresent(field: RouterFilterException.Field): Regex? {
+ if (isBlank()) return null
+ return try {
+ toRegex(RegexOption.IGNORE_CASE)
+ } catch (error: IllegalArgumentException) {
+ throw RouterFilterException(field, error)
+ }
+}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/route/RouterMatcher.kt b/app/src/main/java/io/nekohasekai/sagernet/route/RouterMatcher.kt
new file mode 100644
index 0000000000..e9707d5976
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sagernet/route/RouterMatcher.kt
@@ -0,0 +1,32 @@
+package io.nekohasekai.sagernet.route
+
+data class RouterNodeSnapshot(
+ val id: Long,
+ val stableId: String? = null,
+ val name: String,
+ val subscriptionId: Long? = null,
+ val enabled: Boolean = true,
+ val available: Boolean = true,
+)
+
+data class RouterMatchRequest(
+ val routerId: Long,
+ val sourceGroupIds: List,
+ val filter: RouterFilterValidation,
+)
+
+object RouterMatcher {
+ fun match(
+ nodes: Iterable,
+ requests: Iterable,
+ ): Map> = requests.associate { request ->
+ val nodesBySource = nodes.filter { it.enabled && it.available }.groupBy { it.subscriptionId }
+ request.routerId to request.sourceGroupIds.asSequence().distinct()
+ .flatMap { sourceId -> nodesBySource[sourceId].orEmpty().asSequence() }
+ .filter { node -> request.filter.include?.containsMatchIn(node.name) != false }
+ .filterNot { node -> request.filter.exclude?.containsMatchIn(node.name) == true }
+ .distinctBy { node -> node.id }
+ .map { node -> node.id }
+ .toList()
+ }
+}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/route/RouterMembership.kt b/app/src/main/java/io/nekohasekai/sagernet/route/RouterMembership.kt
new file mode 100644
index 0000000000..107d18db0e
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sagernet/route/RouterMembership.kt
@@ -0,0 +1,22 @@
+package io.nekohasekai.sagernet.route
+
+data class RouterMembershipPlan(
+ val memberProxyIds: List,
+ val selectedProxyId: Long?,
+)
+
+object RouterMembership {
+
+ fun plan(
+ availableProxyIds: Iterable,
+ requestedProxyIds: Iterable,
+ currentSelectedProxyId: Long?,
+ ): RouterMembershipPlan {
+ val requested = requestedProxyIds.toSet()
+ val members = availableProxyIds.distinct()
+ .filter { it in requested }
+ val selected = currentSelectedProxyId?.takeIf(members::contains)
+ ?: members.firstOrNull()
+ return RouterMembershipPlan(members, selected)
+ }
+}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/route/RouterReconciler.kt b/app/src/main/java/io/nekohasekai/sagernet/route/RouterReconciler.kt
new file mode 100644
index 0000000000..0221b5ee23
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sagernet/route/RouterReconciler.kt
@@ -0,0 +1,134 @@
+package io.nekohasekai.sagernet.route
+
+data class RouterMemberSnapshot(
+ val proxyId: Long,
+ val stableId: String,
+ val sourceGroupId: Long? = null,
+ val userOrder: Long = 0L,
+)
+
+data class RouterReconcileGroup(
+ val routerId: Long,
+ val stableTag: String,
+ val sourceGroupIds: List,
+ val filter: RouterFilterValidation,
+ val selectedProxyId: Long? = null,
+)
+
+data class RouterReconciliationResult(
+ val membersByRouterId: Map>,
+ val selectedProxyIdsByRouterId: Map,
+ val preservedPreviousMembers: Boolean,
+ val error: String? = null,
+)
+
+internal fun routerStableIdOrFallback(stableId: String?, proxyId: Long): String =
+ stableId?.takeIf(String::isNotBlank) ?: "proxy:$proxyId"
+
+internal fun routerNodeKey(sourceGroupId: Long?, stableId: String): String =
+ "${sourceGroupId ?: 0}:$stableId"
+
+internal fun danglingRouterMemberProxyIds(
+ members: Iterable,
+ currentProxyIds: Set,
+): Set = members.map { it.proxyId }.filterNot(currentProxyIds::contains).toSet()
+
+private data class StableNodeKey(val sourceGroupId: Long?, val stableId: String)
+
+object RouterReconciler {
+ fun reconcile(
+ currentNodes: Iterable,
+ groups: Iterable,
+ previousMembers: Map> = emptyMap(),
+ ): RouterReconciliationResult {
+ val nodes = currentNodes.toList()
+ val groupList = groups.toList()
+ if (nodes.isEmpty()) return preserved(groupList, previousMembers, "subscription refresh returned no nodes")
+
+ val validNodes = nodes.filter { it.enabled && it.available }
+ if (validNodes.isEmpty()) {
+ return preserved(groupList, previousMembers, "subscription refresh returned no valid nodes")
+ }
+
+ val currentById = validNodes.associateBy { it.id }
+ val currentByStableKey = validNodes.asSequence()
+ .filter { !it.stableId.isNullOrBlank() }
+ .distinctBy { StableNodeKey(it.subscriptionId, it.stableId!!) }
+ .associateBy { StableNodeKey(it.subscriptionId, it.stableId!!) }
+ val matchedByGroup = RouterMatcher.match(
+ validNodes,
+ groupList.map { RouterMatchRequest(it.routerId, it.sourceGroupIds, it.filter) },
+ )
+
+ val membersByRouterId = groupList.associate { group ->
+ val matchedIds = matchedByGroup[group.routerId].orEmpty()
+ val matchedIdSet = matchedIds.toSet()
+ val retained = previousMembers[group.routerId].orEmpty()
+ .withIndex()
+ .sortedWith(compareBy> { it.value.userOrder }.thenBy { it.index })
+ .mapNotNull { indexed ->
+ val previous = indexed.value
+ val current = currentById[previous.proxyId]
+ ?.takeIf {
+ it.subscriptionId == previous.sourceGroupId && it.id in matchedIdSet &&
+ routerStableIdOrFallback(it.stableId, it.id) == previous.stableId
+ }
+ ?: currentByStableKey[StableNodeKey(previous.sourceGroupId, previous.stableId)]
+ ?.takeIf { it.id in matchedIdSet }
+ current?.let { node ->
+ previous.copy(
+ proxyId = node.id,
+ stableId = routerStableIdOrFallback(node.stableId, node.id),
+ sourceGroupId = node.subscriptionId,
+ )
+ }
+ }
+ .distinctBy { it.proxyId }
+
+ val retainedIds = retained.mapTo(mutableSetOf()) { it.proxyId }
+ val nextOrder = retained.maxOfOrNull { it.userOrder } ?: 0L
+ val added = matchedIds.asSequence()
+ .filterNot(retainedIds::contains)
+ .mapNotNull(currentById::get)
+ .map { node ->
+ RouterMemberSnapshot(
+ proxyId = node.id,
+ stableId = routerStableIdOrFallback(node.stableId, node.id),
+ sourceGroupId = node.subscriptionId,
+ )
+ }
+ .mapIndexed { index, member -> member.copy(userOrder = nextOrder + index + 1) }
+ .toList()
+ group.routerId to retained + added
+ }
+
+ val selectedByRouterId = groupList.associate { group ->
+ val members = membersByRouterId[group.routerId].orEmpty()
+ val previousSelected = previousMembers[group.routerId].orEmpty()
+ .firstOrNull { it.proxyId == group.selectedProxyId }
+ val selected = previousSelected?.let { old ->
+ members.firstOrNull {
+ it.stableId == old.stableId && it.sourceGroupId == old.sourceGroupId
+ }
+ }
+ ?: members.firstOrNull {
+ previousSelected == null && it.proxyId == group.selectedProxyId
+ }
+ ?: members.firstOrNull()
+ group.routerId to selected?.proxyId
+ }
+
+ return RouterReconciliationResult(membersByRouterId, selectedByRouterId, false)
+ }
+
+ private fun preserved(
+ groups: List,
+ previous: Map>,
+ error: String,
+ ) = RouterReconciliationResult(
+ membersByRouterId = previous,
+ selectedProxyIdsByRouterId = groups.associate { it.routerId to it.selectedProxyId },
+ preservedPreviousMembers = true,
+ error = error,
+ )
+}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/route/RouterRuntime.kt b/app/src/main/java/io/nekohasekai/sagernet/route/RouterRuntime.kt
new file mode 100644
index 0000000000..d77b050cf2
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sagernet/route/RouterRuntime.kt
@@ -0,0 +1,107 @@
+package io.nekohasekai.sagernet.route
+
+/** Runtime-only Router group data after ConfigBuilder has read the database. */
+data class RouterRuntimeGroup(
+ val stableTag: String,
+ val mode: RouterRuntimeMode,
+ val memberProxyIds: List,
+ val selectedProxyId: Long,
+ val id: Long = 0L,
+ val name: String = "",
+ val filter: RouterFilterConfig = RouterFilterConfig(),
+)
+
+enum class RouterRuntimeMode {
+ SELECTOR,
+ URL_TEST
+}
+
+/** A sing-box-independent Router outbound description. */
+data class RouterRuntimeOutbound(
+ val tag: String,
+ val mode: RouterRuntimeMode,
+ val outbounds: List,
+ val defaultTag: String?,
+ val filter: RouterFilterConfig,
+)
+
+class RouterRuntimeException(
+ val groupId: Long,
+ val groupName: String,
+ val reason: Reason,
+) : IllegalStateException(
+ when (reason) {
+ Reason.MISSING -> "Proxy group ${groupName.ifBlank { groupId.toString() }} is missing"
+ Reason.DISABLED -> "Proxy group ${groupName.ifBlank { groupId.toString() }} is disabled"
+ Reason.EMPTY -> "Proxy group ${groupName.ifBlank { groupId.toString() }} has no available nodes"
+ }
+) {
+ enum class Reason { MISSING, DISABLED, EMPTY }
+}
+
+/**
+ * Resolves persisted Router member IDs against the outbound tags built for the current config.
+ * Router tags are persisted separately from ProxyEntity IDs, so subscription refreshes cannot
+ * invalidate references to a Router group.
+ */
+object RouterRuntime {
+ fun findUrlTestGroupForProxy(
+ groups: Iterable,
+ selectedProxyId: Long,
+ ): String? = groups.firstOrNull {
+ it.mode == RouterRuntimeMode.URL_TEST &&
+ it.stableTag.isNotBlank() &&
+ selectedProxyId in it.memberProxyIds
+ }?.stableTag
+
+ fun build(
+ groups: Iterable,
+ proxyTags: Map,
+ reservedTags: Set = emptySet()
+ ): List {
+ val usedTags = reservedTags.toMutableSet()
+
+ return groups.mapNotNull { group ->
+ if (group.stableTag.isBlank() || !usedTags.add(group.stableTag)) return@mapNotNull null
+
+ val memberTags = group.memberProxyIds.mapNotNull(proxyTags::get).distinct()
+ if (memberTags.isEmpty()) return@mapNotNull null
+ val outbounds = memberTags
+ val defaultTag = if (group.mode == RouterRuntimeMode.SELECTOR) {
+ proxyTags[group.selectedProxyId]?.takeIf { it in memberTags } ?: outbounds.first()
+ } else {
+ null
+ }
+
+ RouterRuntimeOutbound(group.stableTag, group.mode, outbounds, defaultTag, group.filter)
+ }
+ }
+}
+
+object RouterRuntimeSelection {
+ fun resolve(
+ routerTags: Map,
+ profileTags: Map,
+ currentOutbound: (String) -> String,
+ ): LongArray {
+ if (routerTags.isEmpty() || profileTags.isEmpty()) return longArrayOf()
+ val profileIdsByTag = profileTags.entries.associate { (id, tag) -> tag to id }
+ val result = ArrayList(routerTags.size * 2)
+ routerTags.forEach { (routerId, routerTag) ->
+ val profileId = profileIdsByTag[currentOutbound(routerTag)] ?: return@forEach
+ result += routerId
+ result += profileId
+ }
+ return result.toLongArray()
+ }
+
+ fun toMap(pairs: LongArray): Map {
+ val result = linkedMapOf()
+ var index = 0
+ while (index + 1 < pairs.size) {
+ result[pairs[index]] = pairs[index + 1]
+ index += 2
+ }
+ return result
+ }
+}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/route/RouterSelection.kt b/app/src/main/java/io/nekohasekai/sagernet/route/RouterSelection.kt
new file mode 100644
index 0000000000..53c3148a2f
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sagernet/route/RouterSelection.kt
@@ -0,0 +1,57 @@
+package io.nekohasekai.sagernet.route
+
+/** A node selection request from the Router surface. Null routerTag means the legacy selector. */
+data class RouterSelectionRequest(
+ val routerTag: String?,
+ val proxyId: Long,
+ val mode: RouterRuntimeMode,
+ val routerEnabled: Boolean = true,
+)
+
+sealed interface RouterSelectionPlan {
+ data class HotSwitch(
+ val routerTag: String?,
+ val selectorTag: String,
+ val targetTag: String,
+ ) : RouterSelectionPlan
+
+ data object Reload : RouterSelectionPlan
+
+ data object IgnoreMissingRouter : RouterSelectionPlan
+}
+
+/** Decides whether a node click can use the running selector or needs a full reload. */
+object RouterSelection {
+
+ fun plan(
+ request: RouterSelectionRequest,
+ routerSelectorTags: Map,
+ routerMemberIds: Map>,
+ profileTags: Map,
+ selectorGroupId: Long,
+ ): RouterSelectionPlan {
+ val selectorTag = if (request.routerTag == null) {
+ if (selectorGroupId < 0L) return RouterSelectionPlan.Reload
+ "proxy"
+ } else {
+ if (!request.routerEnabled || request.routerTag.isBlank()) {
+ return RouterSelectionPlan.IgnoreMissingRouter
+ }
+ routerSelectorTags[request.routerTag]
+ ?: return RouterSelectionPlan.IgnoreMissingRouter
+ }
+
+ if (request.routerTag != null && request.mode != RouterRuntimeMode.SELECTOR) {
+ return RouterSelectionPlan.Reload
+ }
+
+ val targetTag = profileTags[request.proxyId]
+ ?.takeIf { it.isNotBlank() }
+ ?: return RouterSelectionPlan.Reload
+ if (request.routerTag != null && request.proxyId !in routerMemberIds[request.routerTag].orEmpty()) {
+ return RouterSelectionPlan.Reload
+ }
+
+ return RouterSelectionPlan.HotSwitch(request.routerTag, selectorTag, targetTag)
+ }
+}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/route/UrlTestTargetResolver.kt b/app/src/main/java/io/nekohasekai/sagernet/route/UrlTestTargetResolver.kt
new file mode 100644
index 0000000000..b0afc9660c
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sagernet/route/UrlTestTargetResolver.kt
@@ -0,0 +1,14 @@
+package io.nekohasekai.sagernet.route
+
+internal object UrlTestTargetResolver {
+
+ fun resolve(
+ routerGroupId: Long?,
+ loadRouterTargets: (Long) -> List,
+ loadNormalGroupTargets: () -> List,
+ ): List = if (routerGroupId != null) {
+ loadRouterTargets(routerGroupId)
+ } else {
+ loadNormalGroupTargets()
+ }
+}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/AboutFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/AboutFragment.kt
index 75a64a82ab..94a95296dd 100644
--- a/app/src/main/java/io/nekohasekai/sagernet/ui/AboutFragment.kt
+++ b/app/src/main/java/io/nekohasekai/sagernet/ui/AboutFragment.kt
@@ -4,24 +4,22 @@ import android.app.Activity
import android.content.Context
import android.content.Intent
import android.net.Uri
-import android.os.Build
import android.os.Bundle
import android.os.PowerManager
import android.provider.Settings
-import android.text.util.Linkify
import android.view.View
-import android.widget.Toast
import androidx.activity.result.component1
import androidx.activity.result.component2
import androidx.activity.result.contract.ActivityResultContracts
+import androidx.core.net.toUri
import androidx.core.view.ViewCompat
import androidx.recyclerview.widget.RecyclerView
import com.danielstone.materialaboutlibrary.MaterialAboutFragment
import com.danielstone.materialaboutlibrary.items.MaterialAboutActionItem
import com.danielstone.materialaboutlibrary.model.MaterialAboutCard
import com.danielstone.materialaboutlibrary.model.MaterialAboutList
-import io.nekohasekai.sagernet.BuildConfig
import io.nekohasekai.sagernet.R
+import io.nekohasekai.sagernet.SagerNet
import io.nekohasekai.sagernet.databinding.LayoutAboutBinding
import io.nekohasekai.sagernet.ktx.*
import io.nekohasekai.sagernet.plugin.PluginManager.loadString
@@ -29,12 +27,6 @@ import io.nekohasekai.sagernet.utils.PackageCache
import io.nekohasekai.sagernet.widget.ListListener
import libcore.Libcore
import moe.matsuri.nb4a.plugin.Plugins
-import androidx.core.net.toUri
-import com.google.android.material.dialog.MaterialAlertDialogBuilder
-import io.nekohasekai.sagernet.SagerNet
-import io.nekohasekai.sagernet.database.DataStore
-import moe.matsuri.nb4a.utils.Util
-import org.json.JSONObject
class AboutFragment : ToolbarFragment(R.layout.layout_about) {
@@ -50,13 +42,7 @@ class AboutFragment : ToolbarFragment(R.layout.layout_about) {
.replace(R.id.about_fragment_holder, AboutContent())
.commitAllowingStateLoss()
- runOnDefaultDispatcher {
- val license = view.context.assets.open("LICENSE").bufferedReader().readText()
- onMainDispatcher {
- binding.license.text = license
- Linkify.addLinks(binding.license, Linkify.EMAIL_ADDRESSES or Linkify.WEB_URLS)
- }
- }
+ binding.license.text = "GPL-3.0 License\nGalaxy S24 & Android 16 个人定制版"
}
class AboutContent : MaterialAboutFragment() {
@@ -81,44 +67,15 @@ class AboutFragment : ToolbarFragment(R.layout.layout_about) {
.icon(R.drawable.ic_baseline_update_24)
.text(R.string.app_version)
.subText(SagerNet.appVersionNameForDisplay)
- .setOnClickAction {
- requireContext().launchCustomTab(
- "https://github.com/MatsuriDayo/NekoBoxForAndroid/releases"
- )
- }
- .build())
- .addItem(
- MaterialAboutActionItem.Builder()
- .text(R.string.check_update_release)
- .setOnClickAction {
- checkUpdate(false)
- }
- .build())
- .addItem(
- MaterialAboutActionItem.Builder()
- .text(R.string.check_update_preview)
- .setOnClickAction {
- checkUpdate(true)
- }
- .build())
+ .build()
+ )
.addItem(
MaterialAboutActionItem.Builder()
.icon(R.drawable.ic_baseline_layers_24)
.text(getString(R.string.version_x, "sing-box"))
.subText(Libcore.versionBox())
- .setOnClickAction { }
- .build())
- .addItem(
- MaterialAboutActionItem.Builder()
- .icon(R.drawable.ic_baseline_card_giftcard_24)
- .text(R.string.donate)
- .subText(R.string.donate_info)
- .setOnClickAction {
- requireContext().launchCustomTab(
- "https://matsuridayo.github.io/index_docs/#donate"
- )
- }
- .build())
+ .build()
+ )
.apply {
PackageCache.awaitLoadSync()
for ((_, pkg) in PackageCache.installedPluginPackages) {
@@ -145,62 +102,36 @@ class AboutFragment : ToolbarFragment(R.layout.layout_about) {
)
})
}
- .build())
+ .build()
+ )
} catch (e: Exception) {
Logs.w(e)
}
}
}
.apply {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
- val pm = app.getSystemService(Context.POWER_SERVICE) as PowerManager
- if (!pm.isIgnoringBatteryOptimizations(app.packageName)) {
- addItem(
- MaterialAboutActionItem.Builder()
- .icon(R.drawable.ic_baseline_running_with_errors_24)
- .text(R.string.ignore_battery_optimizations)
- .subText(R.string.ignore_battery_optimizations_sum)
- .setOnClickAction {
- requestIgnoreBatteryOptimizations.launch(
- Intent(
- Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
- "package:${app.packageName}".toUri()
- )
+ val pm = app.getSystemService(Context.POWER_SERVICE) as PowerManager
+ if (!pm.isIgnoringBatteryOptimizations(app.packageName)) {
+ addItem(
+ MaterialAboutActionItem.Builder()
+ .icon(R.drawable.ic_baseline_running_with_errors_24)
+ .text(R.string.ignore_battery_optimizations)
+ .subText(R.string.ignore_battery_optimizations_sum)
+ .setOnClickAction {
+ requestIgnoreBatteryOptimizations.launch(
+ Intent(
+ Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
+ "package:${app.packageName}".toUri()
)
- }
- .build())
- }
+ )
+ }
+ .build()
+ )
}
}
- .build())
- .addCard(
- MaterialAboutCard.Builder()
- .outline(true)
- .title(R.string.project)
- .addItem(
- MaterialAboutActionItem.Builder()
- .icon(R.drawable.ic_baseline_sanitizer_24)
- .text(R.string.github)
- .setOnClickAction {
- requireContext().launchCustomTab(
- "https://github.com/MatsuriDayo/NekoBoxForAndroid"
-
- )
- }
- .build())
- .addItem(
- MaterialAboutActionItem.Builder()
- .icon(R.drawable.ic_qu_shadowsocks_foreground)
- .text(R.string.telegram)
- .setOnClickAction {
- requireContext().launchCustomTab(
- "https://t.me/MatsuriDayo"
- )
- }
- .build())
- .build())
+ .build()
+ )
.build()
-
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
@@ -210,74 +141,6 @@ class AboutFragment : ToolbarFragment(R.layout.layout_about) {
overScrollMode = RecyclerView.OVER_SCROLL_NEVER
}
}
-
- fun checkUpdate(checkPreview: Boolean) {
- runOnIoDispatcher {
- try {
- val client = Libcore.newHttpClient().apply {
- modernTLS()
- trySocks5(
- DataStore.mixedPort,
- DataStore.mixedInboundUser,
- DataStore.mixedInboundPass
- )
- }
- val response = client.newRequest().apply {
- if (checkPreview) {
- setURL("https://api.github.com/repos/starifly/NekoBoxForAndroid/releases/tags/preview")
- } else {
- setURL("https://api.github.com/repos/starifly/NekoBoxForAndroid/releases/latest")
- }
- }.execute()
- val release = JSONObject(Util.getStringBox(response.contentString))
- val releaseName = release.getString("name")
- val releaseUrl = release.getString("html_url")
- var haveUpdate = releaseName.isNotBlank()
- haveUpdate = if (isPreview) {
- if (checkPreview) {
- haveUpdate && releaseName != BuildConfig.PRE_VERSION_NAME
- } else {
- // User: 1.3.9 pre-1.4.0 Stable: 1.3.9 -> No update
- haveUpdate && releaseName != BuildConfig.VERSION_NAME
- }
- } else {
- // User: 1.4.0 Preview: pre-1.4.0 -> No update
- // User: 1.4.0 Preview: pre-1.4.1 -> Update
- // User: 1.4.0 Stable: 1.4.0 -> No update
- // User: 1.4.0 Stable: 1.4.1 -> Update
- haveUpdate && !releaseName.contains(BuildConfig.VERSION_NAME)
- }
- runOnMainDispatcher {
- if (haveUpdate) {
- val context = requireContext()
- MaterialAlertDialogBuilder(context)
- .setTitle(R.string.update_dialog_title)
- .setMessage(
- context.getString(
- R.string.update_dialog_message,
- SagerNet.appVersionNameForDisplay,
- releaseName
- )
- )
- .setPositiveButton(R.string.yes) { _, _ ->
- val intent = Intent(Intent.ACTION_VIEW, releaseUrl.toUri())
- context.startActivity(intent)
- }
- .setNegativeButton(R.string.no, null)
- .show()
- } else {
- Toast.makeText(app, R.string.check_update_no, Toast.LENGTH_SHORT).show()
- }
- }
- } catch (e: Exception) {
- Logs.w(e)
- runOnMainDispatcher {
- Toast.makeText(app, e.readableMessage, Toast.LENGTH_SHORT).show()
- }
- }
- }
- }
-
}
}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/AssetsActivity.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/AssetsActivity.kt
index 22d7789ca9..12e93c643b 100644
--- a/app/src/main/java/io/nekohasekai/sagernet/ui/AssetsActivity.kt
+++ b/app/src/main/java/io/nekohasekai/sagernet/ui/AssetsActivity.kt
@@ -6,6 +6,7 @@ import android.text.format.DateFormat
import android.view.Menu
import android.view.MenuItem
import android.view.ViewGroup
+import androidx.activity.addCallback
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.view.isInvisible
import androidx.recyclerview.widget.ItemTouchHelper
@@ -33,6 +34,9 @@ class AssetsActivity : ThemedActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
+ onBackPressedDispatcher.addCallback(this) {
+ finish()
+ }
val binding = LayoutAssetsBinding.inflate(layoutInflater)
layout = binding
@@ -364,7 +368,7 @@ class AssetsActivity : ThemedActivity() {
response.writeTo(cacheFile.canonicalPath)
cacheFile.renameTo(file)
- val currentDate = java.text.SimpleDateFormat("yyyyMMdd").format(java.util.Date())
+ val currentDate = java.text.SimpleDateFormat("yyyyMMdd", Locale.ROOT).format(Date())
versionFile.writeText(currentDate)
adapter.reloadAssets()
@@ -384,10 +388,6 @@ class AssetsActivity : ThemedActivity() {
return true
}
- override fun onBackPressed() {
- finish()
- }
-
override fun onResume() {
super.onResume()
diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/BackupFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/BackupFragment.kt
index a73052913c..666c2e6ab3 100644
--- a/app/src/main/java/io/nekohasekai/sagernet/ui/BackupFragment.kt
+++ b/app/src/main/java/io/nekohasekai/sagernet/ui/BackupFragment.kt
@@ -3,14 +3,13 @@ package io.nekohasekai.sagernet.ui
import android.content.Intent
import android.net.Uri
import android.os.Bundle
-import android.os.Parcel
-import android.os.Parcelable
import android.provider.OpenableColumns
import android.view.View
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog
import androidx.core.content.FileProvider
import androidx.core.view.isVisible
+import androidx.lifecycle.lifecycleScope
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.jakewharton.processphoenix.ProcessPhoenix
import io.nekohasekai.sagernet.BuildConfig
@@ -25,18 +24,12 @@ import io.nekohasekai.sagernet.databinding.LayoutImportBinding
import io.nekohasekai.sagernet.databinding.LayoutProgressBinding
import io.nekohasekai.sagernet.ktx.*
import kotlinx.coroutines.delay
-import moe.matsuri.nb4a.utils.Util
-import org.json.JSONArray
+import io.nekohasekai.sagernet.fmt.BackupSerializer
import org.json.JSONObject
import java.io.ByteArrayOutputStream
import java.io.File
import java.net.URL
import java.util.*
-import okhttp3.Credentials
-import okhttp3.OkHttpClient
-import okhttp3.Request
-import okhttp3.MediaType.Companion.toMediaType
-import okhttp3.RequestBody.Companion.toRequestBody
import androidx.annotation.StringRes
import com.google.android.material.snackbar.Snackbar
import io.nekohasekai.sagernet.ktx.snackbar
@@ -44,33 +37,24 @@ import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
import java.io.BufferedInputStream
import java.util.zip.ZipInputStream
-import java.util.concurrent.TimeUnit
-import java.util.zip.Deflater
-import java.io.BufferedOutputStream
-import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
-
-class BackupFragment : NamedFragment(R.layout.layout_backup) {
-
- private lateinit var binding: LayoutBackupBinding
- private lateinit var backupData: ByteArray
- private var isWebDAVBackup = false
- private var isBackupInProgress = false
- private var isRestoreInProgress = false
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.NonCancellable
+import kotlinx.coroutines.withContext
+import kotlinx.coroutines.Dispatchers
+import io.nekohasekai.sagernet.fmt.KryoConverters
+import java.text.SimpleDateFormat
+
+class BackupFragment : ToolbarFragment(R.layout.layout_backup) {
+
+ private var pendingExportName: String? = null
private var currentJob: kotlinx.coroutines.Job? = null
private var snackbar: Snackbar? = null
- private var restoreJob: kotlinx.coroutines.Job? = null
override fun onDestroyView() {
super.onDestroyView()
snackbar?.dismiss()
snackbar = null
- // 如果正在进行恢复操作,取消它
- if (isRestoreInProgress) {
- restoreJob?.cancel()
- restoreJob = null
- isRestoreInProgress = false
- MessageStore.showMessage(requireActivity(), R.string.restore_cancelled)
- }
}
override fun onDestroy() {
@@ -79,456 +63,121 @@ class BackupFragment : NamedFragment(R.layout.layout_backup) {
currentJob = null
}
- override fun name0() = app.getString(R.string.backup)
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ pendingExportName = savedInstanceState?.getString("pendingExportName")
+ }
+
+ override fun onSaveInstanceState(outState: Bundle) {
+ outState.putString("pendingExportName", pendingExportName)
+ super.onSaveInstanceState(outState)
+ }
+
+ private fun backupFileName() =
+ "asteria_backup_${SimpleDateFormat("yyyyMMdd-HHmmss-SSS", Locale.ROOT).format(Date())}.json"
var content = ""
private val exportSettings = registerForActivityResult(ActivityResultContracts.CreateDocument()) { data ->
+ val pending = pendingExportName?.let { File(app.cacheDir, it) }
+ pendingExportName = null
if (data != null) {
- runOnDefaultDispatcher {
+ lifecycleScope.launch {
try {
- requireActivity().contentResolver.openOutputStream(data)!!.use { os ->
- os.write(backupData)
+ onDefaultDispatcher {
+ check(pending?.isFile == true) { "Backup snapshot is unavailable; export again" }
+ pending.inputStream().use { input ->
+ checkNotNull(app.contentResolver.openOutputStream(data, "wt")) {
+ "Unable to open backup destination"
+ }.use { input.copyTo(it) }
+ }
}
- onMainDispatcher {
+ if (view != null) {
snackbar(getString(R.string.action_export_msg)).show()
}
+ } catch (e: CancellationException) {
+ throw e
} catch (e: Exception) {
Logs.w(e)
- onMainDispatcher {
+ if (view != null) {
snackbar(e.readableMessage).show()
}
+ } finally {
+ pending?.delete()
}
}
+ } else {
+ pending?.delete()
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
+ toolbar.setTitle(R.string.backup)
val binding = LayoutBackupBinding.bind(view)
binding.actionExport.setOnClickListener {
- runOnDefaultDispatcher {
- backupData = doBackup(
- binding.backupConfigurations.isChecked,
- binding.backupRules.isChecked,
- binding.backupSettings.isChecked
- )
- onMainDispatcher {
- startFilesForResult(
- exportSettings, "nekobox_backup_${Date().toLocaleString()}.json"
- )
+ if (currentJob?.isActive == true || pendingExportName != null) return@setOnClickListener
+ val profiles = binding.backupConfigurations.isChecked
+ val rules = binding.backupRules.isChecked
+ val settings = binding.backupSettings.isChecked
+ currentJob = viewLifecycleOwner.lifecycleScope.launch {
+ try {
+ val cacheFile = onDefaultDispatcher {
+ val bytes = doBackup(profiles, rules, settings)
+ File(app.cacheDir, backupFileName()).apply { writeBytes(bytes) }
+ }
+ pendingExportName = cacheFile.name
+ startFilesForResult(exportSettings, cacheFile.name)
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ Logs.w(e)
+ pendingExportName = null
+ snackbar(e.readableMessage).show()
}
}
}
binding.actionShare.setOnClickListener {
- runOnDefaultDispatcher {
- backupData = doBackup(
- binding.backupConfigurations.isChecked,
- binding.backupRules.isChecked,
- binding.backupSettings.isChecked
- )
- app.cacheDir.mkdirs()
- val cacheFile = File(
- app.cacheDir, "nekobox_backup_${Date().toLocaleString()}.json"
- )
- cacheFile.writeBytes(backupData)
- onMainDispatcher {
- startActivity(
- Intent.createChooser(
- Intent(Intent.ACTION_SEND).setType("application/json")
- .setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
- .putExtra(
- Intent.EXTRA_STREAM, FileProvider.getUriForFile(
- app, BuildConfig.APPLICATION_ID + ".cache", cacheFile
- )
- ), app.getString(R.string.abc_shareactionprovider_share_with)
- )
- )
- }
-
- }
- }
-
- binding.actionImportFile.setOnClickListener {
- startFilesForResult(importFile, "*/*")
- }
-
- setupWebDAV(binding)
- }
-
- private fun setupWebDAV(binding: LayoutBackupBinding) {
- binding.webdavSettings.setOnClickListener {
- startActivity(Intent(requireContext(), WebDAVSettingsActivity::class.java))
- }
-
- binding.backupToWebdav.setOnClickListener {
- if (DataStore.webdavServer.isNullOrEmpty()) {
- showMessage(R.string.webdav_server_empty)
- return@setOnClickListener
- }
- backupToWebDAV()
- }
-
- binding.restoreFromWebdav.setOnClickListener {
- if (DataStore.webdavServer.isNullOrEmpty()) {
- showMessage(R.string.webdav_server_empty)
- return@setOnClickListener
- }
- restoreFromWebDAV()
- }
- }
-
- private fun backupToWebDAV() {
- if (isBackupInProgress) {
- showMessage(R.string.backup_in_progress)
- return
- }
- isBackupInProgress = true
- val activity = requireActivity()
- runOnDefaultDispatcher {
- try {
- isWebDAVBackup = true
- val backupData = doBackup(
- true, // 备份配置和分组
- true, // 备份路由规则
- true // 备份设置
- )
- isWebDAVBackup = false
-
- val client = OkHttpClient()
-
- // 规范化 URL
- val baseUrl = DataStore.webdavServer!!.trimEnd('/')
- val path = DataStore.webdavPath?.trim('/')?.takeIf { it.isNotEmpty() } ?: "Nekobox"
-
- // 使用英文格式的时间戳作为文件名,修改后缀为 .zip
- val timestamp = java.text.SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
- val version = BuildConfig.VERSION_NAME
- val fileName = "nekobox_backup_${version}_$timestamp.zip"
-
- // 确保 baseUrl 是有效的 URL
- if (!baseUrl.startsWith("http://") && !baseUrl.startsWith("https://")) {
- throw Exception("Invalid server URL: must start with http:// or https://")
- }
-
- // 使用 HttpUrl 构建路径,避免 # 等特殊字符被当作 fragment
- val baseHttpUrl = baseUrl.toHttpUrlOrNull()
- ?: throw Exception("Invalid server URL: $baseUrl")
-
- val dirUrl = baseHttpUrl.newBuilder().apply {
- path.split('/').filter { it.isNotEmpty() }.forEach { segment ->
- addPathSegment(segment)
- }
- }.build()
-
- val fileUrl = dirUrl.newBuilder()
- .addPathSegment(fileName)
- .build()
-
- Logs.d("WebDAV backup - Directory URL: $dirUrl")
- Logs.d("WebDAV backup - File URL: $fileUrl")
-
- // 先检查目录是否存在
- val propfindRequest = Request.Builder()
- .url(dirUrl)
- .method("PROPFIND", null)
- .header("Authorization", Credentials.basic(
- DataStore.webdavUsername ?: "",
- DataStore.webdavPassword ?: ""
- ))
- .header("Depth", "0")
- .build()
-
- var needCreateDir = false
- client.newCall(propfindRequest).execute().use { response ->
- Logs.d("WebDAV backup - PROPFIND response: ${response.code}")
- when (response.code) {
- 404 -> needCreateDir = true
- 207 -> needCreateDir = false // 目录存在
- 401 -> throw Exception("Authentication failed")
- else -> {
- if (!response.isSuccessful) {
- val errorBody = response.body?.string()
- Logs.e("WebDAV backup - PROPFIND error: $errorBody")
- throw Exception("Failed to check directory (${response.code}): ${response.message}")
- }
- }
- }
- }
-
- // 如果需要,创建目录
- if (needCreateDir) {
- Logs.d("WebDAV backup - Creating directory")
- val mkcolRequest = Request.Builder()
- .url(dirUrl)
- .method("MKCOL", null)
- .header("Authorization", Credentials.basic(
- DataStore.webdavUsername ?: "",
- DataStore.webdavPassword ?: ""
- ))
- .build()
-
- client.newCall(mkcolRequest).execute().use { response ->
- if (!response.isSuccessful) {
- val errorBody = response.body?.string()
- Logs.e("WebDAV backup - MKCOL error: $errorBody")
- throw Exception("Failed to create directory (${response.code}): ${response.message}")
- }
- }
- }
-
- // 上传文件时使用正确的 Content-Type
- val putRequest = Request.Builder()
- .url(fileUrl)
- .put(backupData.toRequestBody("application/zip".toMediaType()))
- .apply {
- header("Authorization", Credentials.basic(
- DataStore.webdavUsername ?: "",
- DataStore.webdavPassword ?: ""
- ))
+ if (currentJob?.isActive == true || pendingExportName != null) return@setOnClickListener
+ val profiles = binding.backupConfigurations.isChecked
+ val rules = binding.backupRules.isChecked
+ val settings = binding.backupSettings.isChecked
+ currentJob = viewLifecycleOwner.lifecycleScope.launch {
+ try {
+ val cacheFile = onDefaultDispatcher {
+ val bytes = doBackup(profiles, rules, settings)
+ File(app.cacheDir, backupFileName()).apply { writeBytes(bytes) }
}
- .build()
-
- client.newCall(putRequest).execute().use { response ->
- if (!response.isSuccessful) {
- val errorBody = response.body?.string()
- Logs.e("WebDAV backup - PUT error: $errorBody")
- throw Exception("Upload failed (${response.code}): ${response.message}\n$errorBody")
+ onMainDispatcher {
+ startActivity(
+ Intent.createChooser(
+ Intent(Intent.ACTION_SEND).setType("application/json")
+ .setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ .putExtra(
+ Intent.EXTRA_STREAM, FileProvider.getUriForFile(
+ app, BuildConfig.APPLICATION_ID + ".cache", cacheFile
+ )
+ ), app.getString(R.string.abc_shareactionprovider_share_with)
+ )
+ )
}
- Logs.d("WebDAV backup - Upload successful")
- }
-
- onMainDispatcher {
- MessageStore.showMessage(activity, R.string.webdav_backup_success)
- }
- } catch (e: Exception) {
- isWebDAVBackup = false // 确保发生异常时也重置标志
- Logs.w(e)
-
- val errorMessage = try {
- if (isAdded) {
- getString(R.string.webdav_backup_failed, e.message ?: "")
- } else {
- app.getString(R.string.webdav_backup_failed, e.message ?: "")
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ Logs.w(e)
+ onMainDispatcher {
+ snackbar(e.readableMessage).show()
}
- } catch (ex: Exception) {
- "WebDAV backup failed: ${e.message ?: ""}"
}
-
- onMainDispatcher {
- MessageStore.showMessage(activity, errorMessage)
- }
- } finally {
- isBackupInProgress = false
}
}
- }
-
- private fun restoreFromWebDAV() {
- if (isRestoreInProgress) {
- showMessage(R.string.restore_in_progress)
- return
- }
- isRestoreInProgress = true
- val activity = requireActivity()
- restoreJob = runOnDefaultDispatcher {
- try {
- val client = OkHttpClient()
- val baseUrl = DataStore.webdavServer!!.trimEnd('/')
- val path = DataStore.webdavPath?.trim('/')?.takeIf { it.isNotEmpty() } ?: "Nekobox"
-
- if (!baseUrl.startsWith("http://") && !baseUrl.startsWith("https://")) {
- throw Exception("Invalid server URL: must start with http:// or https://")
- }
-
- val baseHttpUrl = baseUrl.toHttpUrlOrNull()
- ?: throw Exception("Invalid server URL: $baseUrl")
-
- val dirUrl = baseHttpUrl.newBuilder().apply {
- path.split('/').filter { it.isNotEmpty() }.forEach { segment ->
- addPathSegment(segment)
- }
- }.build()
-
- Logs.d("WebDAV restore - Directory URL: $dirUrl")
-
- // 先列出目录内容找到最新的备份文件
- val propfindRequest = Request.Builder()
- .url(dirUrl)
- .method("PROPFIND", null)
- .header("Authorization", Credentials.basic(
- DataStore.webdavUsername ?: "",
- DataStore.webdavPassword ?: ""
- ))
- .header("Depth", "1")
- .build()
-
- // 获取最新的备份文件名
- val latestBackup = client.newCall(propfindRequest).execute().use { response ->
- if (!response.isSuccessful && response.code != 207) {
- val errorBody = response.body?.string()
- Logs.e("WebDAV restore - PROPFIND error: $errorBody")
- throw Exception("Failed to list directory: ${response.message}")
- }
-
- val responseBody = response.body?.string() ?: throw Exception("Empty response")
- Logs.d("WebDAV restore - Directory listing: $responseBody")
-
- val patterns = listOf(
- """[^<]*?nekobox_backup_[^<]*?\d{8}_\d{6}\.(json|zip)""".toRegex(),
- """[^<]*?nekobox_backup_[^<]*?\d{8}_\d{6}\.(json|zip)""".toRegex(),
- """[^<]*?nekobox_backup_[^<]*?\d{8}_\d{6}\.(json|zip)""".toRegex()
- )
-
- val backupFiles = mutableListOf()
-
- for (pattern in patterns) {
- val matches = pattern.findAll(responseBody)
- matches.forEach { match ->
- val href = match.value
- Logs.d("WebDAV restore - Found backup file with pattern ${pattern.pattern}: $href")
- val fileName = """nekobox_backup_[^<]*?\d{8}_\d{6}\.(json|zip)""".toRegex()
- .find(href)?.value
- if (fileName != null) {
- backupFiles.add(fileName)
- }
- }
- if (backupFiles.isNotEmpty()) break
- }
-
- Logs.d("WebDAV restore - Found ${backupFiles.size} backup files: ${backupFiles.joinToString()}")
-
- backupFiles.maxByOrNull { fileName ->
- """(\d{8}_\d{6})""".toRegex().find(fileName)?.value ?: ""
- } ?: throw Exception("No backup found")
- }
-
- // 下载最新的备份文件
- val fileUrl = dirUrl.newBuilder()
- .addPathSegment(latestBackup)
- .build()
- Logs.d("WebDAV restore - File URL: $fileUrl")
-
- val getRequest = Request.Builder()
- .url(fileUrl)
- .get()
- .header("Authorization", Credentials.basic(
- DataStore.webdavUsername ?: "",
- DataStore.webdavPassword ?: ""
- ))
- .build()
-
- val content = client.newCall(getRequest).execute().use { response ->
- if (!response.isSuccessful) {
- val errorBody = response.body?.string()
- Logs.e("WebDAV restore - GET error: $errorBody")
- throw Exception("Download failed (${response.code}): ${response.message}")
- }
- response.body?.bytes() ?: throw Exception("Empty backup file")
- }
-
- Logs.d("WebDAV restore - Successfully downloaded backup file, size: ${content.size}")
-
- // 根据文件类型处理内容
- val backupContent = if (latestBackup.endsWith(".zip")) {
- // ZIP 文件处理
- ZipInputStream(content.inputStream()).use { zis ->
- zis.nextEntry?.let { entry ->
- if (entry.name.endsWith(".json")) {
- zis.readBytes().toString(Charsets.UTF_8)
- } else {
- throw Exception("Invalid backup file format")
- }
- } ?: throw Exception("Invalid backup file format")
- }
- } else {
- // JSON 文件处理
- content.toString(Charsets.UTF_8)
- }
-
- // 解析并导入备份数据
- val json = JSONObject(backupContent)
- onMainDispatcher {
- // 如果 Fragment 已经被销毁,取消恢复操作
- if (!isAdded) {
- MessageStore.showMessage(activity, R.string.restore_cancelled)
- return@onMainDispatcher
- }
-
- val import = LayoutImportBinding.inflate(layoutInflater)
- if (!json.has("profiles")) {
- import.backupConfigurations.isVisible = false
- }
- if (!json.has("rules")) {
- import.backupRules.isVisible = false
- }
- if (!json.has("settings")) {
- import.backupSettings.isVisible = false
- }
-
- MaterialAlertDialogBuilder(requireContext()).setTitle(R.string.backup_import)
- .setView(import.root)
- .setPositiveButton(R.string.backup_import) { _, _ ->
- SagerNet.stopService()
-
- val binding = LayoutProgressBinding.inflate(layoutInflater)
- binding.content.text = getString(R.string.backup_importing)
- val dialog = AlertDialog.Builder(requireContext())
- .setView(binding.root)
- .setCancelable(false)
- .show()
- runOnDefaultDispatcher {
- runCatching {
- // 再次检查是否已被取消
- if (!isAdded) {
- MessageStore.showMessage(activity, R.string.restore_cancelled)
- return@runOnDefaultDispatcher
- }
- finishImport(
- json,
- import.backupConfigurations.isChecked,
- import.backupRules.isChecked,
- import.backupSettings.isChecked
- )
- ProcessPhoenix.triggerRebirth(
- activity, Intent(activity, MainActivity::class.java)
- )
- }.onFailure {
- Logs.w(it)
- onMainDispatcher {
- MessageStore.showMessage(activity, it.readableMessage)
- }
- }
- onMainDispatcher {
- dialog.dismiss()
- }
- }
- }
- .setNegativeButton(android.R.string.cancel, null)
- .show()
- }
- } catch (e: Exception) {
- Logs.w(e)
- onMainDispatcher {
- MessageStore.showMessage(activity, e.readableMessage)
- }
- } finally {
- isRestoreInProgress = false
- }
+ binding.actionImportFile.setOnClickListener {
+ startFilesForResult(importFile, "*/*")
}
- }
- fun Parcelable.toBase64Str(): String {
- val parcel = Parcel.obtain()
- writeToParcel(parcel, 0)
- try {
- return Util.b64EncodeUrlSafe(parcel.marshall())
- } finally {
- parcel.recycle()
- }
}
private fun doBackup(
@@ -536,93 +185,41 @@ class BackupFragment : NamedFragment(R.layout.layout_backup) {
rule: Boolean,
setting: Boolean
): ByteArray {
- val out = JSONObject().apply {
- put("version", 1)
- if (profile) {
- put("profiles", JSONArray().apply {
- SagerDatabase.proxyDao.getAll().forEach {
- put(it.toBase64Str())
- }
- })
-
- put("groups", JSONArray().apply {
- SagerDatabase.groupDao.allGroups().forEach {
- put(it.toBase64Str())
- }
- })
- }
- if (rule) {
- put("rules", JSONArray().apply {
- SagerDatabase.rulesDao.allRules().forEach {
- put(it.toBase64Str())
- }
- })
- }
+ val out = BackupSerializer.exportDatabase(SagerDatabase.instance, profile, rule).apply {
if (setting) {
- put("settings", JSONArray().apply {
- PublicDatabase.kvPairDao.all().forEach {
- put(it.toBase64Str())
- }
- })
+ BackupSerializer.putParcelableArray(this, "settings", PublicDatabase.kvPairDao.all())
}
}
val jsonContent = out.toStringPretty()
- return if (isWebDAVBackup) {
- ByteArrayOutputStream().use { bos ->
- ZipOutputStream(bos).use { zos ->
- zos.setLevel(Deflater.BEST_COMPRESSION)
-
- val entry = ZipEntry("nekobox_backup.json").apply {
- method = ZipEntry.DEFLATED
- }
-
- // 写入数据
- zos.putNextEntry(entry)
- val bytes = jsonContent.toByteArray(Charsets.UTF_8)
- zos.write(bytes)
- zos.closeEntry()
-
- // 确保所有数据都被写入和压缩
- zos.finish()
- }
- bos.toByteArray()
- }
- } else {
- // 本地导出和分享功能使用 JSON 格式
- jsonContent.toByteArray()
- }
+ return jsonContent.toByteArray()
}
val importFile = registerForActivityResult(ActivityResultContracts.GetContent()) { file ->
if (file != null) {
- runOnDefaultDispatcher {
+ viewLifecycleOwner.lifecycleScope.launch {
startImport(file)
}
}
}
suspend fun startImport(file: Uri) {
- val activity = requireActivity()
- val fileName = requireContext().contentResolver.query(file, null, null, null, null)
- ?.use { cursor ->
- cursor.moveToFirst()
- cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME).let(cursor::getString)
+ val activity = activity ?: return
+ try {
+ val fileName = onDefaultDispatcher {
+ activity.contentResolver.query(file, null, null, null, null)?.use { cursor ->
+ val column = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
+ if (cursor.moveToFirst() && column >= 0) cursor.getString(column) else null
+ }?.takeIf { it.isNotBlank() }
+ ?: file.lastPathSegment.orEmpty().substringAfterLast('/').substringAfter(':')
}
- ?.takeIf { it.isNotBlank() } ?: file.pathSegments.last()
- .substringAfterLast('/')
- .substringAfter(':')
-
- if (!fileName.endsWith(".json") && !fileName.endsWith(".zip")) {
- onMainDispatcher {
+ if (!fileName.endsWith(".json", true) && !fileName.endsWith(".zip", true)) {
snackbar(getString(R.string.backup_not_file, fileName)).show()
+ return
}
- return
- }
-
- try {
- val content = requireContext().contentResolver.openInputStream(file)!!.use { input ->
- if (fileName.endsWith(".zip")) {
+ val content = onDefaultDispatcher {
+ checkNotNull(activity.contentResolver.openInputStream(file)).use { input ->
+ if (fileName.endsWith(".zip", true)) {
ZipInputStream(BufferedInputStream(input)).use { zis ->
zis.nextEntry?.let { entry ->
if (entry.name.endsWith(".json")) {
@@ -636,6 +233,7 @@ class BackupFragment : NamedFragment(R.layout.layout_backup) {
input.readBytes().toString(Charsets.UTF_8)
}
}
+ }
val json = JSONObject(content)
onMainDispatcher {
@@ -652,6 +250,9 @@ class BackupFragment : NamedFragment(R.layout.layout_backup) {
MaterialAlertDialogBuilder(requireContext()).setTitle(R.string.backup_import)
.setView(import.root)
.setPositiveButton(R.string.backup_import) { _, _ ->
+ val profiles = import.backupConfigurations.isChecked
+ val rules = import.backupRules.isChecked
+ val settings = import.backupSettings.isChecked
SagerNet.stopService()
val binding = LayoutProgressBinding.inflate(layoutInflater)
@@ -660,20 +261,23 @@ class BackupFragment : NamedFragment(R.layout.layout_backup) {
.setView(binding.root)
.setCancelable(false)
.show()
- runOnDefaultDispatcher {
- runCatching {
- finishImport(
- json,
- import.backupConfigurations.isChecked,
- import.backupRules.isChecked,
- import.backupSettings.isChecked
- )
- triggerFullRestart(requireContext())
- }.onFailure {
- Logs.w(it)
- onMainDispatcher {
+ val appContext = requireContext().applicationContext
+ lifecycleScope.launch {
+ try {
+ withContext(Dispatchers.Default + NonCancellable) {
+ finishImport(json, profiles, rules, settings)
+ triggerFullRestart(appContext)
+ }
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ Logs.w(e)
+ MessageStore.showMessage(e.readableMessage)
+ } finally {
+ try {
dialog.dismiss()
- MessageStore.showMessage(activity, it.readableMessage)
+ } catch (e: Exception) {
+ // Ignored if window already detached
}
}
}
@@ -681,6 +285,8 @@ class BackupFragment : NamedFragment(R.layout.layout_backup) {
.setNegativeButton(android.R.string.cancel, null)
.show()
}
+ } catch (e: CancellationException) {
+ throw e
} catch (e: Exception) {
Logs.w(e)
onMainDispatcher {
@@ -692,67 +298,279 @@ class BackupFragment : NamedFragment(R.layout.layout_backup) {
fun finishImport(
content: JSONObject, profile: Boolean, rule: Boolean, setting: Boolean
) {
- if (profile && content.has("profiles")) {
- val profiles = mutableListOf()
- val jsonProfiles = content.getJSONArray("profiles")
- for (i in 0 until jsonProfiles.length()) {
- val data = Util.b64Decode(jsonProfiles[i] as String)
- val parcel = Parcel.obtain()
- parcel.unmarshall(data, 0, data.size)
- parcel.setDataPosition(0)
- profiles.add(ProxyEntity.CREATOR.createFromParcel(parcel))
- parcel.recycle()
+ KryoConverters.withStrictDeserialization {
+ val decodedSettings = if (setting) {
+ require(content.has("settings") && !content.isNull("settings")) {
+ "Backup settings are missing or invalid"
+ }
+ val list = BackupSerializer.getParcelableArray(content, "settings", KeyValuePair.CREATOR)
+ val filteredList = list.filterNot { it.key in DataStore.DEPRECATED_SETTING_KEYS }
+ val keys = HashSet(filteredList.size)
+ for (kv in filteredList) {
+ require(keys.add(kv.key)) { "Duplicate setting key in backup: ${kv.key}" }
+ kv.validate()
+ }
+ filteredList
+ } else null
+
+ data class DecodedProfiles(
+ val profiles: List,
+ val groups: List,
+ val routerGroups: List,
+ val routerMembers: List,
+ val routerSources: List,
+ val validGroupIds: Set,
+ val validRouterIds: Set,
+ val validProxyIds: Set,
+ )
+
+ val decodedProfileData = if (profile) {
+ require(content.has("profiles") && !content.isNull("profiles")) {
+ "Backup profiles are missing or invalid"
+ }
+ require(content.has("groups") && !content.isNull("groups")) {
+ "Backup groups are missing or invalid"
+ }
+ val profilesList = BackupSerializer.getParcelableArray(content, "profiles", ProxyEntity.CREATOR)
+ val groupsList = BackupSerializer.getParcelableArray(content, "groups", ProxyGroup.CREATOR)
+ val routerGroupsList = BackupSerializer.getParcelableArray(content, "routerGroups", RouterGroup.CREATOR)
+ val routerMembersList = BackupSerializer.getParcelableArray(content, "routerMembers", RouterMember.CREATOR)
+ val routerSourcesList = BackupSerializer.getParcelableArray(content, "routerSources", RouterGroupSource.CREATOR)
+
+ require(groupsList.all { g -> g.id > 0L && (g.ungrouped || !g.name.isNullOrBlank()) }) {
+ "Backup contains invalid or blank proxy groups"
+ }
+ require(routerGroupsList.all { it.id > 0L && it.stableTag.isNotBlank() }) {
+ "Backup contains invalid or blank router groups"
+ }
+
+ profilesList.forEach { it.requireBean() }
+ val groupIds = groupsList.mapTo(hashSetOf()) { it.id }
+ require(profilesList.all { it.groupId in groupIds }) {
+ "Backup contains profiles without a group"
+ }
+
+ val routerIds = routerGroupsList.mapTo(hashSetOf()) { it.id }
+ val proxyIds = profilesList.mapTo(hashSetOf()) { it.id }
+
+ require(routerMembersList.all { it.routerId in routerIds && it.proxyId in proxyIds }) {
+ "Backup contains router members referencing missing routers or profiles"
+ }
+ require(routerSourcesList.all { it.routerId in routerIds && it.sourceGroupId in groupIds }) {
+ "Backup contains router sources referencing missing routers or groups"
+ }
+
+ // R05: Validate frontProxy and landingProxy on groups
+ for (group in groupsList) {
+ if (group.frontProxy > 0L) {
+ require(group.frontProxy in proxyIds) {
+ "Group ${group.id} references missing frontProxy ${group.frontProxy}"
+ }
+ }
+ if (group.landingProxy > 0L) {
+ require(group.landingProxy in proxyIds) {
+ "Group ${group.id} references missing landingProxy ${group.landingProxy}"
+ }
+ }
+ }
+
+ // R05: Validate ChainBean references and cycles
+ val chainEdges = HashMap>()
+ for (proxy in profilesList) {
+ val bean = proxy.requireBean()
+ if (bean is io.nekohasekai.sagernet.fmt.internal.ChainBean) {
+ require(bean.proxies.isNotEmpty()) {
+ "Chain proxy ${proxy.id} has empty proxy list"
+ }
+ for (targetId in bean.proxies) {
+ require(targetId in proxyIds) {
+ "Chain proxy ${proxy.id} references missing proxy $targetId"
+ }
+ }
+ chainEdges[proxy.id] = bean.proxies
+ }
+ }
+ val visitedChainNodes = HashSet()
+ val callChainStack = HashSet()
+ fun checkChainCycle(node: Long) {
+ if (node in callChainStack) {
+ throw IllegalArgumentException("Cyclic chain reference detected involving proxy $node")
+ }
+ if (node in visitedChainNodes) return
+ visitedChainNodes.add(node)
+ callChainStack.add(node)
+ for (target in chainEdges[node].orEmpty()) {
+ checkChainCycle(target)
+ }
+ callChainStack.remove(node)
+ }
+ for (node in chainEdges.keys) {
+ checkChainCycle(node)
+ }
+
+ DecodedProfiles(
+ profiles = profilesList,
+ groups = groupsList,
+ routerGroups = routerGroupsList,
+ routerMembers = routerMembersList,
+ routerSources = routerSourcesList,
+ validGroupIds = groupIds,
+ validRouterIds = routerIds,
+ validProxyIds = proxyIds,
+ )
+ } else null
+
+ val decodedRules = if (rule) {
+ require(content.has("rules") && !content.isNull("rules")) {
+ "Backup rules are missing or invalid"
+ }
+ val routerReferences = BackupSerializer.getRouterRuleReferences(content)
+ val routerRuleRefList = BackupSerializer.getRouterRuleReferenceList(content).associateBy { it.ruleId }
+ val rulesList = BackupSerializer.getParcelableArray(content, "rules") {
+ ParcelizeBridge.createRule(it)
+ }.map { imported ->
+ val routerGroupId = routerReferences[imported.id] ?: 0L
+ imported.copy(routerGroupId = routerGroupId)
+ }
+
+ if (decodedProfileData != null) {
+ BackupSerializer.validateRuleReferences(rulesList, decodedProfileData.validRouterIds, decodedProfileData.validProxyIds)
+ val backupRouterTags = decodedProfileData.routerGroups.associate { it.id to it.stableTag }
+ for (ruleItem in rulesList) {
+ if (ruleItem.routerGroupId > 0L) {
+ val ref = routerRuleRefList[ruleItem.id]
+ if (ref?.routerStableTag != null) {
+ val actualTag = backupRouterTags[ruleItem.routerGroupId]
+ require(actualTag == ref.routerStableTag) {
+ "Rule ${ruleItem.id} references router group ${ruleItem.routerGroupId} with mismatched tag: expected ${ref.routerStableTag}, actual $actualTag"
+ }
+ }
+ }
+ }
+ } else {
+ // R03: Partial restore "只恢复规则" (Rules only, local database profiles kept)
+ val localRouters = SagerDatabase.routerGroupDao.all().associate { it.id to it.stableTag }
+ val localProxies = SagerDatabase.proxyDao.getAll().associate { it.id to it.routerStableId() }
+ val backupRouters = if (content.has("routerGroups") && !content.isNull("routerGroups")) {
+ runCatching {
+ BackupSerializer.getParcelableArray(content, "routerGroups", RouterGroup.CREATOR).associate { it.id to it.stableTag }
+ }.getOrNull().orEmpty()
+ } else emptyMap()
+
+ // R03 fix: read the stable-identity index emitted by the exporter when
+ // profiles were not included. Falls back to inline profiles if present.
+ val backupOutboundStableIds: Map =
+ BackupSerializer.getRuleOutboundStableIds(content).ifEmpty {
+ // Legacy fallback: backup was produced with profiles section
+ if (content.has("profiles") && !content.isNull("profiles")) {
+ runCatching {
+ BackupSerializer.getParcelableArray(content, "profiles", ProxyEntity.CREATOR)
+ .associate { it.id to it.routerStableId() }
+ }.getOrNull().orEmpty()
+ } else emptyMap()
+ }
+
+ for (ruleItem in rulesList) {
+ if (ruleItem.routerGroupId > 0L) {
+ val expectedTag = routerRuleRefList[ruleItem.id]?.routerStableTag
+ ?: backupRouters[ruleItem.routerGroupId]
+ require(expectedTag != null) {
+ "Cannot verify stable identity for router group ${ruleItem.routerGroupId} referenced by rule ${ruleItem.id}"
+ }
+ val localTag = localRouters[ruleItem.routerGroupId]
+ require(localTag != null && localTag == expectedTag) {
+ "Rule ${ruleItem.id} references router group ${ruleItem.routerGroupId} whose identity does not match local router (expected $expectedTag, found $localTag)"
+ }
+ } else if (ruleItem.outbound > 0L) {
+ // R03 fix: use the stable ID from ruleOutboundRefs instead of requiring profiles
+ val expectedStableId = backupOutboundStableIds[ruleItem.id]
+ ?: backupOutboundStableIds[ruleItem.outbound]
+ require(expectedStableId != null) {
+ "Cannot verify stable identity for profile ${ruleItem.outbound} referenced by rule ${ruleItem.id}: export does not contain profile identity information"
+ }
+ val localStableId = localProxies[ruleItem.outbound]
+ require(localStableId != null && localStableId == expectedStableId) {
+ "Rule ${ruleItem.id} references profile ${ruleItem.outbound} whose identity does not match local profile"
+ }
+ }
+ }
+ }
+ rulesList
+ } else null
+
+ // R02: Partial restore "只恢复配置" (Profiles only, local rules kept)
+ if (decodedProfileData != null && decodedRules == null) {
+ val existingRules = SagerDatabase.rulesDao.allRules()
+ val incomingRouterTags = decodedProfileData.routerGroups.associate { it.id to it.stableTag }
+ val incomingProxyTags = decodedProfileData.profiles.associate { it.id to it.routerStableId() }
+ val localRouters = SagerDatabase.routerGroupDao.all().associate { it.id to it.stableTag }
+ val localProxies = SagerDatabase.proxyDao.getAll().associate { it.id to it.routerStableId() }
+
+ for (rule in existingRules) {
+ if (rule.routerGroupId > 0L) {
+ val localTag = localRouters[rule.routerGroupId]
+ require(localTag != null) {
+ "Existing rule ${rule.id} references missing local router ${rule.routerGroupId}"
+ }
+ val incomingTag = incomingRouterTags[rule.routerGroupId]
+ require(incomingTag != null && incomingTag == localTag) {
+ "Existing rule ${rule.id} references router ${rule.routerGroupId} whose identity does not match incoming configuration (local: $localTag, incoming: $incomingTag)"
+ }
+ } else if (rule.outbound > 0L) {
+ val localStableId = localProxies[rule.outbound]
+ require(localStableId != null) {
+ "Existing rule ${rule.id} references missing local profile ${rule.outbound}"
+ }
+ val incomingStableId = incomingProxyTags[rule.outbound]
+ require(incomingStableId != null && incomingStableId == localStableId) {
+ "Existing rule ${rule.id} references profile ${rule.outbound} whose identity does not match incoming configuration"
+ }
+ }
+ }
}
- SagerDatabase.proxyDao.reset()
- SagerDatabase.proxyDao.insert(profiles)
-
- val groups = mutableListOf()
- val jsonGroups = content.getJSONArray("groups")
- for (i in 0 until jsonGroups.length()) {
- val data = Util.b64Decode(jsonGroups[i] as String)
- val parcel = Parcel.obtain()
- parcel.unmarshall(data, 0, data.size)
- parcel.setDataPosition(0)
- groups.add(ProxyGroup.CREATOR.createFromParcel(parcel))
- parcel.recycle()
+
+ SagerDatabase.instance.runInTransaction {
+ if (decodedProfileData != null) {
+ SagerDatabase.routerGroupSourceDao.reset()
+ SagerDatabase.routerMemberDao.reset()
+ SagerDatabase.routerGroupDao.reset()
+ SagerDatabase.proxyDao.reset()
+ SagerDatabase.groupDao.reset()
+
+ SagerDatabase.groupDao.insert(decodedProfileData.groups)
+ SagerDatabase.proxyDao.insert(decodedProfileData.profiles)
+ if (decodedProfileData.routerGroups.isNotEmpty()) {
+ SagerDatabase.routerGroupDao.insert(decodedProfileData.routerGroups)
+ }
+ if (decodedProfileData.routerMembers.isNotEmpty()) {
+ SagerDatabase.routerMemberDao.insert(decodedProfileData.routerMembers)
+ }
+ if (decodedProfileData.routerSources.isNotEmpty()) {
+ SagerDatabase.routerGroupSourceDao.insert(decodedProfileData.routerSources)
+ }
+ SagerDatabase.routerGroupDao.clearInvalidSelections()
+ }
+
+ if (decodedRules != null) {
+ SagerDatabase.rulesDao.reset()
+ SagerDatabase.rulesDao.insert(decodedRules)
+ }
}
- SagerDatabase.groupDao.reset()
- SagerDatabase.groupDao.insert(groups)
- }
- if (rule && content.has("rules")) {
- val rules = mutableListOf()
- val jsonRules = content.getJSONArray("rules")
- for (i in 0 until jsonRules.length()) {
- val data = Util.b64Decode(jsonRules[i] as String)
- val parcel = Parcel.obtain()
- parcel.unmarshall(data, 0, data.size)
- parcel.setDataPosition(0)
- rules.add(ParcelizeBridge.createRule(parcel))
- parcel.recycle()
+ if (decodedProfileData != null) {
+ GroupManager.cleanupDanglingRouterMembers()
}
- SagerDatabase.rulesDao.reset()
- SagerDatabase.rulesDao.insert(rules)
- }
- if (setting && content.has("settings")) {
- val settings = mutableListOf()
- val jsonSettings = content.getJSONArray("settings")
- for (i in 0 until jsonSettings.length()) {
- val data = Util.b64Decode(jsonSettings[i] as String)
- val parcel = Parcel.obtain()
- parcel.unmarshall(data, 0, data.size)
- parcel.setDataPosition(0)
- settings.add(KeyValuePair.CREATOR.createFromParcel(parcel))
- parcel.recycle()
+ if (decodedSettings != null) {
+ PublicDatabase.instance.runInTransaction {
+ PublicDatabase.kvPairDao.reset()
+ PublicDatabase.kvPairDao.insert(decodedSettings)
+ }
}
- PublicDatabase.kvPairDao.reset()
- PublicDatabase.kvPairDao.insert(settings)
}
}
private fun showMessage(message: String) {
MessageStore.showMessage(message)
}
-
private fun showMessage(@StringRes resId: Int) {
MessageStore.showMessage(requireActivity(), resId)
}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt
index a6734cd9fa..da1b021a1b 100644
--- a/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt
+++ b/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt
@@ -114,6 +114,7 @@ import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.isActive
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
@@ -139,6 +140,10 @@ import java.util.zip.ZipInputStream
import kotlin.collections.set
import androidx.appcompat.app.AlertDialog
import io.nekohasekai.sagernet.database.SubscriptionBean
+import io.nekohasekai.sagernet.database.RouterGroup
+import io.nekohasekai.sagernet.database.RouterGroupRepository
+import io.nekohasekai.sagernet.route.RouterRuntimeSelection
+import io.nekohasekai.sagernet.route.UrlTestTargetResolver
import kotlin.math.abs
class ConfigurationFragment @JvmOverloads constructor(
@@ -260,6 +265,89 @@ class ConfigurationFragment @JvmOverloads constructor(
private fun isSelectedProfile(profileId: Long) = selectedProxySnapshot == profileId
+ /**
+ * In router-group view mode each tab has its own selected proxy. We cache routerId->selectedProxyId
+ * so that [ConfigurationHolder.bindProfileState] can determine selection without a DB call.
+ */
+ @Volatile
+ private var routerGroupSelectionSnapshot: Map = emptyMap()
+
+ @Volatile
+ private var routerGroupRuntimeSelectionSnapshot: Map = emptyMap()
+
+ /** Returns true when [profileId] is the selected node inside [routerGroupId]. */
+ fun isSelectedProfileInRouterGroup(routerGroup: RouterGroup, profileId: Long): Boolean {
+ val selectedId = if (routerGroup.mode == RouterGroup.MODE_URL_TEST) {
+ routerGroupRuntimeSelectionSnapshot[routerGroup.id]
+ } else {
+ routerGroupSelectionSnapshot[routerGroup.id]
+ }
+ return selectedId == profileId
+ }
+
+ private fun selectedProfileInRouterGroup(routerGroup: RouterGroup): Long? {
+ return if (routerGroup.mode == RouterGroup.MODE_URL_TEST) {
+ routerGroupRuntimeSelectionSnapshot[routerGroup.id]
+ } else {
+ routerGroupSelectionSnapshot[routerGroup.id]
+ }
+ }
+
+ fun updateRuntimeUrlTestSelections(pairs: LongArray) {
+ val next = RouterRuntimeSelection.toMap(pairs)
+ if (next == routerGroupRuntimeSelectionSnapshot) return
+ val changedIds = (routerGroupRuntimeSelectionSnapshot.values + next.values)
+ .filter { it > 0L }
+ .toSet()
+ routerGroupRuntimeSelectionSnapshot = next
+ if (!::adapter.isInitialized) return
+ adapter.groupFragments.values.forEach { fragment ->
+ fragment.adapter?.refreshProfileState(changedIds)
+ }
+ }
+
+ /** Called from [selectProfileInRouterGroup] to refresh the snapshot after a selection change. */
+ private fun updateRouterGroupSelectionSnapshot(routerGroupId: Long, proxyId: Long) {
+ routerGroupSelectionSnapshot = routerGroupSelectionSnapshot + (routerGroupId to proxyId)
+ }
+
+ /** Refreshes the router-group selection snapshot from the DB on the background. */
+ fun refreshRouterGroupSelections() {
+ if (!::adapter.isInitialized) return
+ val currentAdapter = adapter
+ runOnDefaultDispatcher {
+ val groups = RouterGroupRepository.all()
+ val snapshot = groups.associate { it.id to it.selectedProxyId }
+ onMainDispatcher {
+ if (!isCurrentGroupPagerAdapter(currentAdapter) ||
+ viewLifecycleOwnerLiveData.value == null || !currentAdapter.inRouterGroupMode
+ ) return@onMainDispatcher
+ if (groups.isEmpty()) {
+ currentAdapter.reload()
+ return@onMainDispatcher
+ }
+ routerGroupSelectionSnapshot = snapshot
+ val selectedRouterId = currentAdapter.routerGroupList
+ .getOrNull(groupPager.currentItem)?.id
+ currentAdapter.routerGroupList = ArrayList(groups)
+ val groupsById = groups.associateBy { it.id }
+ currentAdapter.groupFragments.keys.retainAll(groupsById.keys.map { -it }.toSet())
+ // Trigger a UI refresh for all visible router-group pages
+ currentAdapter.groupFragments.values.forEach { frag ->
+ frag.routerGroup = groupsById[frag.routerGroup?.id] ?: return@forEach
+ frag.adapter?.notifyDataSetChanged()
+ }
+ currentAdapter.notifyDataSetChanged()
+ groups.indexOfFirst { it.id == selectedRouterId }.takeIf { it >= 0 }?.let {
+ groupPager.setCurrentItem(it, false)
+ }
+ val hideTab = groups.size < 2
+ tabLayout.isGone = hideTab
+ toolbar.elevation = if (hideTab) 0F else dp2px(4).toFloat()
+ }
+ }
+ }
+
private fun isCurrentProfile(profileId: Long) = currentProfileSnapshot == profileId
private fun isCurrentGroupPagerAdapter(candidate: GroupPagerAdapter): Boolean {
@@ -330,6 +418,13 @@ class ConfigurationFragment @JvmOverloads constructor(
if (!select) {
toolbar.inflateMenu(R.menu.add_profile_menu)
toolbar.menu.findItem(R.id.action_global_mode)?.isChecked = DataStore.globalMode
+ // Set initial title to '代理' (Proxy)
+ toolbar.setTitle(R.string.route_proxy)
+ // Set initial label for the view-switch menu item
+ toolbar.menu.findItem(R.id.action_switch_group_view)?.setTitle(
+ if (DataStore.viewModeRouterGroups) R.string.switch_to_subscription_view
+ else R.string.switch_to_router_group_view
+ )
toolbar.setOnMenuItemClickListener(this)
} else {
toolbar.setTitle(titleRes)
@@ -361,8 +456,15 @@ class ConfigurationFragment @JvmOverloads constructor(
groupPager.offscreenPageLimit = 2
TabLayoutMediator(tabLayout, groupPager) { tab, position ->
- if (adapter.groupList.size > position) {
- tab.text = adapter.groupList[position].displayName()
+ val rgMode = adapter.inRouterGroupMode
+ if (rgMode) {
+ if (adapter.routerGroupList.size > position) {
+ tab.text = adapter.routerGroupList[position].name
+ }
+ } else {
+ if (adapter.groupList.size > position) {
+ tab.text = adapter.groupList[position].displayName()
+ }
}
tab.view.setOnLongClickListener { // clear toast
true
@@ -401,6 +503,13 @@ class ConfigurationFragment @JvmOverloads constructor(
DataStore.profileCacheStore.registerChangeListener(this)
}
+ override fun onResume() {
+ super.onResume()
+ if (::adapter.isInitialized && adapter.inRouterGroupMode) {
+ refreshRouterGroupSelections()
+ }
+ }
+
override fun onPrepareOptionsMenu(menu: Menu) {
menu.findItem(R.id.action_global_mode)?.isChecked = DataStore.globalMode
super.onPrepareOptionsMenu(menu)
@@ -493,7 +602,12 @@ class ConfigurationFragment @JvmOverloads constructor(
suspend fun import(proxies: List) {
val targetId = DataStore.selectedGroupForImport()
for (proxy in proxies) {
- ProfileManager.createProfile(targetId, proxy)
+ ProfileManager.createProfile(targetId, proxy, reconcile = false)
+ }
+ if (RouterGroupRepository.all().isNotEmpty()) {
+ runCatching {
+ GroupManager.reconcileRouterMembers(GroupManager.snapshotRouterMembers())
+ }
}
onMainDispatcher {
DataStore.editingGroup = targetId
@@ -863,6 +977,32 @@ class ConfigurationFragment @JvmOverloads constructor(
urlTest()
}
+ R.id.action_switch_group_view -> {
+ // Toggle between router-groups view and subscription groups view
+ val hasRouterGroups = runCatching {
+ io.nekohasekai.sagernet.database.RouterGroupRepository.all().isNotEmpty()
+ }.getOrDefault(false)
+ if (!hasRouterGroups) {
+ snackbar(getString(R.string.router_empty_title)).show()
+ } else {
+ DataStore.viewModeRouterGroups = !DataStore.viewModeRouterGroups
+ // Update the menu item title to reflect the new state
+ val newTitle = if (DataStore.viewModeRouterGroups) {
+ R.string.switch_to_subscription_view
+ } else {
+ R.string.switch_to_router_group_view
+ }
+ toolbar.menu.findItem(R.id.action_switch_group_view)?.setTitle(newTitle)
+ adapter.reload(now = true)
+ }
+ return true
+ }
+
+ R.id.action_manage_router_groups -> {
+ startActivity(android.content.Intent(requireContext(), RouterGroupListActivity::class.java))
+ return true
+ }
+
R.id.action_global_mode -> {
item.isChecked = !item.isChecked
DataStore.globalMode = item.isChecked
@@ -1142,11 +1282,33 @@ class ConfigurationFragment @JvmOverloads constructor(
val test = TestDialog()
val dialog = test.builder.show()
val testJobs = mutableListOf()
- val group = DataStore.currentGroup()
+ val routerGroup = if (adapter.inRouterGroupMode) {
+ adapter.routerGroupList.getOrNull(groupPager.currentItem)
+ } else {
+ null
+ }
+ val group = if (routerGroup == null) DataStore.currentGroup() else null
+ val groupName = routerGroup?.name ?: group!!.displayName()
val mainJob = runOnDefaultDispatcher {
- val profilesList = SagerDatabase.proxyDao.getByGroup(group.id)
+ val profilesList = UrlTestTargetResolver.resolve(
+ routerGroupId = routerGroup?.id,
+ loadRouterTargets = { routerId ->
+ val memberIds = SagerDatabase.routerMemberDao.getByRouter(routerId)
+ .map { it.proxyId }
+ val entities = SagerDatabase.proxyDao.getEntities(memberIds).associateBy { it.id }
+ memberIds.mapNotNull { entities[it] }
+ },
+ loadNormalGroupTargets = {
+ SagerDatabase.proxyDao.getByGroup(group!!.id)
+ },
+ )
test.proxyN = profilesList.size
+ if (routerGroup != null && profilesList.isEmpty()) {
+ runOnMainDispatcher {
+ snackbar(getString(R.string.profile_empty)).show()
+ }
+ }
val profiles = ConcurrentLinkedQueue(profilesList)
repeat(DataStore.connectionTestConcurrent) {
testJobs.add(launch(Dispatchers.IO) {
@@ -1199,7 +1361,7 @@ class ConfigurationFragment @JvmOverloads constructor(
test.dialogStatus.set(1)
test.notification = ConnectionTestNotification(
dialog.context,
- "[${group.displayName()}] ${getString(R.string.connection_test)}"
+ "[$groupName] ${getString(R.string.connection_test)}"
)
dialog.hide()
}
@@ -1211,6 +1373,13 @@ class ConfigurationFragment @JvmOverloads constructor(
var selectedGroupIndex = 0
var groupList: ArrayList = ArrayList()
+
+ /** Router groups shown when [inRouterGroupMode] is true. */
+ var routerGroupList: ArrayList = ArrayList()
+
+ /** True when the user has switched to the router-group view. */
+ var inRouterGroupMode: Boolean = false
+
var groupFragments: HashMap = HashMap()
private val reloadGeneration = AtomicLong()
@@ -1222,58 +1391,116 @@ class ConfigurationFragment @JvmOverloads constructor(
}
runOnDefaultDispatcher {
- var newGroupList = ArrayList(SagerDatabase.groupDao.allGroups())
- if (newGroupList.isEmpty()) {
- SagerDatabase.groupDao.createGroup(ProxyGroup(ungrouped = true))
- newGroupList = ArrayList(SagerDatabase.groupDao.allGroups())
- }
- newGroupList.find { it.ungrouped }?.let {
- if (SagerDatabase.proxyDao.countByGroup(it.id) == 0L) {
- newGroupList.remove(it)
- }
- }
-
- if (generation != reloadGeneration.get()) return@runOnDefaultDispatcher
-
- var selectedGroup = selectedItem?.groupId ?: DataStore.currentGroupId()
- var newSelectedGroupIndex: Int? = null
- if (selectedGroup > 0L) {
- newSelectedGroupIndex = newGroupList.indexOfFirst { it.id == selectedGroup }
- } else if (groupList.size == 1) {
- selectedGroup = groupList[0].id
- if (DataStore.selectedGroup != selectedGroup) {
- DataStore.selectedGroup = selectedGroup
- }
- }
-
- val runFunc = if (now) activity?.let { it::runOnUiThread } else groupPager::post
- if (runFunc != null) {
- val reloadAdapter = this@GroupPagerAdapter
- runFunc {
- val viewOwner = viewLifecycleOwnerLiveData.value
- if (generation == reloadGeneration.get() && viewOwner != null &&
- isCurrentGroupPagerAdapter(reloadAdapter)
- ) {
- viewOwner.lifecycleScope.launch(Dispatchers.Main.immediate) {
- profileStateInitialized.await()
- if (generation != reloadGeneration.get() ||
- viewLifecycleOwnerLiveData.value !== viewOwner ||
- !isCurrentGroupPagerAdapter(reloadAdapter)
- ) {
- return@launch
- }
- refreshProfileState()
- newSelectedGroupIndex?.let { selectedGroupIndex = it }
- groupList = newGroupList
- notifyDataSetChanged()
- if (newSelectedGroupIndex != null) {
- groupPager.setCurrentItem(selectedGroupIndex, false)
+ // Determine view mode
+ val wantRouterMode = !select && DataStore.viewModeRouterGroups &&
+ RouterGroupRepository.all().isNotEmpty()
+
+ if (wantRouterMode) {
+ // ----- Router-group mode -----
+ if (SagerDatabase.routerMemberDao.all().isEmpty() && SagerDatabase.proxyDao.getAll().isNotEmpty()) {
+ runCatching {
+ GroupManager.reconcileRouterMembers(GroupManager.snapshotRouterMembers())
+ }
+ }
+ val newRouterList = ArrayList(RouterGroupRepository.all())
+
+ if (generation != reloadGeneration.get()) return@runOnDefaultDispatcher
+
+ // Refresh selection snapshot for all router groups
+ val selectionSnapshot = newRouterList.associate { it.id to it.selectedProxyId }
+
+ val runFunc = if (now) activity?.let { it::runOnUiThread } else groupPager::post
+ if (runFunc != null) {
+ val reloadAdapter = this@GroupPagerAdapter
+ runFunc {
+ val viewOwner = viewLifecycleOwnerLiveData.value
+ if (generation == reloadGeneration.get() && viewOwner != null &&
+ isCurrentGroupPagerAdapter(reloadAdapter)
+ ) {
+ viewOwner.lifecycleScope.launch(Dispatchers.Main.immediate) {
+ profileStateInitialized.await()
+ if (generation != reloadGeneration.get() ||
+ viewLifecycleOwnerLiveData.value !== viewOwner ||
+ !isCurrentGroupPagerAdapter(reloadAdapter)
+ ) {
+ return@launch
+ }
+ routerGroupSelectionSnapshot = selectionSnapshot
+ inRouterGroupMode = true
+ routerGroupList = newRouterList
+ val groupsById = newRouterList.associateBy { it.id }
+ groupFragments.keys.retainAll(groupsById.keys.map { -it }.toSet())
+ groupFragments.values.forEach { fragment ->
+ fragment.routerGroup = groupsById[fragment.routerGroup?.id]
+ }
+ notifyDataSetChanged()
+ groupPager.setCurrentItem(0, false)
+ val hideTab = routerGroupList.size < 2
+ tabLayout.isGone = hideTab
+ toolbar.elevation = if (hideTab) 0F else dp2px(4).toFloat()
+ // Refresh tab labels
+ tabLayout.invalidate()
}
- val hideTab = groupList.size < 2
- tabLayout.isGone = hideTab
- toolbar.elevation = if (hideTab) 0F else dp2px(4).toFloat()
- if (!select) {
- groupPager.registerOnPageChangeCallback(updateSelectedCallback)
+ }
+ }
+ }
+ } else {
+ // ----- Normal proxy-group mode -----
+ var newGroupList = ArrayList(SagerDatabase.groupDao.allGroups())
+ if (newGroupList.isEmpty()) {
+ SagerDatabase.groupDao.createGroup(ProxyGroup(ungrouped = true))
+ newGroupList = ArrayList(SagerDatabase.groupDao.allGroups())
+ }
+ newGroupList.find { it.ungrouped }?.let {
+ if (SagerDatabase.proxyDao.countByGroup(it.id) == 0L) {
+ newGroupList.remove(it)
+ }
+ }
+
+ if (generation != reloadGeneration.get()) return@runOnDefaultDispatcher
+
+ var selectedGroup = selectedItem?.groupId ?: DataStore.currentGroupId()
+ var newSelectedGroupIndex: Int? = null
+ if (selectedGroup > 0L) {
+ newSelectedGroupIndex = newGroupList.indexOfFirst { it.id == selectedGroup }
+ } else if (groupList.size == 1) {
+ selectedGroup = groupList[0].id
+ if (DataStore.selectedGroup != selectedGroup) {
+ DataStore.selectedGroup = selectedGroup
+ }
+ }
+
+ val runFunc = if (now) activity?.let { it::runOnUiThread } else groupPager::post
+ if (runFunc != null) {
+ val reloadAdapter = this@GroupPagerAdapter
+ runFunc {
+ val viewOwner = viewLifecycleOwnerLiveData.value
+ if (generation == reloadGeneration.get() && viewOwner != null &&
+ isCurrentGroupPagerAdapter(reloadAdapter)
+ ) {
+ viewOwner.lifecycleScope.launch(Dispatchers.Main.immediate) {
+ profileStateInitialized.await()
+ if (generation != reloadGeneration.get() ||
+ viewLifecycleOwnerLiveData.value !== viewOwner ||
+ !isCurrentGroupPagerAdapter(reloadAdapter)
+ ) {
+ return@launch
+ }
+ refreshProfileState()
+ inRouterGroupMode = false
+ newSelectedGroupIndex?.let { selectedGroupIndex = it }
+ groupList = newGroupList
+ groupFragments.keys.retainAll(newGroupList.map { it.id }.toSet())
+ notifyDataSetChanged()
+ if (newSelectedGroupIndex != null) {
+ groupPager.setCurrentItem(selectedGroupIndex, false)
+ }
+ val hideTab = groupList.size < 2
+ tabLayout.isGone = hideTab
+ toolbar.elevation = if (hideTab) 0F else dp2px(4).toFloat()
+ if (!select) {
+ groupPager.registerOnPageChangeCallback(updateSelectedCallback)
+ }
}
}
}
@@ -1287,28 +1514,48 @@ class ConfigurationFragment @JvmOverloads constructor(
}
override fun getItemCount(): Int {
- return groupList.size
+ return if (inRouterGroupMode) routerGroupList.size else groupList.size
}
override fun createFragment(position: Int): Fragment {
- return GroupFragment().apply {
- proxyGroup = groupList[position]
- groupFragments[proxyGroup.id] = this
- if (position == selectedGroupIndex) {
- selected = true
+ return if (inRouterGroupMode) {
+ GroupFragment().apply {
+ proxyGroup = ProxyGroup(ungrouped = true) // placeholder – not actually used in router mode
+ routerGroup = routerGroupList[position]
+ val key = -(routerGroupList[position].id) // negative to avoid collision with ProxyGroup ids
+ groupFragments[key] = this
+ selected = position == selectedGroupIndex
+ }
+ } else {
+ GroupFragment().apply {
+ proxyGroup = groupList[position]
+ groupFragments[proxyGroup.id] = this
+ if (position == selectedGroupIndex) {
+ selected = true
+ }
}
}
}
override fun getItemId(position: Int): Long {
- return groupList[position].id
+ return if (inRouterGroupMode) {
+ // Use negative IDs for router groups to avoid collision with proxy group IDs
+ -(routerGroupList[position].id)
+ } else {
+ groupList[position].id
+ }
}
override fun containsItem(itemId: Long): Boolean {
- return groupList.any { it.id == itemId }
+ return if (inRouterGroupMode) {
+ routerGroupList.any { -(it.id) == itemId }
+ } else {
+ groupList.any { it.id == itemId }
+ }
}
override suspend fun groupAdd(group: ProxyGroup) {
+ if (inRouterGroupMode) return
tabLayout.post {
groupList.add(group)
@@ -1322,6 +1569,7 @@ class ConfigurationFragment @JvmOverloads constructor(
}
override suspend fun groupRemoved(groupId: Long) {
+ if (inRouterGroupMode) return
val index = groupList.indexOfFirst { it.id == groupId }
if (index == -1) return
@@ -1332,6 +1580,7 @@ class ConfigurationFragment @JvmOverloads constructor(
}
override suspend fun groupUpdated(group: ProxyGroup) {
+ if (inRouterGroupMode) return
val index = groupList.indexOfFirst { it.id == group.id }
if (index == -1) return
@@ -1342,7 +1591,13 @@ class ConfigurationFragment @JvmOverloads constructor(
override suspend fun groupUpdated(groupId: Long) = Unit
+ override suspend fun routerGroupsUpdated() {
+ if (!inRouterGroupMode) return
+ refreshRouterGroupSelections()
+ }
+
override suspend fun onAdd(profile: ProxyEntity) {
+ if (inRouterGroupMode) return
if (groupList.find { it.id == profile.groupId } == null) {
DataStore.selectedGroup = profile.groupId
reload()
@@ -1354,6 +1609,7 @@ class ConfigurationFragment @JvmOverloads constructor(
override suspend fun onUpdated(profile: ProxyEntity, noTraffic: Boolean) = Unit
override suspend fun onRemoved(groupId: Long, profileId: Long) {
+ if (inRouterGroupMode) return
val group = groupList.find { it.id == groupId } ?: return
if (group.ungrouped && SagerDatabase.proxyDao.countByGroup(groupId) == 0L) {
reload()
@@ -1364,6 +1620,12 @@ class ConfigurationFragment @JvmOverloads constructor(
class GroupFragment : Fragment() {
lateinit var proxyGroup: ProxyGroup
+
+ /** Non-null when this fragment represents a tab in router-group view mode. */
+ var routerGroup: RouterGroup? = null
+
+ val inRouterGroupMode get() = routerGroup != null
+
var selected = false
override fun onCreateView(
@@ -1383,13 +1645,23 @@ class ConfigurationFragment @JvmOverloads constructor(
if (::proxyGroup.isInitialized) {
outState.putParcelable("proxyGroup", proxyGroup)
}
+ routerGroup?.let {
+ outState.putParcelable("routerGroup", it)
+ }
}
override fun onViewStateRestored(savedInstanceState: Bundle?) {
super.onViewStateRestored(savedInstanceState)
+ var restored = false
savedInstanceState?.getParcelable("proxyGroup")?.also {
proxyGroup = it
+ restored = true
+ }
+ savedInstanceState?.getParcelable("routerGroup")?.also {
+ routerGroup = it
+ }
+ if (restored && !::configurationListView.isInitialized) {
onViewCreated(requireView(), null)
}
}
@@ -1495,7 +1767,7 @@ class ConfigurationFragment @JvmOverloads constructor(
override fun onResume() {
super.onResume()
- if (::configurationListView.isInitialized && configurationListView.size == 0) {
+ if (::configurationListView.isInitialized && (adapter?.itemCount ?: 0) == 0) {
configurationListView.adapter = adapter
runOnDefaultDispatcher {
adapter?.reloadProfiles()
@@ -1509,6 +1781,8 @@ class ConfigurationFragment @JvmOverloads constructor(
fun checkOrderMenu() {
if (select) return
+ // Sort/order menu doesn't apply in router-group mode
+ if (inRouterGroupMode) return
val pf = requireParentFragment() as? ToolbarFragment ?: return
val menu = pf.toolbar.menu
@@ -1636,40 +1910,50 @@ class ConfigurationFragment @JvmOverloads constructor(
}
})
+ val adapterKey = if (inRouterGroupMode) {
+ routerGroup?.id?.let { -it }
+ } else {
+ proxyGroup.id
+ }
+ if (adapterKey != null) {
+ (parentFragment as? ConfigurationFragment)?.adapter?.groupFragments?.put(adapterKey, this)
+ }
+
if (!select) {
- undoManager = UndoSnackbarManager(activity as MainActivity, adapter!!)
+ if (!inRouterGroupMode) {
+ undoManager = UndoSnackbarManager(activity as MainActivity, adapter!!)
+ }
setupItemTouchHelper()
- setupBottomBarScrollDriver()
+ }
+
+ runOnDefaultDispatcher {
+ adapter?.reloadProfiles()
}
}
- private fun setupBottomBarScrollDriver() {
- val mainActivity = activity as? MainActivity ?: return
- configurationListView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
- override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
- if (dy != 0) mainActivity.driveBottomBar(dy)
- }
- })
+ override fun onDestroyView() {
+ val adapterKey = if (inRouterGroupMode) {
+ routerGroup?.id?.let { -it }
+ } else if (::proxyGroup.isInitialized) {
+ proxyGroup.id
+ } else null
+ if (adapterKey != null) {
+ val fragments = (parentFragment as? ConfigurationFragment)?.adapter?.groupFragments
+ if (fragments?.get(adapterKey) === this) fragments?.remove(adapterKey)
+ }
- val touchSlop = ViewConfiguration.get(requireContext()).scaledTouchSlop
- var lastRawY = 0f
- configurationListView.setOnTouchListener { recyclerView, event ->
- when (event.actionMasked) {
- MotionEvent.ACTION_DOWN -> lastRawY = event.rawY
- MotionEvent.ACTION_MOVE -> {
- val cannotScroll = !recyclerView.canScrollVertically(-1) &&
- !recyclerView.canScrollVertically(1)
- if (cannotScroll) {
- val fingerDy = event.rawY - lastRawY
- if (abs(fingerDy) >= touchSlop) {
- mainActivity.driveBottomBar(-fingerDy.toInt())
- lastRawY = event.rawY
- }
- }
- }
- }
- false
+ adapter?.let {
+ ProfileManager.removeListener(it)
+ GroupManager.removeListener(it)
+ }
+ if (::undoManager.isInitialized) {
+ undoManager.flush()
}
+ if (::itemTouchHelper.isInitialized) {
+ itemTouchHelper.attachToRecyclerView(null)
+ }
+ adapter = null
+ super.onDestroyView()
}
override fun onDestroy() {
@@ -1881,25 +2165,41 @@ class ConfigurationFragment @JvmOverloads constructor(
}
private val updated = HashSet()
+ private var routerOrderChanged = false
fun filter(name: String) {
if (name.isEmpty()) {
reloadProfiles()
return
}
- configurationIdList.clear()
val lower = name.lowercase()
- configurationIdList.addAll(configurationList.filter {
- it.value.displayName().lowercase().contains(lower) ||
- it.value.displayType().lowercase().contains(lower) ||
- it.value.displayAddress().lowercase().contains(lower)
- }.keys)
+ configurationIdList.clear()
+ // Runs on every keystroke: Map.filter() would allocate an intermediate
+ // map plus a key collection, and lowercase() one copy per field per
+ // profile. Iterating in place and comparing case-insensitively keeps it
+ // allocation free.
+ configurationList.forEach { (id, entity) ->
+ if (entity.displayName().contains(lower, ignoreCase = true) ||
+ entity.displayType().contains(lower, ignoreCase = true) ||
+ entity.displayAddress().contains(lower, ignoreCase = true)
+ ) {
+ configurationIdList.add(id)
+ }
+ }
notifyDataSetChanged()
}
fun move(from: Int, to: Int) {
if (from == to) return
+ if (inRouterGroupMode) {
+ val draggedItemId = configurationIdList.removeAt(from)
+ configurationIdList.add(to, draggedItemId)
+ routerOrderChanged = true
+ notifyItemMoved(from, to)
+ return
+ }
+
if (layoutManager is FixedGridLayoutManager) {
moveDualColumn(from, to)
} else {
@@ -1945,12 +2245,35 @@ class ConfigurationFragment @JvmOverloads constructor(
notifyItemMoved(from, to)
}
- fun commitMove() = runOnDefaultDispatcher {
- updated.forEach { SagerDatabase.proxyDao.updateProxy(it) }
+ fun commitMove() {
+ val rg = routerGroup
+ if (inRouterGroupMode && rg != null) {
+ if (!routerOrderChanged) return
+ routerOrderChanged = false
+ val orderedIds = ArrayList(configurationIdList)
+ runOnDefaultDispatcher {
+ SagerDatabase.routerMemberDao.updateOrders(rg.id, orderedIds)
+ if (DataStore.serviceState.started) {
+ SagerNet.reloadService()
+ }
+ onMainDispatcher {
+ if (layoutManager is FixedGridLayoutManager) {
+ notifyDataSetChanged()
+ }
+ }
+ }
+ return
+ }
+
+ val toUpdate = ArrayList(updated)
updated.clear()
- onMainDispatcher {
- if (layoutManager is FixedGridLayoutManager) {
- notifyDataSetChanged()
+ if (toUpdate.isEmpty()) return
+ runOnDefaultDispatcher {
+ toUpdate.forEach { SagerDatabase.proxyDao.updateProxy(it) }
+ onMainDispatcher {
+ if (layoutManager is FixedGridLayoutManager) {
+ notifyDataSetChanged()
+ }
}
}
}
@@ -2004,12 +2327,20 @@ class ConfigurationFragment @JvmOverloads constructor(
}
override suspend fun onAdd(profile: ProxyEntity) {
- if (profile.groupId != proxyGroup.id) return
+ if (!inRouterGroupMode) {
+ if (profile.groupId != proxyGroup.id) return
+ } else {
+ val rg = routerGroup ?: return
+ val isMember = SagerDatabase.routerMemberDao.getByRouter(rg.id).any { it.proxyId == profile.id }
+ if (!isMember) return
+ }
- configurationListView.post {
+ runOnMainDispatcher {
+ if (!isAdded || !::configurationListView.isInitialized) return@runOnMainDispatcher
if (::undoManager.isInitialized) {
undoManager.flush()
}
+ if (profile.id in configurationIdList) return@runOnMainDispatcher
val pos = itemCount
configurationList[profile.id] = profile
configurationIdList.add(profile.id)
@@ -2019,13 +2350,15 @@ class ConfigurationFragment @JvmOverloads constructor(
}
override suspend fun onUpdated(profile: ProxyEntity, noTraffic: Boolean) {
- if (profile.groupId != proxyGroup.id) return
+ if (!inRouterGroupMode && profile.groupId != proxyGroup.id) return
+ if (inRouterGroupMode && !configurationList.containsKey(profile.id)) return
if (noTraffic) {
(parentFragment as? ConfigurationFragment)?.refreshProfileState()
}
val index = configurationIdList.indexOf(profile.id)
if (index < 0) return
- configurationListView.post {
+ runOnMainDispatcher {
+ if (!isAdded || !::configurationListView.isInitialized) return@runOnMainDispatcher
if (::undoManager.isInitialized) {
undoManager.flush()
}
@@ -2044,7 +2377,7 @@ class ConfigurationFragment @JvmOverloads constructor(
cachedProfile.dirty != updatedProfile.dirty ||
cachedProfile.displayName() != updatedProfile.displayName()
configurationList[profile.id] = updatedProfile
- if (noTraffic && !contentChanged) return@post
+ if (noTraffic && !contentChanged) return@runOnMainDispatcher
val newHasMiddleRow = hasMiddleRow(updatedProfile)
val holder = layoutManager.findViewByPosition(index)
@@ -2079,11 +2412,13 @@ class ConfigurationFragment @JvmOverloads constructor(
}
override suspend fun onRemoved(groupId: Long, profileId: Long) {
- if (groupId != proxyGroup.id) return
+ if (!inRouterGroupMode && groupId != proxyGroup.id) return
+ if (inRouterGroupMode && !configurationList.containsKey(profileId)) return
val index = configurationIdList.indexOf(profileId)
if (index < 0) return
- configurationListView.post {
+ runOnMainDispatcher {
+ if (!isAdded || !::configurationListView.isInitialized) return@runOnMainDispatcher
configurationIdList.removeAt(index)
configurationList.remove(profileId)
notifyItemRemoved(index)
@@ -2106,18 +2441,34 @@ class ConfigurationFragment @JvmOverloads constructor(
reloadProfiles()
}
- fun reloadProfiles() {
- var newProfiles = SagerDatabase.proxyDao.getByGroup(proxyGroup.id)
- when (proxyGroup.order) {
- GroupOrder.BY_NAME -> {
- newProfiles = newProfiles.sortedBy { it.displayName() }
-
- }
+ override suspend fun routerGroupsUpdated() {
+ if (inRouterGroupMode) {
+ reloadProfiles()
+ }
+ }
- GroupOrder.BY_DELAY -> {
- newProfiles =
- newProfiles.sortedBy { if (it.status == 1) it.ping else 114514 }
+ fun reloadProfiles() {
+ val rg = routerGroup
+ val newProfiles: List
+ if (rg != null) {
+ // Router-group mode: load members from the router group
+ val memberIds = SagerDatabase.routerMemberDao.getByRouter(rg.id)
+ .sortedBy { it.userOrder }
+ .map { it.proxyId }
+ val entities = SagerDatabase.proxyDao.getEntities(memberIds).associateBy { it.id }
+ newProfiles = memberIds.mapNotNull { entities[it] }
+ } else {
+ // Normal proxy-group mode
+ var list = SagerDatabase.proxyDao.getByGroup(proxyGroup.id)
+ when (proxyGroup.order) {
+ GroupOrder.BY_NAME -> {
+ list = list.sortedBy { it.displayName() }
+ }
+ GroupOrder.BY_DELAY -> {
+ list = list.sortedBy { if (it.status == 1) it.ping else 114514 }
+ }
}
+ newProfiles = list
}
val newProfileMap = newProfiles.associateBy { it.id }
@@ -2125,12 +2476,19 @@ class ConfigurationFragment @JvmOverloads constructor(
var selectedProfileIndex = -1
- if (selected) {
+ if (rg != null) {
+ // In router-group mode, scroll to the currently selected node
+ selectedProfileIndex = newProfileIds.indexOf(
+ (parentFragment as? ConfigurationFragment)
+ ?.selectedProfileInRouterGroup(rg)
+ )
+ } else if (selected) {
val selectedProxy = selectedItem?.id ?: DataStore.selectedProxy
selectedProfileIndex = newProfileIds.indexOf(selectedProxy)
}
- configurationListView.post {
+ runOnMainDispatcher {
+ if (!isAdded || !::configurationListView.isInitialized) return@runOnMainDispatcher
configurationList.clear()
configurationList.putAll(newProfileMap)
configurationIdList.clear()
@@ -2142,7 +2500,6 @@ class ConfigurationFragment @JvmOverloads constructor(
} else if (newProfiles.isNotEmpty()) {
configurationListView.scrollTo(0, true)
}
-
}
}
@@ -2240,36 +2597,65 @@ class ConfigurationFragment @JvmOverloads constructor(
private fun selectProfile(proxyEntity: ProxyEntity) {
val pf = parentFragment as? ConfigurationFragment ?: return
- runOnDefaultDispatcher {
- var update: Boolean
- var lastSelected: Long
- profileAccess.withLock {
- update = DataStore.selectedProxy != proxyEntity.id
- lastSelected = DataStore.selectedProxy
- DataStore.selectedProxy = proxyEntity.id
- onMainDispatcher {
- pf.updateSelectedProxySnapshot(proxyEntity.id)
+ val rg = routerGroup
+ if (rg != null) {
+ // --- Router-group mode: select the node inside this router group ---
+ if (rg.mode != RouterGroup.MODE_SELECTOR) {
+ // URL_TEST groups auto-select; don't allow manual selection
+ return
+ }
+ runOnDefaultDispatcher {
+ try {
+ val updated = RouterGroupRepository.select(rg.id, proxyEntity.id)
+ // Update in-memory routerGroup reference
+ routerGroup = updated
+ onMainDispatcher {
+ pf.updateRouterGroupSelectionSnapshot(rg.id, proxyEntity.id)
+ adapter?.notifyDataSetChanged()
+ }
+ if (DataStore.serviceState.canStop) {
+ SagerNet.reloadService(routerTag = updated.stableTag, routerProxyId = proxyEntity.id)
+ }
+ } catch (e: Exception) {
+ Logs.w(e)
+ onMainDispatcher {
+ snackbar(e.readableMessage).show()
+ }
}
}
-
- if (update) {
- ProfileManager.postUpdate(lastSelected, noTraffic = true)
- if (DataStore.serviceState.canStop && reloadAccess.tryLock()) {
- SagerNet.reloadService()
- reloadAccess.unlock()
+ } else {
+ // --- Normal proxy-group mode ---
+ runOnDefaultDispatcher {
+ var update: Boolean
+ var lastSelected: Long
+ profileAccess.withLock {
+ update = DataStore.selectedProxy != proxyEntity.id
+ lastSelected = DataStore.selectedProxy
+ DataStore.selectedProxy = proxyEntity.id
+ onMainDispatcher {
+ pf.updateSelectedProxySnapshot(proxyEntity.id)
+ }
}
- } else if (SagerNet.isTv) {
- if (DataStore.serviceState.started) {
- SagerNet.stopService()
- } else {
- SagerNet.startService()
+
+ if (update) {
+ ProfileManager.postUpdate(lastSelected, noTraffic = true)
+ if (DataStore.serviceState.canStop && reloadAccess.tryLock()) {
+ SagerNet.reloadService()
+ reloadAccess.unlock()
+ }
+ } else if (SagerNet.isTv) {
+ if (DataStore.serviceState.started) {
+ SagerNet.stopService()
+ } else {
+ SagerNet.startService()
+ }
}
}
}
}
private fun removeProfile(proxyEntity: ProxyEntity) {
- if (select) return
+ if (select || inRouterGroupMode) return
val currentAdapter = adapter ?: return
val index = currentAdapter.configurationIdList.indexOf(proxyEntity.id)
if (index < 0) return
@@ -2422,16 +2808,17 @@ class ConfigurationFragment @JvmOverloads constructor(
val selectOrChain = select || proxyEntity.type == ProxyEntity.TYPE_CHAIN
val isDoubleColumn = layoutManager is FixedGridLayoutManager
-
+ val isRgMode = inRouterGroupMode
+
if (isDoubleColumn) {
editButton.isGone = true
shareLayout.isGone = true
removeButton.isGone = true
- doubleColumnMenuButton.isVisible = true
+ doubleColumnMenuButton.isVisible = !isRgMode
} else {
- shareLayout.isGone = selectOrChain
- editButton.isGone = select
- removeButton.isGone = select
+ shareLayout.isGone = selectOrChain || isRgMode
+ editButton.isGone = select || isRgMode
+ removeButton.isGone = select || isRgMode
doubleColumnMenuButton.isGone = true
}
@@ -2441,14 +2828,19 @@ class ConfigurationFragment @JvmOverloads constructor(
}
}
- val selected = pf.isSelectedProfile(proxyEntity.id)
+ val rg = routerGroup
+ val selected = if (rg != null) {
+ pf.isSelectedProfileInRouterGroup(rg, proxyEntity.id)
+ } else {
+ pf.isSelectedProfile(proxyEntity.id)
+ }
val started =
selected && DataStore.serviceState.started && pf.isCurrentProfile(proxyEntity.id)
editButton.isEnabled = !started
removeButton.isEnabled = !started
applySelected(selected)
- if (!(select || proxyEntity.type == ProxyEntity.TYPE_CHAIN)) {
+ if (!(select || proxyEntity.type == ProxyEntity.TYPE_CHAIN || isRgMode)) {
shareLayer.setBackgroundColor(Color.TRANSPARENT)
shareButton.setImageResource(R.drawable.ic_social_share)
shareButton.setColorFilter(Color.GRAY)
@@ -2466,7 +2858,12 @@ class ConfigurationFragment @JvmOverloads constructor(
return
}
val pf = parentFragment as? ConfigurationFragment ?: return
- val selected = pf.isSelectedProfile(proxyEntity.id)
+ val rg = routerGroup
+ val selected = if (rg != null) {
+ pf.isSelectedProfileInRouterGroup(rg, proxyEntity.id)
+ } else {
+ pf.isSelectedProfile(proxyEntity.id)
+ }
val started = selected && DataStore.serviceState.started &&
pf.isCurrentProfile(proxyEntity.id)
editButton.isEnabled = !started
diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/GroupFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/GroupFragment.kt
index 840234b549..718fa9d681 100644
--- a/app/src/main/java/io/nekohasekai/sagernet/ui/GroupFragment.kt
+++ b/app/src/main/java/io/nekohasekai/sagernet/ui/GroupFragment.kt
@@ -7,6 +7,7 @@ import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
+import android.widget.TextView
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.widget.PopupMenu
import androidx.appcompat.widget.Toolbar
@@ -37,6 +38,7 @@ class GroupFragment : ToolbarFragment(R.layout.layout_group),
lateinit var activity: MainActivity
lateinit var groupListView: RecyclerView
+ lateinit var routerSection: View
lateinit var layoutManager: LinearLayoutManager
lateinit var groupAdapter: GroupAdapter
lateinit var undoManager: UndoSnackbarManager
@@ -51,6 +53,12 @@ class GroupFragment : ToolbarFragment(R.layout.layout_group),
toolbar.setOnMenuItemClickListener(this)
groupListView = view.findViewById(R.id.group_list)
+ routerSection = view.findViewById(R.id.router_section)
+ routerSection.isVisible = true
+ routerSection.setOnClickListener {
+ startActivity(Intent(requireContext(), RouterGroupListActivity::class.java))
+ }
+ updateRouterSection()
layoutManager = FixedLinearLayoutManager(groupListView)
groupListView.layoutManager = layoutManager
groupAdapter = GroupAdapter()
@@ -544,4 +552,23 @@ class GroupFragment : ToolbarFragment(R.layout.layout_group),
}
}
-}
\ No newline at end of file
+ override fun onResume() {
+ super.onResume()
+ updateRouterSection()
+ }
+
+ private fun updateRouterSection() {
+ val routerSubtitle = view?.findViewById(R.id.router_subtitle) ?: return
+ runOnDefaultDispatcher {
+ val count = runCatching { SagerDatabase.routerGroupDao.all().size }.getOrDefault(0)
+ onMainDispatcher {
+ if (count > 0) {
+ routerSubtitle.text = getString(R.string.router_groups_card_summary_with_count, count)
+ } else {
+ routerSubtitle.text = getString(R.string.router_groups_card_summary)
+ }
+ }
+ }
+ }
+
+}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/LogcatFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/LogcatFragment.kt
index 2f276ee9fe..11fd8256f0 100644
--- a/app/src/main/java/io/nekohasekai/sagernet/ui/LogcatFragment.kt
+++ b/app/src/main/java/io/nekohasekai/sagernet/ui/LogcatFragment.kt
@@ -2,7 +2,6 @@ package io.nekohasekai.sagernet.ui
import android.annotation.SuppressLint
import android.graphics.Color
-import android.os.Build
import android.os.Bundle
import android.text.SpannableString
import android.text.Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
@@ -35,9 +34,7 @@ class LogcatFragment : ToolbarFragment(R.layout.layout_logcat),
binding = LayoutLogcatBinding.bind(view)
- if (Build.VERSION.SDK_INT >= 23) {
- binding.textview.breakStrategy = 0 // simple
- }
+ binding.textview.breakStrategy = 0 // simple
ViewCompat.setOnApplyWindowInsetsListener(binding.root, ListListener)
@@ -105,7 +102,7 @@ class LogcatFragment : ToolbarFragment(R.layout.layout_logcat),
R.id.action_send_logcat -> {
val context = requireContext()
runOnDefaultDispatcher {
- SendLog.sendLog(context, "NB4A")
+ SendLog.sendLog(context, "Asteria")
}
}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/MainActivity.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/MainActivity.kt
index 5b11495849..542a85ac86 100644
--- a/app/src/main/java/io/nekohasekai/sagernet/ui/MainActivity.kt
+++ b/app/src/main/java/io/nekohasekai/sagernet/ui/MainActivity.kt
@@ -7,7 +7,6 @@ import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
-import android.os.Build
import android.os.Bundle
import android.os.RemoteException
import android.view.KeyEvent
@@ -36,6 +35,7 @@ import io.nekohasekai.sagernet.database.GroupManager
import io.nekohasekai.sagernet.database.ProfileManager
import io.nekohasekai.sagernet.database.ProxyGroup
import io.nekohasekai.sagernet.database.SubscriptionBean
+import io.nekohasekai.sagernet.database.RouterGroupRepository
import io.nekohasekai.sagernet.database.preference.OnPreferenceDataStoreChangeListener
import io.nekohasekai.sagernet.databinding.LayoutMainBinding
import io.nekohasekai.sagernet.fmt.AbstractBean
@@ -102,7 +102,6 @@ class MainActivity : ThemedActivity(),
null
)
}
- binding.stats.setOnClickListener { if (DataStore.serviceState.connected) binding.stats.testConnection() }
setContentView(binding.root)
currentMainFragment =
@@ -120,22 +119,28 @@ class MainActivity : ThemedActivity(),
DataStore.configurationStore.registerChangeListener(this)
GroupManager.userInterface = GroupInterfaceAdapter(this)
+ runOnDefaultDispatcher {
+ if (RouterGroupRepository.all().isNotEmpty()) {
+ runCatching {
+ GroupManager.reconcileRouterMembers(GroupManager.snapshotRouterMembers())
+ }
+ }
+ }
+
if (intent?.action == Intent.ACTION_VIEW) {
onNewIntent(intent)
}
refreshNavMenu(DataStore.enableClashAPI)
- // sdk 33 notification
- if (Build.VERSION.SDK_INT >= 33) {
- val checkPermission =
- ContextCompat.checkSelfPermission(this@MainActivity, POST_NOTIFICATIONS)
- if (checkPermission != PackageManager.PERMISSION_GRANTED) {
- //动态申请
- ActivityCompat.requestPermissions(
- this@MainActivity, arrayOf(POST_NOTIFICATIONS), 0
- )
- }
+ // notification permission
+ val checkPermission =
+ ContextCompat.checkSelfPermission(this@MainActivity, POST_NOTIFICATIONS)
+ if (checkPermission != PackageManager.PERMISSION_GRANTED) {
+ //动态申请
+ ActivityCompat.requestPermissions(
+ this@MainActivity, arrayOf(POST_NOTIFICATIONS), 0
+ )
}
if (isPreview) {
@@ -371,6 +376,9 @@ class MainActivity : ThemedActivity(),
@SuppressLint("CommitTransaction")
fun displayFragment(fragment: ToolbarFragment) {
currentMainFragment = fragment
+ (fragment as? ConfigurationFragment)?.updateRuntimeUrlTestSelections(
+ runtimeUrlTestSelections
+ )
supportFragmentManager.beginTransaction()
.replace(R.id.fragment_holder, fragment)
.commitAllowingStateLoss()
@@ -384,14 +392,7 @@ class MainActivity : ThemedActivity(),
showWhenConnected: Boolean,
animate: Boolean,
) {
- val showControls = fragment is ConfigurationFragment || DataStore.showBottomBar
- binding.stats.useExternalScrollDriver = fragment is ConfigurationFragment
- binding.stats.syncMainControls(
- showControls,
- DataStore.serviceState,
- showWhenConnected,
- animate,
- )
+ val showControls = fragment is ConfigurationFragment
binding.fab.animate().cancel()
if (showControls) {
binding.fab.show()
@@ -413,10 +414,6 @@ class MainActivity : ThemedActivity(),
(fragment as? ConfigurationFragment)?.refreshProfileState()
}
- fun driveBottomBar(scrollDy: Int) {
- binding.stats.onListScrolled(scrollDy)
- }
-
fun displayFragmentWithId(@IdRes id: Int): Boolean {
when (id) {
R.id.nav_configuration -> {
@@ -427,18 +424,13 @@ class MainActivity : ThemedActivity(),
R.id.nav_route -> displayFragment(RouteFragment())
R.id.nav_settings -> displayFragment(SettingsFragment())
R.id.nav_traffic -> displayFragment(WebviewFragment())
- R.id.nav_tools -> displayFragment(ToolsFragment())
+ R.id.nav_tools -> displayFragment(BackupFragment())
R.id.nav_logcat -> displayFragment(LogcatFragment())
- R.id.nav_faq -> {
- launchCustomTab("https://matsuridayo.github.io/")
- return false
- }
-
R.id.nav_about -> displayFragment(AboutFragment())
else -> return false
}
- navigation.menu.findItem(id).isChecked = true
+ navigation.menu.findItem(id)?.isChecked = true
return true
}
@@ -449,10 +441,12 @@ class MainActivity : ThemedActivity(),
animateControls: Boolean = animate,
) {
DataStore.serviceState = state
+ if (state != BaseService.State.Connected) {
+ updateRuntimeUrlTestSelections(longArrayOf())
+ }
refreshConfigurationProfileState()
binding.fab.changeState(state, DataStore.serviceState, animate)
- binding.stats.changeState(state)
syncMainControls(
showWhenConnected = state == BaseService.State.Connected,
animate = animateControls,
@@ -474,13 +468,23 @@ class MainActivity : ThemedActivity(),
}
val connection = SagerConnection(SagerConnection.CONNECTION_ID_MAIN_ACTIVITY_FOREGROUND, true)
- override fun onServiceConnected(service: ISagerNetService) = changeState(
- try {
+ override fun onServiceConnected(service: ISagerNetService) {
+ val state = try {
BaseService.State.values()[service.state]
} catch (_: RemoteException) {
BaseService.State.Idle
}
- )
+ changeState(state)
+ updateRuntimeUrlTestSelections(
+ if (state == BaseService.State.Connected) {
+ runCatching { service.currentUrlTestSelections }.getOrDefault(longArrayOf())
+ } else {
+ longArrayOf()
+ }
+ )
+ }
+
+ private var runtimeUrlTestSelections = longArrayOf()
override fun onServiceDisconnected() = changeState(BaseService.State.Idle)
override fun onBinderDied() {
@@ -495,7 +499,16 @@ class MainActivity : ThemedActivity(),
// may NOT called when app is in background
// ONLY do UI update here, write DB in bg process
override fun cbSpeedUpdate(stats: SpeedDisplayData) {
- binding.stats.updateSpeed(stats.txRateProxy, stats.rxRateProxy)
+ updateRuntimeUrlTestSelections(stats.urlTestSelections)
+ }
+
+ private fun updateRuntimeUrlTestSelections(selections: LongArray) {
+ runtimeUrlTestSelections = selections.copyOf()
+ val fragment = currentMainFragment
+ ?: supportFragmentManager.findFragmentById(R.id.fragment_holder)
+ (fragment as? ConfigurationFragment)?.updateRuntimeUrlTestSelections(
+ runtimeUrlTestSelections
+ )
}
override suspend fun cbTrafficUpdate(data: TrafficDataBatch) {
@@ -516,10 +529,11 @@ class MainActivity : ThemedActivity(),
override fun onPreferenceDataStoreChanged(store: PreferenceDataStore, key: String) {
when (key) {
Key.SERVICE_MODE -> onBinderDied()
- Key.SHOW_BOTTOM_BAR -> syncMainControls(
- showWhenConnected = DataStore.showBottomBar,
- animate = true,
- )
+ Key.SHOW_PROFILE_IN_NOTIFICATION -> {
+ if (DataStore.serviceState.canStop) {
+ SagerNet.reloadService()
+ }
+ }
Key.PROXY_APPS, Key.BYPASS_MODE, Key.INDIVIDUAL -> {
if (DataStore.serviceState.canStop) {
snackbar(getString(R.string.need_reload)).setAction(R.string.apply) {
diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/NetworkFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/NetworkFragment.kt
deleted file mode 100644
index b8bb941451..0000000000
--- a/app/src/main/java/io/nekohasekai/sagernet/ui/NetworkFragment.kt
+++ /dev/null
@@ -1,23 +0,0 @@
-package io.nekohasekai.sagernet.ui
-
-import android.content.Intent
-import android.os.Bundle
-import android.view.View
-import io.nekohasekai.sagernet.R
-import io.nekohasekai.sagernet.databinding.LayoutNetworkBinding
-import io.nekohasekai.sagernet.ktx.app
-
-class NetworkFragment : NamedFragment(R.layout.layout_network) {
-
- override fun name0() = app.getString(R.string.tools_network)
-
- override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
- super.onViewCreated(view, savedInstanceState)
-
- val binding = LayoutNetworkBinding.bind(view)
- binding.stunTest.setOnClickListener {
- startActivity(Intent(requireContext(), StunActivity::class.java))
- }
- }
-
-}
\ No newline at end of file
diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/QuickDisableShortcut.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/QuickDisableShortcut.kt
index 9571a75c83..cf84558e66 100644
--- a/app/src/main/java/io/nekohasekai/sagernet/ui/QuickDisableShortcut.kt
+++ b/app/src/main/java/io/nekohasekai/sagernet/ui/QuickDisableShortcut.kt
@@ -22,7 +22,6 @@ package io.nekohasekai.sagernet.ui
import android.app.Activity
import android.content.pm.ShortcutManager
-import android.os.Build
import android.os.Bundle
import androidx.core.content.getSystemService
import io.nekohasekai.sagernet.SagerNet
@@ -36,9 +35,7 @@ class QuickDisableShortcut : Activity(), SagerConnection.Callback {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
connection.connect(this, this)
- if (Build.VERSION.SDK_INT >= 25) {
- getSystemService()!!.reportShortcutUsed("disable")
- }
+ getSystemService()!!.reportShortcutUsed("disable")
}
override fun onServiceConnected(service: ISagerNetService) {
diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/QuickEnableShortcut.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/QuickEnableShortcut.kt
index 590868d186..af6afa009c 100644
--- a/app/src/main/java/io/nekohasekai/sagernet/ui/QuickEnableShortcut.kt
+++ b/app/src/main/java/io/nekohasekai/sagernet/ui/QuickEnableShortcut.kt
@@ -22,7 +22,6 @@ package io.nekohasekai.sagernet.ui
import android.app.Activity
import android.content.pm.ShortcutManager
-import android.os.Build
import android.os.Bundle
import androidx.core.content.getSystemService
import io.nekohasekai.sagernet.SagerNet
@@ -36,9 +35,7 @@ class QuickEnableShortcut : Activity(), SagerConnection.Callback {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
connection.connect(this, this)
- if (Build.VERSION.SDK_INT >= 25) {
- getSystemService()!!.reportShortcutUsed("enable")
- }
+ getSystemService()!!.reportShortcutUsed("enable")
}
override fun onServiceConnected(service: ISagerNetService) {
diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/RouteSettingsActivity.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/RouteSettingsActivity.kt
index 94e0b8f7d4..a721070a41 100644
--- a/app/src/main/java/io/nekohasekai/sagernet/ui/RouteSettingsActivity.kt
+++ b/app/src/main/java/io/nekohasekai/sagernet/ui/RouteSettingsActivity.kt
@@ -30,6 +30,7 @@ import io.nekohasekai.sagernet.database.ProfileManager
import io.nekohasekai.sagernet.database.RuleEntity
import io.nekohasekai.sagernet.database.SagerDatabase
import io.nekohasekai.sagernet.database.preference.OnPreferenceDataStoreChangeListener
+import io.nekohasekai.sagernet.fmt.serializeRouteOutboundChoice
import io.nekohasekai.sagernet.ktx.Logs
import io.nekohasekai.sagernet.ktx.app
import io.nekohasekai.sagernet.ktx.onMainDispatcher
@@ -68,7 +69,10 @@ class RouteSettingsActivity(
DataStore.routeProtocol = protocol
DataStore.routeRuleset = ruleset
DataStore.routeOutboundRule = outbound
- DataStore.routeOutbound = when (outbound) {
+ DataStore.routeOutboundRouter = routerGroupId
+ DataStore.routeOutbound = if (routerGroupId > 0L) {
+ OutboundPreference.VALUE_SELECT_ROUTER.toInt()
+ } else when (outbound) {
0L -> 0
-1L -> 1
-2L -> 2
@@ -88,12 +92,14 @@ class RouteSettingsActivity(
source = DataStore.routeSource
protocol = DataStore.routeProtocol
ruleset = DataStore.routeRuleset
- outbound = when (DataStore.routeOutbound) {
- 0 -> 0L
- 1 -> -1L
- 2 -> -2L
- else -> DataStore.routeOutboundRule
- }
+ val outboundChoice = serializeRouteOutboundChoice(
+ DataStore.routeOutbound,
+ DataStore.routeOutboundRule,
+ DataStore.routeOutboundRouter,
+ OutboundPreference.VALUE_SELECT_ROUTER.toInt(),
+ )
+ outbound = outboundChoice.outbound
+ routerGroupId = outboundChoice.routerGroupId
packages = DataStore.routePackages.split("\n").filter { it.isNotBlank() }.toSet()
if (DataStore.editingId == 0L) {
@@ -146,6 +152,17 @@ class RouteSettingsActivity(
apps.postUpdate()
}
+ val selectRouterGroup = registerForActivityResult(
+ ActivityResultContracts.StartActivityForResult()
+ ) { (resultCode, data) ->
+ if (resultCode == Activity.RESULT_OK) {
+ DataStore.routeOutboundRouter = data?.getLongExtra(
+ RouterGroupSelectActivity.EXTRA_ROUTER_ID, 0L
+ ) ?: 0L
+ outbound.value = OutboundPreference.VALUE_SELECT_ROUTER
+ }
+ }
+
lateinit var outbound: OutboundPreference
lateinit var apps: AppListPreference
@@ -165,6 +182,13 @@ class RouteSettingsActivity(
}
)
false
+ } else if (newValue.toString() == OutboundPreference.VALUE_SELECT_ROUTER) {
+ selectRouterGroup.launch(
+ Intent(this@RouteSettingsActivity, RouterGroupSelectActivity::class.java).apply {
+ putExtra(RouterGroupSelectActivity.EXTRA_SELECTED, DataStore.routeOutboundRouter)
+ }
+ )
+ false
} else {
true
}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/RouterGroupListActivity.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/RouterGroupListActivity.kt
new file mode 100644
index 0000000000..653cec43d3
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sagernet/ui/RouterGroupListActivity.kt
@@ -0,0 +1,27 @@
+package io.nekohasekai.sagernet.ui
+
+import android.os.Bundle
+import androidx.appcompat.widget.Toolbar
+import io.nekohasekai.sagernet.R
+
+class RouterGroupListActivity : ThemedActivity(R.layout.layout_settings_activity) {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ val toolbar = findViewById(R.id.toolbar)
+ setSupportActionBar(toolbar)
+ supportActionBar?.apply {
+ setTitle(R.string.router_groups_title)
+ setDisplayHomeAsUpEnabled(true)
+ }
+ if (savedInstanceState == null) {
+ supportFragmentManager.beginTransaction()
+ .replace(R.id.settings, RouterGroupListFragment())
+ .commit()
+ }
+ }
+
+ override fun onSupportNavigateUp(): Boolean {
+ finish()
+ return true
+ }
+}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/RouterGroupListFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/RouterGroupListFragment.kt
new file mode 100644
index 0000000000..abd0a93d6b
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sagernet/ui/RouterGroupListFragment.kt
@@ -0,0 +1,155 @@
+package io.nekohasekai.sagernet.ui
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import androidx.lifecycle.lifecycleScope
+import androidx.preference.Preference
+import androidx.preference.PreferenceCategory
+import androidx.preference.PreferenceFragmentCompat
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import io.nekohasekai.sagernet.R
+import io.nekohasekai.sagernet.SagerNet
+import io.nekohasekai.sagernet.database.DataStore
+import io.nekohasekai.sagernet.database.GroupManager
+import io.nekohasekai.sagernet.database.ProxyEntity
+import io.nekohasekai.sagernet.database.RouterGroup
+import io.nekohasekai.sagernet.database.RouterGroupRepository
+import io.nekohasekai.sagernet.database.RouterMember
+import io.nekohasekai.sagernet.database.SagerDatabase
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+class RouterGroupListFragment : PreferenceFragmentCompat() {
+ override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) = rebuild()
+
+ override fun onResume() {
+ super.onResume()
+ rebuild()
+ viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
+ runCatching {
+ GroupManager.reconcileRouterMembers(GroupManager.snapshotRouterMembers())
+ }
+ withContext(Dispatchers.Main) {
+ if (isAdded) rebuild()
+ }
+ }
+ }
+
+ fun rebuild() {
+ val context = requireContext()
+ val screen = preferenceManager.createPreferenceScreen(context)
+ val groups = RouterGroupRepository.all()
+
+ if (groups.isEmpty()) {
+ screen.addPreference(Preference(context).apply {
+ title = getString(R.string.router_empty_title)
+ summary = getString(R.string.router_empty_summary)
+ setIcon(R.drawable.ic_hardware_router)
+ setOnPreferenceClickListener {
+ startActivity(Intent(context, RouterGroupSettingsActivity::class.java))
+ true
+ }
+ })
+ } else {
+ val category = PreferenceCategory(context).apply {
+ title = getString(R.string.router_groups_title)
+ }
+ screen.addPreference(category)
+
+ // rebuild() runs on the main thread (onCreatePreferences / onResume), so the
+ // per-group lookups are batched here: one query for all members and one for
+ // all displayed nodes instead of two queries per router group.
+ val membersByRouter = SagerDatabase.routerMemberDao.all().groupBy { it.routerId }
+ val selectedIds = groups.mapNotNull { it.selectedProxyId.takeIf { id -> id > 0 } }
+ val selectedProxies: Map =
+ if (selectedIds.isEmpty()) emptyMap()
+ else SagerDatabase.proxyDao.getEntities(selectedIds).associateBy { it.id }
+
+ groups.forEach { group ->
+ category.addPreference(
+ group.toPreference(
+ context,
+ membersByRouter[group.id].orEmpty(),
+ selectedProxies[group.selectedProxyId],
+ )
+ )
+ }
+ }
+
+ preferenceScreen = screen
+ }
+
+ private fun RouterGroup.toPreference(
+ context: Context,
+ members: List,
+ selectedProxy: ProxyEntity?,
+ ): Preference = Preference(context).apply {
+ title = name.ifBlank { stableTag }
+ setIcon(R.drawable.ic_hardware_router)
+ val modeName = context.getString(
+ if (mode == RouterGroup.MODE_URL_TEST) R.string.router_mode_automatic
+ else R.string.router_mode_manual
+ )
+ val selectedName = selectedProxy?.displayName() ?: context.getString(R.string.router_no_selection)
+
+ summary = when {
+ !enabled -> getString(R.string.router_group_disabled)
+ lastError.isNotBlank() -> lastError
+ mode == RouterGroup.MODE_URL_TEST -> getString(R.string.router_status, modeName, members.size)
+ else -> "${getString(R.string.router_status, modeName, members.size)} • ${getString(R.string.router_current_node, selectedName)}"
+ }
+
+ setOnPreferenceClickListener {
+ if (mode == RouterGroup.MODE_SELECTOR && members.isNotEmpty()) {
+ showNodeSelectionDialog(this@toPreference, members)
+ } else {
+ startActivity(Intent(requireContext(), RouterGroupSettingsActivity::class.java).apply {
+ putExtra(RouterGroupSettingsActivity.EXTRA_ROUTER_ID, id)
+ })
+ }
+ true
+ }
+ }
+
+ private fun showNodeSelectionDialog(group: RouterGroup, members: List) {
+ val proxies = SagerDatabase.proxyDao.getEntities(members.map { it.proxyId })
+ val proxyMap = proxies.associateBy { it.id }
+ val orderedProxies = members.mapNotNull { proxyMap[it.proxyId] }
+ if (orderedProxies.isEmpty()) {
+ startActivity(Intent(requireContext(), RouterGroupSettingsActivity::class.java).apply {
+ putExtra(RouterGroupSettingsActivity.EXTRA_ROUTER_ID, group.id)
+ })
+ return
+ }
+ val items = orderedProxies.map { it.displayName() }.toTypedArray()
+ val currentIndex = orderedProxies.indexOfFirst { it.id == group.selectedProxyId }
+
+ MaterialAlertDialogBuilder(requireContext())
+ .setTitle(group.name.ifBlank { group.stableTag })
+ .setSingleChoiceItems(items, currentIndex) { dialog, which ->
+ val chosen = orderedProxies[which]
+ viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
+ runCatching {
+ RouterGroupRepository.select(group.id, chosen.id)
+ }.onSuccess { updated ->
+ if (DataStore.serviceState.started) {
+ SagerNet.reloadService(updated.stableTag, chosen.id)
+ }
+ }
+ withContext(Dispatchers.Main) {
+ rebuild()
+ }
+ }
+ dialog.dismiss()
+ }
+ .setNeutralButton(R.string.router_edit_group) { _, _ ->
+ startActivity(Intent(requireContext(), RouterGroupSettingsActivity::class.java).apply {
+ putExtra(RouterGroupSettingsActivity.EXTRA_ROUTER_ID, group.id)
+ })
+ }
+ .setNegativeButton(android.R.string.cancel, null)
+ .show()
+ }
+}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/RouterGroupSelectActivity.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/RouterGroupSelectActivity.kt
new file mode 100644
index 0000000000..b9c13e2976
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sagernet/ui/RouterGroupSelectActivity.kt
@@ -0,0 +1,58 @@
+package io.nekohasekai.sagernet.ui
+
+import android.app.Activity
+import android.content.Intent
+import android.os.Bundle
+import androidx.appcompat.widget.Toolbar
+import androidx.preference.Preference
+import androidx.preference.PreferenceFragmentCompat
+import io.nekohasekai.sagernet.R
+import io.nekohasekai.sagernet.database.RouterGroup
+import io.nekohasekai.sagernet.database.RouterGroupRepository
+import io.nekohasekai.sagernet.database.SagerDatabase
+
+class RouterGroupSelectActivity : ThemedActivity(R.layout.layout_settings_activity) {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setSupportActionBar(findViewById(R.id.toolbar))
+ supportActionBar?.apply {
+ setTitle(R.string.route_proxy_group)
+ setDisplayHomeAsUpEnabled(true)
+ }
+ if (savedInstanceState == null) {
+ supportFragmentManager.beginTransaction().replace(R.id.settings, PickerFragment()).commit()
+ }
+ }
+
+ override fun onSupportNavigateUp(): Boolean { finish(); return true }
+
+ class PickerFragment : PreferenceFragmentCompat() {
+ override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
+ val selected = requireActivity().intent.getLongExtra(EXTRA_SELECTED, 0L)
+ val screen = preferenceManager.createPreferenceScreen(requireContext())
+ RouterGroupRepository.all().filter { group ->
+ group.enabled && SagerDatabase.routerMemberDao.getByRouter(group.id).isNotEmpty()
+ }.forEach { group ->
+ screen.addPreference(Preference(requireContext()).apply {
+ title = group.name
+ summary = if (group.id == selected) getString(R.string.router_group_selected) else getString(
+ R.string.router_status,
+ getString(if (group.mode == RouterGroup.MODE_URL_TEST) R.string.router_mode_automatic else R.string.router_mode_manual),
+ SagerDatabase.routerMemberDao.getByRouter(group.id).size,
+ )
+ setOnPreferenceClickListener {
+ requireActivity().setResult(Activity.RESULT_OK, Intent().putExtra(EXTRA_ROUTER_ID, group.id))
+ requireActivity().finish()
+ true
+ }
+ })
+ }
+ preferenceScreen = screen
+ }
+ }
+
+ companion object {
+ const val EXTRA_SELECTED = "selected"
+ const val EXTRA_ROUTER_ID = "router_id"
+ }
+}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/RouterGroupSettingsActivity.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/RouterGroupSettingsActivity.kt
new file mode 100644
index 0000000000..840e1c7886
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sagernet/ui/RouterGroupSettingsActivity.kt
@@ -0,0 +1,259 @@
+package io.nekohasekai.sagernet.ui
+
+import android.os.Bundle
+import android.view.Menu
+import android.view.MenuItem
+import android.widget.Toast
+import androidx.appcompat.widget.Toolbar
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import androidx.preference.EditTextPreference
+import androidx.preference.ListPreference
+import androidx.preference.MultiSelectListPreference
+import androidx.preference.Preference
+import androidx.preference.PreferenceCategory
+import androidx.preference.PreferenceFragmentCompat
+import androidx.preference.SwitchPreferenceCompat
+import io.nekohasekai.sagernet.GroupType
+import io.nekohasekai.sagernet.R
+import io.nekohasekai.sagernet.SagerNet
+import io.nekohasekai.sagernet.database.DataStore
+import io.nekohasekai.sagernet.database.RouterDeleteResult
+import io.nekohasekai.sagernet.database.RouterGroup
+import io.nekohasekai.sagernet.database.RouterGroupDraft
+import io.nekohasekai.sagernet.database.RouterGroupRepository
+import io.nekohasekai.sagernet.database.RouterGroupValidationException
+import io.nekohasekai.sagernet.database.SagerDatabase
+import io.nekohasekai.sagernet.database.displayNameOrFallback
+import io.nekohasekai.sagernet.ktx.onMainDispatcher
+import io.nekohasekai.sagernet.ktx.runOnDefaultDispatcher
+import io.nekohasekai.sagernet.route.RouterFilterConfig
+
+class RouterGroupSettingsActivity : ThemedActivity(R.layout.layout_settings_activity) {
+ private val editor get() = supportFragmentManager.findFragmentById(R.id.settings) as? EditorFragment
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setSupportActionBar(findViewById(R.id.toolbar))
+ supportActionBar?.apply {
+ setTitle(R.string.router_group_settings)
+ setDisplayHomeAsUpEnabled(true)
+ }
+ if (savedInstanceState == null) {
+ supportFragmentManager.beginTransaction().replace(R.id.settings, EditorFragment()).commit()
+ }
+ }
+
+ override fun onCreateOptionsMenu(menu: Menu): Boolean {
+ menuInflater.inflate(R.menu.profile_config_menu, menu)
+ menu.findItem(R.id.action_delete).isVisible = intent.getLongExtra(EXTRA_ROUTER_ID, 0) > 0
+ return true
+ }
+
+ override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
+ android.R.id.home -> { finish(); true }
+ R.id.action_apply -> { editor?.save(); true }
+ R.id.action_delete -> {
+ MaterialAlertDialogBuilder(this)
+ .setTitle(R.string.delete_group_prompt)
+ .setPositiveButton(R.string.yes) { _, _ ->
+ editor?.delete()
+ }
+ .setNegativeButton(android.R.string.cancel, null)
+ .show()
+ true
+ }
+ else -> super.onOptionsItemSelected(item)
+ }
+
+ class EditorFragment : PreferenceFragmentCompat() {
+ private val routerId get() = requireActivity().intent.getLongExtra(EXTRA_ROUTER_ID, 0L)
+ private lateinit var name: EditTextPreference
+ private lateinit var enabled: SwitchPreferenceCompat
+ private lateinit var mode: ListPreference
+ private lateinit var sources: MultiSelectListPreference
+ private lateinit var include: EditTextPreference
+ private lateinit var exclude: EditTextPreference
+ private lateinit var urlCategory: PreferenceCategory
+ private lateinit var testUrl: EditTextPreference
+ private lateinit var interval: EditTextPreference
+ private lateinit var tolerance: EditTextPreference
+ private lateinit var selected: ListPreference
+ private lateinit var preview: Preference
+ private var sourceOrder = emptyList()
+
+ override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
+ val group = routerId.takeIf { it > 0 }?.let(RouterGroupRepository::get)
+ val filter = group?.matchConfig?.let(RouterFilterConfig::fromJson) ?: RouterFilterConfig()
+ val subscriptions = SagerDatabase.groupDao.allGroups().filter { it.type == GroupType.SUBSCRIPTION }
+ sourceOrder = subscriptions.map { it.id }
+ val screen = preferenceManager.createPreferenceScreen(requireContext())
+ name = EditTextPreference(requireContext()).nonPersistent().apply {
+ key = "router_group_name"
+ title = getString(R.string.router_group_name)
+ text = group?.name.orEmpty()
+ summaryProvider = EditTextPreference.SimpleSummaryProvider.getInstance()
+ }
+ enabled = SwitchPreferenceCompat(requireContext()).nonPersistent().apply {
+ key = "router_group_enabled"
+ title = getString(R.string.router_group_enabled)
+ isChecked = group?.enabled ?: true
+ }
+ mode = ListPreference(requireContext()).nonPersistent().apply {
+ key = "router_group_mode"
+ title = getString(R.string.router_group_mode)
+ entries = arrayOf(getString(R.string.router_mode_manual), getString(R.string.router_mode_automatic))
+ entryValues = arrayOf(RouterGroup.MODE_SELECTOR.toString(), RouterGroup.MODE_URL_TEST.toString())
+ value = (group?.mode ?: RouterGroup.MODE_SELECTOR).toString()
+ summaryProvider = ListPreference.SimpleSummaryProvider.getInstance()
+ }
+ val subMap = subscriptions.associate { it.id.toString() to it.displayName() }
+ sources = MultiSelectListPreference(requireContext()).nonPersistent().apply {
+ key = "router_group_sources"
+ title = getString(R.string.router_group_sources)
+ entries = subscriptions.map { it.displayName() }.toTypedArray()
+ entryValues = subscriptions.map { it.id.toString() }.toTypedArray()
+ values = RouterGroupRepository.sourceIds(routerId).map(Long::toString).toSet()
+ summaryProvider = Preference.SummaryProvider { pref ->
+ val selectedNames = pref.values.mapNotNull { subMap[it] }
+ if (selectedNames.isEmpty()) {
+ getString(R.string.router_no_sources_selected)
+ } else {
+ selectedNames.joinToString(", ")
+ }
+ }
+ }
+ include = textPreference("router_group_include", R.string.router_group_include, filter.includeRegex)
+ exclude = textPreference("router_group_exclude", R.string.router_group_exclude, filter.excludeRegex)
+ urlCategory = PreferenceCategory(requireContext()).apply {
+ key = "router_category_url_test"
+ title = getString(R.string.router_url_test_settings)
+ }
+ testUrl = textPreference("router_test_url", R.string.router_test_url, filter.testUrl)
+ interval = textPreference("router_test_interval", R.string.router_test_interval, filter.intervalSeconds.toString())
+ tolerance = textPreference("router_test_tolerance", R.string.router_test_tolerance, filter.toleranceMs.toString())
+ selected = ListPreference(requireContext()).nonPersistent().apply {
+ key = "router_select_node"
+ title = getString(R.string.router_select_node)
+ val members = SagerDatabase.routerMemberDao.getByRouter(routerId)
+ .mapNotNull { SagerDatabase.proxyDao.getById(it.proxyId) }
+ entries = members.map { proxy ->
+ val subName = subMap[proxy.groupId.toString()]
+ if (!subName.isNullOrBlank()) "[${subName}] ${proxy.displayNameOrFallback().trim()}"
+ else proxy.displayNameOrFallback().trim()
+ }.toTypedArray()
+ entryValues = members.map { it.id.toString() }.toTypedArray()
+ value = group?.selectedProxyId?.takeIf { it > 0 }?.toString()
+ summaryProvider = ListPreference.SimpleSummaryProvider.getInstance()
+ setOnPreferenceChangeListener { _, newValue ->
+ val proxyId = newValue.toString().toLong()
+ runOnDefaultDispatcher {
+ runCatching { RouterGroupRepository.select(routerId, proxyId) }
+ .onSuccess { updated -> if (DataStore.serviceState.started) SagerNet.reloadService(updated.stableTag, proxyId) }
+ .onFailure { error -> onMainDispatcher { toast(error.message) } }
+ }
+ true
+ }
+ }
+ preview = Preference(requireContext()).apply {
+ key = "router_group_preview"
+ title = getString(R.string.router_group_preview)
+ isSelectable = false
+ }
+ listOf(name, enabled, mode, sources, include, exclude).forEach(screen::addPreference)
+ screen.addPreference(urlCategory)
+ listOf(testUrl, interval, tolerance).forEach(urlCategory::addPreference)
+ screen.addPreference(selected)
+ screen.addPreference(preview)
+ preferenceScreen = screen
+
+ listOf(name, enabled, mode, sources, include, exclude, testUrl, interval, tolerance).forEach { preference ->
+ preference.setOnPreferenceChangeListener { _, newValue ->
+ if (preference == sources) {
+ @Suppress("UNCHECKED_CAST")
+ sources.values = newValue as? Set ?: emptySet()
+ }
+ view?.post { updateDynamicState() }
+ true
+ }
+ }
+ updateDynamicState()
+ }
+
+ private fun updateDynamicState() {
+ val automatic = mode.value == RouterGroup.MODE_URL_TEST.toString()
+ urlCategory.isVisible = automatic
+ selected.isVisible = !automatic && routerId > 0
+ runCatching { RouterGroupRepository.preview(draft()) }
+ .onSuccess { result ->
+ preview.summary = if (result.names.isEmpty()) getString(R.string.router_no_members)
+ else {
+ val maxDisplay = 20
+ val previewText = result.names.take(maxDisplay).joinToString("\n")
+ val suffix = if (result.names.size > maxDisplay) "\n..." else ""
+ getString(R.string.router_group_preview_count, result.names.size, previewText + suffix)
+ }
+ }
+ .onFailure { preview.summary = it.message }
+ }
+
+ fun save() {
+ val draft = draft()
+ runOnDefaultDispatcher {
+ runCatching { RouterGroupRepository.save(draft) }
+ .onSuccess {
+ if (DataStore.serviceState.started) SagerNet.reloadServiceFully()
+ onMainDispatcher { requireActivity().finish() }
+ }
+ .onFailure { error -> onMainDispatcher { toast(validationMessage(error)) } }
+ }
+ }
+
+ fun delete() {
+ runOnDefaultDispatcher {
+ when (val result = RouterGroupRepository.delete(routerId)) {
+ RouterDeleteResult.Deleted -> {
+ if (DataStore.serviceState.started) SagerNet.reloadServiceFully()
+ onMainDispatcher { requireActivity().finish() }
+ }
+ is RouterDeleteResult.Referenced -> onMainDispatcher {
+ toast(getString(R.string.router_group_delete_referenced, result.ruleCount))
+ }
+ }
+ }
+ }
+
+ private fun draft() = RouterGroupDraft(
+ id = routerId,
+ name = name.text.orEmpty(),
+ mode = mode.value?.toIntOrNull() ?: RouterGroup.MODE_SELECTOR,
+ enabled = enabled.isChecked,
+ sourceGroupIds = sourceOrder.filter { it.toString() in sources.values },
+ filter = RouterFilterConfig(
+ includeRegex = include.text.orEmpty(),
+ excludeRegex = exclude.text.orEmpty(),
+ testUrl = testUrl.text.orEmpty(),
+ intervalSeconds = interval.text?.toLongOrNull() ?: 0,
+ toleranceMs = tolerance.text?.toIntOrNull() ?: -1,
+ ),
+ )
+
+ private fun textPreference(prefKey: String, titleRes: Int, initial: String) =
+ EditTextPreference(requireContext()).nonPersistent().apply {
+ key = prefKey
+ title = getString(titleRes)
+ text = initial
+ summaryProvider = EditTextPreference.SimpleSummaryProvider.getInstance()
+ }
+
+ private fun T.nonPersistent(): T = apply { isPersistent = false }
+
+ private fun validationMessage(error: Throwable): String = when (error) {
+ is RouterGroupValidationException -> getString(R.string.router_group_validation_field, error.field.name, error.message)
+ else -> error.message ?: getString(R.string.error_title)
+ }
+
+ private fun toast(message: String?) = Toast.makeText(requireContext(), message, Toast.LENGTH_LONG).show()
+ }
+
+ companion object { const val EXTRA_ROUTER_ID = "router_id" }
+}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/ScannerActivity.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/ScannerActivity.kt
index 99f6f6468e..60e005adc8 100644
--- a/app/src/main/java/io/nekohasekai/sagernet/ui/ScannerActivity.kt
+++ b/app/src/main/java/io/nekohasekai/sagernet/ui/ScannerActivity.kt
@@ -4,9 +4,7 @@ import android.Manifest
import android.content.Intent
import android.content.pm.ShortcutManager
import android.graphics.ImageDecoder
-import android.os.Build
import android.os.Bundle
-import android.provider.MediaStore
import android.view.Menu
import android.view.MenuItem
import android.widget.Toast
@@ -39,7 +37,7 @@ class ScannerActivity : ThemedActivity(),
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
- if (Build.VERSION.SDK_INT >= 25) getSystemService()!!.reportShortcutUsed("scan")
+ getSystemService()!!.reportShortcutUsed("scan")
binding = LayoutScannerBinding.inflate(layoutInflater)
setContentView(binding.root)
setSupportActionBar(findViewById(R.id.toolbar))
@@ -63,19 +61,13 @@ class ScannerActivity : ThemedActivity(),
runOnDefaultDispatcher {
try {
it.forEachTry { uri ->
- val bitmap = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
- ImageDecoder.decodeBitmap(
- ImageDecoder.createSource(
- contentResolver, uri
- )
- ) { decoder, _, _ ->
- decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE
- decoder.isMutableRequired = true
- }
- } else {
- @Suppress("DEPRECATION") MediaStore.Images.Media.getBitmap(
+ val bitmap = ImageDecoder.decodeBitmap(
+ ImageDecoder.createSource(
contentResolver, uri
)
+ ) { decoder, _, _ ->
+ decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE
+ decoder.isMutableRequired = true
}
val result = CodeUtils.parseCodeResult(bitmap)
onMainDispatcher {
diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/SettingsPreferenceFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/SettingsPreferenceFragment.kt
index 659a54e3c0..71990d3c02 100644
--- a/app/src/main/java/io/nekohasekai/sagernet/ui/SettingsPreferenceFragment.kt
+++ b/app/src/main/java/io/nekohasekai/sagernet/ui/SettingsPreferenceFragment.kt
@@ -1,7 +1,6 @@
package io.nekohasekai.sagernet.ui
import android.content.Intent
-import android.os.Build
import android.os.Bundle
import android.view.View
import android.view.inputmethod.EditorInfo
@@ -75,8 +74,6 @@ class SettingsPreferenceFragment : PreferenceFragmentCompat() {
}
val localProxySettings = findPreference("localProxySettings")!!
val serviceMode = findPreference(Key.SERVICE_MODE)!!
- val appendHttpProxy = findPreference(Key.APPEND_HTTP_PROXY)!!
- val httpProxyBypass = findPreference(Key.HTTP_PROXY_BYPASS)!!
val dnsHosts = findPreference(Key.DNS_HOSTS)!!
val strictRoute = findPreference(Key.STRICT_ROUTE)!!
@@ -92,8 +89,6 @@ class SettingsPreferenceFragment : PreferenceFragmentCompat() {
val enableDnsRouting = findPreference(Key.ENABLE_DNS_ROUTING)!!
val enableFakeDns = findPreference(Key.ENABLE_FAKEDNS)!!
- val enableTLSFragment = findPreference(Key.ENABLE_TLS_FRAGMENT)!!
-
val logLevel = findPreference(Key.LOG_LEVEL)!!
val mtu = findPreference(Key.MTU)!!
globalCustomConfig = findPreference(Key.GLOBAL_CUSTOM_CONFIG)!!
@@ -126,15 +121,9 @@ class SettingsPreferenceFragment : PreferenceFragmentCompat() {
true
}
- httpProxyBypass.setOnBindEditTextListener(EditTextPreferenceModifiers.Hosts)
dnsHosts.setOnBindEditTextListener(EditTextPreferenceModifiers.Hosts)
- httpProxyBypass.summaryProvider = ListSummaryProvider(maxLines = 1)
dnsHosts.summaryProvider = ListSummaryProvider(maxLines = 1)
- val metedNetwork = findPreference(Key.METERED_NETWORK)!!
- if (Build.VERSION.SDK_INT < 28) {
- metedNetwork.remove()
- }
isProxyApps = findPreference(Key.PROXY_APPS)!!
isProxyApps.setOnPreferenceChangeListener { _, newValue ->
startActivity(Intent(activity, AppManagerActivity::class.java))
@@ -158,13 +147,7 @@ class SettingsPreferenceFragment : PreferenceFragmentCompat() {
}
val tunImplementation = findPreference(Key.TUN_IMPLEMENTATION)!!
- val enableHevTun = findPreference(Key.ENABLE_HEV_TUN)!!
- tunImplementation.isEnabled = !DataStore.enableHevTun
- enableHevTun.setOnPreferenceChangeListener { _, newValue ->
- tunImplementation.isEnabled = !(newValue as Boolean)
- needReload()
- true
- }
+ tunImplementation.onPreferenceChangeListener = reloadListener
val resolveDestination = findPreference(Key.RESOLVE_DESTINATION)!!
val acquireWakeLock = findPreference(Key.ACQUIRE_WAKE_LOCK)!!
val hideFromRecentApps = findPreference(Key.HIDE_FROM_RECENT_APPS)!!
@@ -175,41 +158,11 @@ class SettingsPreferenceFragment : PreferenceFragmentCompat() {
true
}
- val rulesProvider = findPreference(Key.RULES_PROVIDER)!!
- val rulesGeositeUrl = findPreference(Key.RULES_GEOSITE_URL)!!
- val rulesGeoipUrl = findPreference(Key.RULES_GEOIP_URL)!!
- rulesGeositeUrl.isVisible = DataStore.rulesProvider == 4
- rulesGeoipUrl.isVisible = DataStore.rulesProvider == 4
- rulesProvider.setOnPreferenceChangeListener { _, newValue ->
- val provider = (newValue as String).toInt()
- rulesGeositeUrl.isVisible = provider == 4
- rulesGeoipUrl.isVisible = provider == 4
- true
- }
-
localProxySettings.summary = localProxySummary()
localProxySettings.setOnPreferenceClickListener {
showLocalProxySettingsDialog(localProxySettings)
true
}
- appendHttpProxy.setOnPreferenceChangeListener { _, newValue ->
- if (newValue as Boolean) {
- MaterialAlertDialogBuilder(requireContext()).apply {
- setTitle(R.string.append_http_proxy_security_title)
- setMessage(R.string.append_http_proxy_security_message)
- setNegativeButton(android.R.string.cancel, null)
- setPositiveButton(R.string.enable_anyway) { _, _ ->
- appendHttpProxy.isChecked = true
- needReload()
- }
- }.show()
- false
- } else {
- needReload()
- true
- }
- }
- httpProxyBypass.onPreferenceChangeListener = reloadListener
dnsHosts.onPreferenceChangeListener = reloadListener
strictRoute.onPreferenceChangeListener = reloadListener
showDirectSpeed.onPreferenceChangeListener = reloadListener
@@ -233,11 +186,11 @@ class SettingsPreferenceFragment : PreferenceFragmentCompat() {
acquireWakeLock.onPreferenceChangeListener = reloadListener
hideFromRecentApps.setOnPreferenceChangeListener { _, newValue ->
(activity as? MainActivity)?.applyHideFromRecentApps(newValue as Boolean)
- // needReload()
true
}
- enableTLSFragment.onPreferenceChangeListener = reloadListener
+ findPreference(Key.SHOW_PROFILE_IN_NOTIFICATION)?.onPreferenceChangeListener = reloadListener
+ findPreference(Key.GLOBAL_ALLOW_INSECURE)?.onPreferenceChangeListener = reloadListener
// 恢复默认设置功能
val resetSettings = findPreference("resetSettings")!!
diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/ThemedActivity.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/ThemedActivity.kt
index 208734b77d..8ae366b9e9 100644
--- a/app/src/main/java/io/nekohasekai/sagernet/ui/ThemedActivity.kt
+++ b/app/src/main/java/io/nekohasekai/sagernet/ui/ThemedActivity.kt
@@ -1,7 +1,6 @@
package io.nekohasekai.sagernet.ui
import android.content.res.Configuration
-import android.os.Build
import android.os.Bundle
import android.widget.TextView
import androidx.annotation.StringRes
@@ -37,16 +36,14 @@ abstract class ThemedActivity : AppCompatActivity {
uiMode = resources.configuration.uiMode
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
- WindowCompat.setDecorFitsSystemWindows(window, false)
+ WindowCompat.setDecorFitsSystemWindows(window, false)
- val isNight = (uiMode and Configuration.UI_MODE_NIGHT_MASK) ==
- Configuration.UI_MODE_NIGHT_YES
- val insetController = WindowCompat.getInsetsController(window, window.decorView)
- insetController.isAppearanceLightNavigationBars = false
- insetController.isAppearanceLightStatusBars =
- if (DataStore.appTheme == Theme.BLACK) !isNight else false
- }
+ val isNight = (uiMode and Configuration.UI_MODE_NIGHT_MASK) ==
+ Configuration.UI_MODE_NIGHT_YES
+ val insetController = WindowCompat.getInsetsController(window, window.decorView)
+ insetController.isAppearanceLightNavigationBars = false
+ insetController.isAppearanceLightStatusBars =
+ if (DataStore.appTheme == Theme.BLACK) !isNight else false
ViewCompat.setOnApplyWindowInsetsListener(findViewById(android.R.id.content)) { _, insets ->
val bars = insets.getInsets(
diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/ToolsFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/ToolsFragment.kt
deleted file mode 100644
index 32738ea98f..0000000000
--- a/app/src/main/java/io/nekohasekai/sagernet/ui/ToolsFragment.kt
+++ /dev/null
@@ -1,39 +0,0 @@
-package io.nekohasekai.sagernet.ui
-
-import android.os.Bundle
-import android.view.View
-import androidx.fragment.app.Fragment
-import androidx.viewpager2.adapter.FragmentStateAdapter
-import com.google.android.material.tabs.TabLayoutMediator
-import io.nekohasekai.sagernet.R
-import io.nekohasekai.sagernet.databinding.LayoutToolsBinding
-
-class ToolsFragment : ToolbarFragment(R.layout.layout_tools) {
-
- override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
- super.onViewCreated(view, savedInstanceState)
- toolbar.setTitle(R.string.menu_tools)
-
- val tools = mutableListOf()
- tools.add(NetworkFragment())
- tools.add(BackupFragment())
-
- val binding = LayoutToolsBinding.bind(view)
- binding.toolsPager.adapter = ToolsAdapter(tools)
-
- TabLayoutMediator(binding.toolsTab, binding.toolsPager) { tab, position ->
- tab.text = tools[position].name()
- tab.view.setOnLongClickListener { // clear toast
- true
- }
- }.attach()
- }
-
- inner class ToolsAdapter(val tools: List) : FragmentStateAdapter(this) {
-
- override fun getItemCount() = tools.size
-
- override fun createFragment(position: Int) = tools[position]
- }
-
-}
\ No newline at end of file
diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/VpnRequestActivity.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/VpnRequestActivity.kt
index a1d81afc6e..69565db38e 100644
--- a/app/src/main/java/io/nekohasekai/sagernet/ui/VpnRequestActivity.kt
+++ b/app/src/main/java/io/nekohasekai/sagernet/ui/VpnRequestActivity.kt
@@ -7,7 +7,6 @@ import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.net.VpnService
-import android.os.Build.VERSION.SDK_INT
import android.os.Bundle
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContract
@@ -27,15 +26,11 @@ class VpnRequestActivity : AppCompatActivity() {
super.onCreate(savedInstanceState)
if (getSystemService()!!.isKeyguardLocked) {
receiver = broadcastReceiver { _, _ -> connect.launch(null) }
- if (SDK_INT >= 33) {
- registerReceiver(
- receiver,
- IntentFilter(Intent.ACTION_USER_PRESENT),
- Context.RECEIVER_EXPORTED
- )
- } else {
- registerReceiver(receiver, IntentFilter(Intent.ACTION_USER_PRESENT))
- }
+ registerReceiver(
+ receiver,
+ IntentFilter(Intent.ACTION_USER_PRESENT),
+ Context.RECEIVER_EXPORTED
+ )
} else connect.launch(null)
}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/WebDAVSettingsActivity.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/WebDAVSettingsActivity.kt
deleted file mode 100644
index 0fbe6faee1..0000000000
--- a/app/src/main/java/io/nekohasekai/sagernet/ui/WebDAVSettingsActivity.kt
+++ /dev/null
@@ -1,219 +0,0 @@
-package io.nekohasekai.sagernet.ui
-
-import android.os.Bundle
-import android.view.MenuItem
-import android.text.InputType
-import androidx.annotation.StringRes
-import androidx.appcompat.app.AppCompatActivity
-import androidx.appcompat.widget.Toolbar
-import androidx.preference.EditTextPreference
-import androidx.preference.Preference
-import androidx.preference.PreferenceFragmentCompat
-import androidx.preference.PreferenceDataStore
-import io.nekohasekai.sagernet.R
-import io.nekohasekai.sagernet.database.DataStore
-import io.nekohasekai.sagernet.ktx.onMainDispatcher
-import io.nekohasekai.sagernet.ktx.runOnDefaultDispatcher
-import io.nekohasekai.sagernet.ktx.snackbar
-import kotlinx.coroutines.launch
-import okhttp3.Credentials
-import okhttp3.OkHttpClient
-import okhttp3.Request
-import java.net.URL
-import com.google.android.material.snackbar.Snackbar
-import okhttp3.MediaType.Companion.toMediaType
-import okhttp3.RequestBody.Companion.toRequestBody
-import java.util.concurrent.TimeUnit
-import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
-
-class WebDAVSettingsActivity : ThemedActivity() {
-
- private lateinit var toolbar: Toolbar
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
-
- setContentView(R.layout.layout_webdav_settings)
- toolbar = findViewById(R.id.toolbar)
- setSupportActionBar(toolbar)
- supportActionBar?.apply {
- setTitle(R.string.webdav_settings)
- setDisplayHomeAsUpEnabled(true)
- setHomeAsUpIndicator(R.drawable.ic_navigation_close)
- }
-
- supportFragmentManager.beginTransaction()
- .replace(R.id.settings, WebDAVSettingsFragment())
- .commit()
- }
-
- override fun onSupportNavigateUp(): Boolean {
- finish()
- return true
- }
-
- class WebDAVSettingsFragment : PreferenceFragmentCompat(), PreferenceFragmentCompat.OnPreferenceStartFragmentCallback {
- private var lastClickTime = 0L
- private val DEBOUNCE_TIME = 1000L // 1秒内不允许重复点击
- private var isFragmentAlive = true
-
- private fun isClickAllowed(): Boolean {
- val currentTime = System.currentTimeMillis()
- val isAllowed = currentTime - lastClickTime > DEBOUNCE_TIME
- if (isAllowed) {
- lastClickTime = currentTime
- }
- return isAllowed
- }
-
- override fun onDestroy() {
- isFragmentAlive = false
- super.onDestroy()
- }
-
- override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
- preferenceManager.preferenceDataStore = DataStore.configurationStore
- addPreferencesFromResource(R.xml.webdav_preferences)
-
- findPreference("webdavServer")?.apply {
- setOnBindEditTextListener { editText ->
- editText.setSingleLine()
- editText.setSelection(editText.text.length)
- }
- summaryProvider = EditTextPreference.SimpleSummaryProvider.getInstance()
- }
-
- findPreference("webdavUsername")?.apply {
- setOnBindEditTextListener { editText ->
- editText.setSingleLine()
- editText.setSelection(editText.text.length)
- }
- summaryProvider = EditTextPreference.SimpleSummaryProvider.getInstance()
- }
-
- findPreference("webdavPassword")?.apply {
- setOnBindEditTextListener { editText ->
- editText.setSingleLine()
- editText.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
- editText.setSelection(editText.text.length)
- }
- // 使用与其他密码字段一致的隐藏摘要样式
- summaryProvider = GroupSettingsActivity.PasswordSummaryProvider
- }
-
- findPreference("webdavPath")?.apply {
- setOnBindEditTextListener { editText ->
- editText.setSingleLine()
- editText.setSelection(editText.text.length)
- }
- summaryProvider = EditTextPreference.SimpleSummaryProvider.getInstance()
- }
-
- findPreference("webdavTest")?.setOnPreferenceClickListener {
- if (isClickAllowed()) {
- testWebDAV()
- } else {
- Snackbar.make(requireView(), "请稍后再试", Snackbar.LENGTH_SHORT).show()
- }
- true
- }
- }
-
- private fun testWebDAV() {
- runOnDefaultDispatcher {
- try {
- val server = DataStore.webdavServer ?: ""
- if (server.isBlank()) {
- throw Exception(getString(R.string.webdav_server_empty))
- }
-
- val url = URL(server)
- val client = OkHttpClient.Builder()
- .connectTimeout(10, TimeUnit.SECONDS)
- .readTimeout(10, TimeUnit.SECONDS)
- .writeTimeout(10, TimeUnit.SECONDS)
- .build()
-
- // 首先测试连接和认证
- val authRequest = Request.Builder()
- .url(url)
- .method("PROPFIND", null)
- .apply {
- val credentials = Credentials.basic(
- DataStore.webdavUsername ?: "",
- DataStore.webdavPassword ?: ""
- )
- header("Authorization", credentials)
- header("Depth", "0")
- }
- .build()
-
- val response = client.newCall(authRequest).execute()
-
- when (response.code) {
- 401 -> throw Exception(getString(R.string.webdav_auth_error))
- 403 -> throw Exception(getString(R.string.webdav_permission_denied))
- 404 -> throw Exception(getString(R.string.webdav_server_not_found))
- in 500..599 -> throw Exception(getString(R.string.webdav_server_error))
- }
-
- if (!response.isSuccessful) {
- throw Exception(getString(R.string.webdav_connect_failed, response.code))
- }
-
- // 如果认证成功,再测试目录操作
- val path = (DataStore.webdavPath ?: "").trim('/')
- if (path.isNotBlank()) {
- val baseHttpUrl = server.toHttpUrlOrNull()
- ?: throw Exception(getString(R.string.webdav_server_not_found))
-
- val dirUrl = baseHttpUrl.newBuilder().apply {
- path.split('/').filter { it.isNotEmpty() }.forEach { segment ->
- addPathSegment(segment)
- }
- }.build()
-
- val dirRequest = Request.Builder()
- .url(dirUrl)
- .method("MKCOL", null)
- .apply {
- val credentials = Credentials.basic(
- DataStore.webdavUsername ?: "",
- DataStore.webdavPassword ?: ""
- )
- header("Authorization", credentials)
- }
- .build()
-
- val dirResponse = client.newCall(dirRequest).execute()
- if (!dirResponse.isSuccessful && dirResponse.code != 405) { // 405 表示目录已存在
- throw Exception(getString(R.string.webdav_create_dir_failed))
- }
- }
-
- onMainDispatcher {
- if (!isFragmentAlive) return@onMainDispatcher
- Snackbar.make(
- requireView(),
- getString(R.string.webdav_test_success),
- Snackbar.LENGTH_SHORT
- ).show()
- }
- } catch (e: Exception) {
- onMainDispatcher {
- if (!isFragmentAlive) return@onMainDispatcher
- Snackbar.make(
- requireView(),
- getString(R.string.webdav_test_failed, e.message),
- Snackbar.LENGTH_SHORT
- ).show()
- }
- }
- }
- }
-
- override fun onPreferenceStartFragment(caller: PreferenceFragmentCompat, pref: Preference): Boolean {
- return false
- }
- }
-}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/profile/ProfileSettingsActivity.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/profile/ProfileSettingsActivity.kt
index 6424502ba3..be3572716f 100644
--- a/app/src/main/java/io/nekohasekai/sagernet/ui/profile/ProfileSettingsActivity.kt
+++ b/app/src/main/java/io/nekohasekai/sagernet/ui/profile/ProfileSettingsActivity.kt
@@ -3,7 +3,6 @@ package io.nekohasekai.sagernet.ui.profile
import android.annotation.SuppressLint
import android.content.DialogInterface
import android.content.Intent
-import android.os.Build
import android.os.Bundle
import android.os.Parcelable
import android.view.Menu
@@ -162,7 +161,7 @@ abstract class ProfileSettingsActivity(
) isVisible = true
}
menu.findItem(R.id.action_create_shortcut)?.apply {
- if (Build.VERSION.SDK_INT >= 26 && DataStore.editingId != 0L) {
+ if (DataStore.editingId != 0L) {
isVisible = true // not new profile
}
}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/utils/CrashHandler.kt b/app/src/main/java/io/nekohasekai/sagernet/utils/CrashHandler.kt
index b660e5c696..adebe26e24 100644
--- a/app/src/main/java/io/nekohasekai/sagernet/utils/CrashHandler.kt
+++ b/app/src/main/java/io/nekohasekai/sagernet/utils/CrashHandler.kt
@@ -36,7 +36,7 @@ object CrashHandler : Thread.UncaughtExceptionHandler {
}
ProcessPhoenix.triggerRebirth(app, Intent(app, BlankActivity::class.java).apply {
- putExtra("sendLog", "NB4A Crash")
+ putExtra("sendLog", "Asteria Crash")
})
}
@@ -62,7 +62,7 @@ object CrashHandler : Thread.UncaughtExceptionHandler {
fun buildReportHeader(): String {
var report = ""
- report += "NekoBoxF for Android ${SagerNet.appVersionNameForDisplay} (${BuildConfig.VERSION_CODE})\n"
+ report += "${app.getString(io.nekohasekai.sagernet.R.string.app_name_long)} ${SagerNet.appVersionNameForDisplay} (${BuildConfig.VERSION_CODE})\n"
report += "Date: ${getCurrentMilliSecondUTCTimeStamp()}\n\n"
report += "OS_VERSION: ${getSystemPropertyWithAndroidAPI("os.version")}\n"
report += "SDK_INT: ${Build.VERSION.SDK_INT}\n"
diff --git a/app/src/main/java/io/nekohasekai/sagernet/utils/DefaultNetworkListener.kt b/app/src/main/java/io/nekohasekai/sagernet/utils/DefaultNetworkListener.kt
index 6af79fd574..d68c0af9a2 100644
--- a/app/src/main/java/io/nekohasekai/sagernet/utils/DefaultNetworkListener.kt
+++ b/app/src/main/java/io/nekohasekai/sagernet/utils/DefaultNetworkListener.kt
@@ -1,11 +1,9 @@
package io.nekohasekai.sagernet.utils
-import android.annotation.TargetApi
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
-import android.os.Build
import android.os.Handler
import android.os.Looper
import io.nekohasekai.sagernet.SagerNet
@@ -15,7 +13,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.channels.actor
import kotlinx.coroutines.runBlocking
-import java.net.UnknownHostException
object DefaultNetworkListener {
private sealed class NetworkMessage {
@@ -75,17 +72,14 @@ object DefaultNetworkListener {
suspend fun start(key: Any, listener: (Network?) -> Unit) =
networkActor.send(NetworkMessage.Start(key, listener))
- suspend fun get() = if (fallback) @TargetApi(23) {
- SagerNet.connectivity.activeNetwork
- ?: throw UnknownHostException() // failed to listen, return current if available
- } else NetworkMessage.Get().run {
+ suspend fun get() = NetworkMessage.Get().run {
networkActor.send(this)
response.await()
}
suspend fun stop(key: Any) = networkActor.send(NetworkMessage.Stop(key))
- // NB: this runs in ConnectivityThread, and this behavior cannot be changed until API 26
+ // NB: this runs in ConnectivityThread
private object Callback : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) =
runBlocking { networkActor.send(NetworkMessage.Put(network)) }
@@ -100,14 +94,9 @@ object DefaultNetworkListener {
runBlocking { networkActor.send(NetworkMessage.Lost(network)) }
}
- private var fallback = false
private val request = NetworkRequest.Builder().apply {
addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED)
- if (Build.VERSION.SDK_INT == 23) { // workarounds for OEM bugs
- removeCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
- removeCapability(NetworkCapabilities.NET_CAPABILITY_CAPTIVE_PORTAL)
- }
}.build()
private val mainHandler = Handler(Looper.getMainLooper())
@@ -123,30 +112,9 @@ object DefaultNetworkListener {
*/
private fun register() {
try {
- fallback = false
- when (Build.VERSION.SDK_INT) {
- in 31..Int.MAX_VALUE -> @TargetApi(31) {
- SagerNet.connectivity.registerBestMatchingNetworkCallback(
- request, Callback, mainHandler
- )
- }
- in 28 until 31 -> @TargetApi(28) { // we want REQUEST here instead of LISTEN
- SagerNet.connectivity.requestNetwork(request, Callback, mainHandler)
- }
- in 26 until 28 -> @TargetApi(26) {
- SagerNet.connectivity.registerDefaultNetworkCallback(Callback, mainHandler)
- }
- in 24 until 26 -> @TargetApi(24) {
- SagerNet.connectivity.registerDefaultNetworkCallback(Callback)
- }
- else -> {
- SagerNet.connectivity.requestNetwork(request, Callback)
- // known bug on API 23: https://stackoverflow.com/a/33509180/2245107
- }
- }
+ SagerNet.connectivity.registerBestMatchingNetworkCallback(request, Callback, mainHandler)
} catch (e: Exception) {
Logs.w(e)
- fallback = true
}
}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/widget/OutboundPreference.kt b/app/src/main/java/io/nekohasekai/sagernet/widget/OutboundPreference.kt
index f7f4fc6bcf..c3eb4701a4 100644
--- a/app/src/main/java/io/nekohasekai/sagernet/widget/OutboundPreference.kt
+++ b/app/src/main/java/io/nekohasekai/sagernet/widget/OutboundPreference.kt
@@ -9,6 +9,7 @@ import androidx.preference.PreferenceViewHolder
import io.nekohasekai.sagernet.R
import io.nekohasekai.sagernet.database.DataStore
import io.nekohasekai.sagernet.database.ProfileManager
+import io.nekohasekai.sagernet.database.SagerDatabase
import moe.matsuri.nb4a.ui.SimpleMenuPreference
class OutboundPreference
@@ -18,6 +19,7 @@ class OutboundPreference
companion object {
const val VALUE_SELECT_PROFILE = "3"
+ const val VALUE_SELECT_ROUTER = "4"
}
init {
@@ -58,9 +60,9 @@ class OutboundPreference
) {
if (!selectionReady || position < 0) return
val newValue = entryValues?.getOrNull(position)?.toString() ?: return
- val reselectedProfile =
- dropdownOpened && newValue == value && newValue == VALUE_SELECT_PROFILE
- if ((newValue != value || reselectedProfile) && callChangeListener(newValue)) {
+ val reselectedPicker = dropdownOpened && newValue == value &&
+ newValue in setOf(VALUE_SELECT_PROFILE, VALUE_SELECT_ROUTER)
+ if ((newValue != value || reselectedPicker) && callChangeListener(newValue)) {
value = newValue
}
dropdownOpened = false
@@ -81,6 +83,13 @@ class OutboundPreference
}
}
}
+ if (value == VALUE_SELECT_ROUTER) {
+ val routerId = DataStore.routeOutboundRouter
+ if (routerId > 0) {
+ return SagerDatabase.routerGroupDao.getById(routerId)?.name
+ ?: context.getString(R.string.router_reference_invalid)
+ }
+ }
return super.getSummary()
}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/widget/QRCodeDialog.kt b/app/src/main/java/io/nekohasekai/sagernet/widget/QRCodeDialog.kt
index fa72f906e5..a68ce1181d 100644
--- a/app/src/main/java/io/nekohasekai/sagernet/widget/QRCodeDialog.kt
+++ b/app/src/main/java/io/nekohasekai/sagernet/widget/QRCodeDialog.kt
@@ -78,10 +78,21 @@ class QRCodeDialog() : DialogFragment() {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT
)
- setImageBitmap(Bitmap.createBitmap(size, size, Bitmap.Config.RGB_565).apply {
- for (x in 0 until size) for (y in 0 until size) {
- setPixel(x, y, if (qrBits.get(x, y)) Color.BLACK else Color.WHITE)
+ // Bulk-fill instead of per-pixel setPixel(): a size*size loop of
+ // Bitmap.setPixel() is one JNI round-trip per pixel and blocks the
+ // main thread for a long time on large QR codes.
+ // setPixels() reads row-major: the pixel at (x, y) is taken from
+ // pixels[y * stride + x].
+ val pixels = IntArray(size * size)
+ for (y in 0 until size) {
+ val rowOffset = y * size
+ for (x in 0 until size) {
+ pixels[rowOffset + x] =
+ if (qrBits.get(x, y)) Color.BLACK else Color.WHITE
}
+ }
+ setImageBitmap(Bitmap.createBitmap(size, size, Bitmap.Config.RGB_565).apply {
+ setPixels(pixels, 0, size, 0, 0, size, size)
})
})
diff --git a/app/src/main/java/io/nekohasekai/sagernet/widget/ServiceButton.kt b/app/src/main/java/io/nekohasekai/sagernet/widget/ServiceButton.kt
index 73404c4bcd..e203c0fa68 100644
--- a/app/src/main/java/io/nekohasekai/sagernet/widget/ServiceButton.kt
+++ b/app/src/main/java/io/nekohasekai/sagernet/widget/ServiceButton.kt
@@ -2,7 +2,6 @@ package io.nekohasekai.sagernet.widget
import android.content.Context
import android.graphics.drawable.Drawable
-import android.os.Build
import android.util.AttributeSet
import android.view.PointerIcon
import android.view.View
@@ -123,7 +122,7 @@ class ServiceButton @JvmOverloads constructor(
TooltipCompat.setTooltipText(this, description)
val enabled = state.canStop || state == BaseService.State.Stopped
isEnabled = enabled
- if (Build.VERSION.SDK_INT >= 24) pointerIcon = PointerIcon.getSystemIcon(
+ pointerIcon = PointerIcon.getSystemIcon(
context,
if (enabled) PointerIcon.TYPE_HAND else PointerIcon.TYPE_WAIT
)
diff --git a/app/src/main/java/io/nekohasekai/sagernet/widget/StatsBar.kt b/app/src/main/java/io/nekohasekai/sagernet/widget/StatsBar.kt
deleted file mode 100644
index 728a4cca34..0000000000
--- a/app/src/main/java/io/nekohasekai/sagernet/widget/StatsBar.kt
+++ /dev/null
@@ -1,346 +0,0 @@
-package io.nekohasekai.sagernet.widget
-
-import android.annotation.SuppressLint
-import android.content.Context
-import android.text.format.Formatter
-import android.util.AttributeSet
-import android.view.View
-import android.widget.TextView
-import androidx.appcompat.widget.TooltipCompat
-import androidx.coordinatorlayout.widget.CoordinatorLayout
-import androidx.lifecycle.lifecycleScope
-import androidx.lifecycle.whenStarted
-import com.google.android.material.bottomappbar.BottomAppBar
-import io.nekohasekai.sagernet.R
-import io.nekohasekai.sagernet.bg.BaseService
-import io.nekohasekai.sagernet.database.DataStore
-import io.nekohasekai.sagernet.ktx.*
-import io.nekohasekai.sagernet.ui.MainActivity
-import kotlin.math.abs
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.launch
-
-class StatsBar @JvmOverloads constructor(
- context: Context, attrs: AttributeSet? = null,
- defStyleAttr: Int = R.attr.bottomAppBarStyle,
-) : BottomAppBar(context, attrs, defStyleAttr) {
- companion object {
- private const val INITIAL_HIDE_DELAY_MS = 100L
- private const val SCROLL_TOGGLE_THRESHOLD_DP = 8f
- }
-
- private enum class Transition {
- ShowImmediate,
- ShowAnimated,
- HideImmediate,
- HideAfterStart,
- }
-
- private lateinit var statusText: TextView
- private lateinit var txText: TextView
- private lateinit var rxText: TextView
- @Suppress("unused")
- private lateinit var behavior: YourBehavior
- private var currentState = BaseService.State.Idle
- private var pendingTransition: Transition? = Transition.HideImmediate
- private var transitionJob: Job? = null
-
- private var scrollHidden = false
- private var scrollDirection = 0 // 1 = hide (dy>0), -1 = show (dy<0), 0 = none
- private var scrollAccumulatedDy = 0
- private val scrollToggleThresholdPx =
- (SCROLL_TOGGLE_THRESHOLD_DP * resources.displayMetrics.density).toInt().coerceAtLeast(8)
-
- var useExternalScrollDriver = false
- set(value) {
- if (field == value) return
- field = value
- syncScrollHiddenFromView()
- resetScrollDriverState()
- updateHideOnScroll()
- }
-
- var allowShow = false
- set(value) {
- field = value
- updateHideOnScroll()
- }
-
- init {
- alpha = 0f
- }
-
- override fun getBehavior(): YourBehavior {
- if (!this::behavior.isInitialized) behavior = YourBehavior()
- return behavior
- }
-
- inner class YourBehavior : Behavior() {
-
- override fun onStartNestedScroll(
- coordinatorLayout: CoordinatorLayout,
- child: BottomAppBar,
- directTarget: View,
- target: View,
- nestedScrollAxes: Int,
- type: Int,
- ): Boolean {
- if (useExternalScrollDriver) return false
- return super.onStartNestedScroll(
- coordinatorLayout,
- child,
- directTarget,
- target,
- nestedScrollAxes,
- type,
- )
- }
-
- override fun slideUp(child: BottomAppBar) {
- if (!allowShow) return
- super.slideUp(child)
- }
- }
-
- override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
- super.onLayout(changed, left, top, right, bottom)
- val transition = pendingTransition
- if (transition != null) {
- pendingTransition = null
- applyTransition(transition)
- } else if (alpha == 0f) {
- alpha = 1f
- }
- }
-
- override fun setOnClickListener(l: OnClickListener?) {
- statusText = findViewById(R.id.status)
- txText = findViewById(R.id.tx)
- rxText = findViewById(R.id.rx)
- super.setOnClickListener(l)
- }
-
- private fun setStatus(text: CharSequence) {
- statusText.text = text
- TooltipCompat.setTooltipText(this, text)
- }
-
- private fun updateHideOnScroll() {
- hideOnScroll =
- !useExternalScrollDriver && allowShow && currentState == BaseService.State.Connected
- }
-
- private fun shouldShow(): Boolean {
- return allowShow && currentState == BaseService.State.Connected
- }
-
- private fun resetScrollDriverState() {
- scrollDirection = 0
- scrollAccumulatedDy = 0
- }
-
- private fun syncScrollHiddenFromView() {
- if (!isLaidOut || height <= 0) return
- scrollHidden = translationY >= height / 2f
- }
-
- fun onListScrolled(dy: Int) {
- if (!useExternalScrollDriver || !shouldShow() || dy == 0) return
-
- val direction = if (dy > 0) 1 else -1
- if (direction != scrollDirection) {
- scrollDirection = direction
- scrollAccumulatedDy = 0
- }
- scrollAccumulatedDy += dy
-
- val wantHidden = scrollAccumulatedDy > 0
- if (wantHidden == scrollHidden) {
- if (abs(scrollAccumulatedDy) > scrollToggleThresholdPx) {
- scrollAccumulatedDy = direction * scrollToggleThresholdPx
- }
- return
- }
- if (abs(scrollAccumulatedDy) < scrollToggleThresholdPx) return
-
- scrollHidden = wantHidden
- scrollAccumulatedDy = 0
- if (wantHidden) performHide() else performShow()
- }
-
- fun syncMainControls(
- showControls: Boolean,
- state: BaseService.State,
- showWhenConnected: Boolean,
- animate: Boolean,
- ) {
- currentState = state
- allowShow = showControls
- when {
- !showControls || state != BaseService.State.Connected -> {
- applyTransition(
- if (animate && showControls) Transition.HideAfterStart else Transition.HideImmediate
- )
- }
-
- showWhenConnected -> applyTransition(
- if (animate) Transition.ShowAnimated else Transition.ShowImmediate
- )
- alpha == 0f && isLaidOut -> alpha = 1f
- }
- }
-
- private fun applyTransition(transition: Transition) {
- if (transition == Transition.HideImmediate && hasPendingDelayedHide()) return
- cancelPendingTransition()
- if (!isLaidOut || height == 0) {
- pendingTransition = transition
- alpha = if (transition == Transition.HideImmediate && !allowShow) 0f else 1f
- return
- }
- pendingTransition = null
- when (transition) {
- Transition.ShowImmediate -> commitVisible(animated = false)
- Transition.ShowAnimated -> commitVisible(animated = true)
- Transition.HideImmediate -> commitHidden(animated = false)
- Transition.HideAfterStart -> commitHidden(animated = true)
- }
- }
-
- private fun commitVisible(animated: Boolean) {
- alpha = 1f
- scrollHidden = false
- resetScrollDriverState()
- if (animated) {
- performShow()
- } else {
- getBehavior().slideUp(this)
- animate().cancel()
- translationY = 0f
- }
- }
-
- private fun commitHidden(animated: Boolean) {
- alpha = 1f
- scrollHidden = true
- resetScrollDriverState()
- if (!animated) {
- syncHiddenPosition()
- return
- }
- val activity = context as? MainActivity
- if (activity == null) {
- post {
- if (shouldShow()) {
- commitVisible(animated = false)
- } else if (isLaidOut && height > 0) {
- performHide()
- } else {
- pendingTransition = Transition.HideAfterStart
- }
- }
- return
- }
- transitionJob = activity.lifecycleScope.launch(Dispatchers.Main) {
- delay(INITIAL_HIDE_DELAY_MS)
- activity.whenStarted {
- transitionJob = null
- if (shouldShow()) {
- commitVisible(animated = false)
- } else if (isLaidOut && height > 0) {
- performHide()
- } else {
- pendingTransition = Transition.HideAfterStart
- }
- }
- }
- }
-
- private fun syncHiddenPosition() {
- getBehavior().slideDown(this)
- animate().cancel()
- translationY = height.toFloat()
- alpha = 1f
- }
-
- private fun cancelPendingTransition() {
- transitionJob?.cancel()
- transitionJob = null
- }
-
- private fun hasPendingDelayedHide(): Boolean {
- return pendingTransition == Transition.HideAfterStart || transitionJob != null
- }
-
- fun changeState(state: BaseService.State) {
- currentState = state
- updateHideOnScroll()
- if (state == BaseService.State.Connected) {
- setStatus(app.getText(R.string.vpn_connected))
- } else {
- updateSpeed(0, 0)
- setStatus(
- context.getText(
- when (state) {
- BaseService.State.Connecting -> R.string.connecting
- BaseService.State.Stopping -> R.string.stopping
- else -> R.string.not_connected
- }
- )
- )
- }
- }
-
- @SuppressLint("SetTextI18n")
- fun updateSpeed(txRate: Long, rxRate: Long) {
- txText.text = "▲ ${
- context.getString(
- R.string.speed, Formatter.formatFileSize(context, txRate)
- )
- }"
- rxText.text = "▼ ${
- context.getString(
- R.string.speed, Formatter.formatFileSize(context, rxRate)
- )
- }"
- }
-
- fun testConnection() {
- val activity = context as MainActivity
- isEnabled = false
- setStatus(app.getText(R.string.connection_test_testing))
- runOnDefaultDispatcher {
- try {
- val elapsed = activity.urlTest()
- onMainDispatcher {
- isEnabled = true
- setStatus(
- app.getString(
- if (DataStore.connectionTestURL.startsWith("https://")) {
- R.string.connection_test_available
- } else {
- R.string.connection_test_available_http
- }, elapsed
- )
- )
- }
-
- } catch (e: Exception) {
- Logs.w(e.toString())
- onMainDispatcher {
- isEnabled = true
- setStatus(app.getText(R.string.connection_test_testing))
-
- activity.snackbar(
- app.getString(
- R.string.connection_test_error, e.readableMessage
- )
- ).show()
- }
- }
- }
- }
-
-}
diff --git a/app/src/main/java/moe/matsuri/nb4a/NativeInterface.kt b/app/src/main/java/moe/matsuri/nb4a/NativeInterface.kt
index f06e26d84b..ddee9d1b41 100644
--- a/app/src/main/java/moe/matsuri/nb4a/NativeInterface.kt
+++ b/app/src/main/java/moe/matsuri/nb4a/NativeInterface.kt
@@ -3,9 +3,6 @@ package moe.matsuri.nb4a
import android.content.Context
import android.net.ConnectivityManager
import android.net.wifi.WifiManager
-import android.os.Build
-import android.os.Build.VERSION_CODES
-import androidx.annotation.RequiresApi
import io.nekohasekai.sagernet.SagerNet
import io.nekohasekai.sagernet.bg.ServiceNotification
import io.nekohasekai.sagernet.database.DataStore
@@ -14,6 +11,8 @@ import io.nekohasekai.sagernet.ktx.Logs
import io.nekohasekai.sagernet.ktx.app
import io.nekohasekai.sagernet.ktx.runOnDefaultDispatcher
import io.nekohasekai.sagernet.utils.PackageCache
+import io.nekohasekai.sagernet.route.routerNodeKey
+import io.nekohasekai.sagernet.route.routerStableIdOrFallback
import libcore.BoxPlatformInterface
import libcore.Libcore
import libcore.NB4AInterface
@@ -34,11 +33,8 @@ class NativeInterface : BoxPlatformInterface, NB4AInterface {
return DataStore.vpnService!!.startVpn(singTunOptionsJson, tunPlatformOptionsJson).toLong()
}
- override fun useProcFS(): Boolean {
- return Build.VERSION.SDK_INT < Build.VERSION_CODES.Q
- }
+ override fun useProcFS(): Boolean = false
- @RequiresApi(Build.VERSION_CODES.Q)
override fun findConnectionOwner(
ipProto: Int, srcIp: String, srcPort: Int, destIp: String, destPort: Int
): Int {
@@ -82,26 +78,49 @@ class NativeInterface : BoxPlatformInterface, NB4AInterface {
}
override fun selector_OnProxySelected(selectorTag: String, tag: String) {
+ val service = DataStore.baseService
+ val proxy = service?.data?.proxy
+ val routerTag = proxy?.config?.routerSelectorTags?.entries
+ ?.firstOrNull { it.value == selectorTag }?.key
+ if (routerTag != null && service != null && proxy != null) {
+ val id = proxy.config.profileTagMap
+ .filterValues { it == tag }.keys.firstOrNull() ?: return
+ runOnDefaultDispatcher {
+ if (DataStore.baseService !== service || service.data.proxy !== proxy) return@runOnDefaultDispatcher
+ val selected = SagerDatabase.proxyDao.getById(id) ?: return@runOnDefaultDispatcher
+ SagerDatabase.routerGroupDao.getByStableTag(routerTag)?.let { router ->
+ SagerDatabase.routerGroupDao.updateSelection(
+ routerId = router.id,
+ selectedProxyId = id,
+ selectedNodeKey = routerNodeKey(
+ selected.groupId,
+ routerStableIdOrFallback(selected.uuid, selected.id),
+ ),
+ )
+ }
+ }
+ return
+ }
if (selectorTag != "proxy") {
Logs.d("other selector: $selectorTag")
return
}
Libcore.resetAllConnections(true)
- DataStore.baseService?.apply {
- runOnDefaultDispatcher {
- val id = data.proxy!!.config.profileTagMap
- .filterValues { it == tag }.keys.firstOrNull() ?: -1
- val ent = SagerDatabase.proxyDao.getById(id) ?: return@runOnDefaultDispatcher
- // traffic & title
- data.proxy?.apply {
- looper?.selectMain(id)
- displayProfileName = ServiceNotification.genTitle(ent)
- data.notification?.postNotificationTitle(displayProfileName)
- }
- // post binder
- data.binder.broadcast { b ->
- b.cbSelectorUpdate(id)
- }
+ if (service == null || proxy == null) return
+ runOnDefaultDispatcher {
+ if (DataStore.baseService !== service || service.data.proxy !== proxy) return@runOnDefaultDispatcher
+ val id = proxy.config.profileTagMap
+ .filterValues { it == tag }.keys.firstOrNull() ?: -1
+ val ent = SagerDatabase.proxyDao.getById(id) ?: return@runOnDefaultDispatcher
+ // traffic & title
+ proxy.apply {
+ looper?.selectMain(id)
+ displayProfileName = ServiceNotification.genTitle(ent)
+ service.data.notification?.postNotificationTitle(displayProfileName)
+ }
+ // post binder
+ service.data.binder.broadcast { b ->
+ b.cbSelectorUpdate(id)
}
}
}
diff --git a/app/src/main/java/moe/matsuri/nb4a/SingBoxOptions.java b/app/src/main/java/moe/matsuri/nb4a/SingBoxOptions.java
index 0cfb24a225..aff361defe 100644
--- a/app/src/main/java/moe/matsuri/nb4a/SingBoxOptions.java
+++ b/app/src/main/java/moe/matsuri/nb4a/SingBoxOptions.java
@@ -113,12 +113,94 @@ public static class MyOptions extends SingBoxOption {
public List outbounds;
+ public List endpoints;
+
public RouteOptions route;
public ExperimentalOptions experimental;
}
+ // sing-box 1.15 moved some protocols (WireGuard) from outbounds to endpoints.
+
+ public static class Endpoint extends SingBoxOption {
+
+ public String type;
+
+ public String tag;
+
+ }
+
+ public static class Endpoint_WireGuardPeer extends SingBoxOption {
+
+ public String address;
+
+ public Integer port;
+
+ public String public_key;
+
+ public String pre_shared_key;
+
+ // Generate note: Listable
+ public List allowed_ips;
+
+ public Integer persistent_keepalive_interval;
+
+ // Generate note: Base64 String
+ public String reserved;
+
+ }
+
+ public static class Endpoint_WireGuardOptions extends Endpoint {
+
+ // Generate note: nested type DialerOptions
+ public String detour;
+
+ public String bind_interface;
+
+ public String inet4_bind_address;
+
+ public String inet6_bind_address;
+
+ public String protect_path;
+
+ public Integer routing_mark;
+
+ public Boolean reuse_addr;
+
+ public Long connect_timeout;
+
+ public Boolean tcp_fast_open;
+
+ public Boolean tcp_multi_path;
+
+ public Boolean udp_fragment;
+
+ public String domain_strategy;
+
+ public Long fallback_delay;
+
+ // End of public DialerOptions ;
+
+ public Boolean system;
+
+ public String name;
+
+ public Integer mtu;
+
+ // Generate note: Listable
+ public List address;
+
+ public String private_key;
+
+ public Integer listen_port;
+
+ public List peers;
+
+ public Integer workers;
+
+ }
+
// paste generate output here
public static class ClashAPIOptions extends SingBoxOption {
@@ -2829,20 +2911,22 @@ public static class Inbound_TunOptions extends Inbound {
public Integer mtu;
// Generate note: Listable
- public List inet4_address;
+ public List address;
+
+ public String dns_mode;
// Generate note: Listable
- public List inet6_address;
+ public List dns_address;
public Boolean auto_route;
public Boolean strict_route;
// Generate note: Listable
- public List inet4_route_address;
+ public List route_address;
// Generate note: Listable
- public List inet6_route_address;
+ public List route_exclude_address;
// Generate note: Listable
public List include_interface;
@@ -2871,8 +2955,6 @@ public static class Inbound_TunOptions extends Inbound {
// Generate note: Listable
public List exclude_package;
- public Boolean endpoint_independent_nat;
-
public Long udp_timeout;
public String stack;
@@ -4452,10 +4534,12 @@ public static class Outbound_URLTestOptions extends Outbound {
public String url;
- public Long interval;
+ public String interval;
public Integer tolerance;
+ public String idle_timeout;
+
}
public static class Rule_DefaultOptions extends Rule {
diff --git a/app/src/main/java/moe/matsuri/nb4a/hevtun/HevTunNative.kt b/app/src/main/java/moe/matsuri/nb4a/hevtun/HevTunNative.kt
deleted file mode 100644
index 2fa9751256..0000000000
--- a/app/src/main/java/moe/matsuri/nb4a/hevtun/HevTunNative.kt
+++ /dev/null
@@ -1,34 +0,0 @@
-package moe.matsuri.nb4a.hevtun
-
-import androidx.annotation.Keep
-
-/**
- * JNI bindings for hev-socks5-tunnel.
- *
- * The native methods are registered via RegisterNatives in JNI_OnLoad against
- * the class named by the -DPKGNAME/-DCLSNAME macros in
- * buildScript/compile-hevtun.sh; keep both sides in sync.
- */
-@Keep
-object HevTunNative {
-
- init {
- System.loadLibrary("hev-socks5-tunnel")
- }
-
- @JvmStatic
- @Keep
- external fun TProxyStartService(configPath: String, fd: Int): Boolean
-
- @JvmStatic
- @Keep
- external fun TProxyStopService(): Boolean
-
- @JvmStatic
- @Keep
- external fun TProxyIsRunning(): Boolean
-
- @JvmStatic
- @Keep
- external fun TProxyGetStats(): LongArray
-}
diff --git a/app/src/main/java/moe/matsuri/nb4a/hevtun/HevTunRuntime.kt b/app/src/main/java/moe/matsuri/nb4a/hevtun/HevTunRuntime.kt
deleted file mode 100644
index f1d949493c..0000000000
--- a/app/src/main/java/moe/matsuri/nb4a/hevtun/HevTunRuntime.kt
+++ /dev/null
@@ -1,84 +0,0 @@
-package moe.matsuri.nb4a.hevtun
-
-import android.content.Context
-import io.nekohasekai.sagernet.IPv6Mode
-import io.nekohasekai.sagernet.bg.VpnService
-import io.nekohasekai.sagernet.database.DataStore
-import io.nekohasekai.sagernet.fmt.LOCALHOST
-import java.io.File
-
-/**
- * Manages the in-process hev-socks5-tunnel runtime. The tunnel reads packets
- * from the VPN file descriptor and forwards TCP/UDP connections into the
- * sing-box mixed inbound on loopback.
- */
-object HevTunRuntime {
-
- private const val CONFIG_FILE = "hev-socks5-tunnel.yaml"
-
- private var running = false
-
- fun isRunning(): Boolean {
- return try {
- HevTunNative.TProxyIsRunning()
- } catch (e: UnsatisfiedLinkError) {
- false
- }
- }
-
- @Synchronized
- fun start(context: Context, tunFd: Int) {
- stop()
- val configFile = File(context.filesDir, CONFIG_FILE)
- configFile.writeText(buildConfig())
- check(HevTunNative.TProxyStartService(configFile.absolutePath, tunFd)) {
- "Failed to start hev-socks5-tunnel"
- }
- running = true
- }
-
- @Synchronized
- fun stop() {
- if (!running) return
- try {
- HevTunNative.TProxyStopService()
- } finally {
- running = false
- }
- }
-
- private fun buildConfig(): String {
- val useAuth = DataStore.mixedInboundHasAuth
- return buildString {
- appendLine("tunnel:")
- appendLine(" mtu: ${DataStore.mtu}")
- appendLine(" ipv4: '${VpnService.PRIVATE_VLAN4_CLIENT}'")
- if (DataStore.ipv6Mode != IPv6Mode.DISABLE) {
- appendLine(" ipv6: '${VpnService.PRIVATE_VLAN6_CLIENT}'")
- }
- appendLine("socks5:")
- appendLine(" port: ${DataStore.mixedPort}")
- appendLine(" address: '$LOCALHOST'")
- appendLine(" udp: 'udp'")
- appendLine(" pipeline: true")
- if (useAuth) {
- appendLine(" username: '${DataStore.mixedUsername.yamlEscape()}'")
- appendLine(" password: '${DataStore.mixedSecret.yamlEscape()}'")
- }
- if (DataStore.enableFakeDns) {
- appendLine("mapdns:")
- appendLine(" address: '${VpnService.PRIVATE_VLAN4_ROUTER}'")
- appendLine(" port: 53")
- appendLine(" network: '${VpnService.HEV_MAPDNS_VLAN4}'")
- appendLine(" netmask: '255.192.0.0'")
- appendLine(" cache-size: 10000")
- }
- appendLine("misc:")
- appendLine(" log-level: 'warn'")
- }
- }
-
- private fun String.yamlEscape(): String {
- return replace("'", "''")
- }
-}
diff --git a/app/src/main/java/moe/matsuri/nb4a/net/LocalResolverImpl.kt b/app/src/main/java/moe/matsuri/nb4a/net/LocalResolverImpl.kt
index 9b125aeeab..708811b5cf 100644
--- a/app/src/main/java/moe/matsuri/nb4a/net/LocalResolverImpl.kt
+++ b/app/src/main/java/moe/matsuri/nb4a/net/LocalResolverImpl.kt
@@ -1,38 +1,24 @@
package moe.matsuri.nb4a.net
import android.net.DnsResolver
-import android.os.Build
import android.os.CancellationSignal
import android.system.ErrnoException
-import androidx.annotation.RequiresApi
import io.nekohasekai.sagernet.SagerNet
import io.nekohasekai.sagernet.ktx.Logs
-import io.nekohasekai.sagernet.ktx.runOnIoDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.asExecutor
import libcore.ExchangeContext
import libcore.LocalDNSTransport
import java.net.InetAddress
-import java.net.UnknownHostException
object LocalResolverImpl : LocalDNSTransport {
// new local
- private const val RCODE_NXDOMAIN = 3
+ override fun raw(): Boolean = true
- override fun raw(): Boolean {
- return Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
- }
-
- override fun networkHandle(): Long {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
- return SagerNet.underlyingNetwork?.networkHandle ?: 0
- }
- return 0
- }
+ override fun networkHandle(): Long = SagerNet.underlyingNetwork?.networkHandle ?: 0
- @RequiresApi(Build.VERSION_CODES.Q)
override fun exchange(ctx: ExchangeContext, message: ByteArray) {
val signal = CancellationSignal()
ctx.onCancel(signal::cancel)
@@ -64,88 +50,64 @@ object LocalResolverImpl : LocalDNSTransport {
}
override fun lookup(ctx: ExchangeContext, network: String, domain: String) {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
- val signal = CancellationSignal()
- ctx.onCancel(signal::cancel)
-
- val callback = object : DnsResolver.Callback> {
- override fun onAnswer(answer: Collection, rcode: Int) {
- try {
- if (rcode == 0) {
- ctx.success(answer.mapNotNull { it.hostAddress }.joinToString("\n"))
- } else {
- ctx.errorCode(rcode)
- }
- } catch (e: Exception) {
- Logs.w(e)
- ctx.errnoCode(114514)
- }
- }
+ val signal = CancellationSignal()
+ ctx.onCancel(signal::cancel)
- override fun onError(error: DnsResolver.DnsException) {
- try {
- val cause = error.cause
- if (cause is ErrnoException) {
- ctx.errnoCode(cause.errno)
- } else {
- Logs.w(error)
- ctx.errnoCode(114514)
- }
- } catch (e: Exception) {
- Logs.w(e)
- ctx.errnoCode(114514)
+ val callback = object : DnsResolver.Callback> {
+ override fun onAnswer(answer: Collection