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, 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 type = when { - network.endsWith("4") -> DnsResolver.TYPE_A - network.endsWith("6") -> DnsResolver.TYPE_AAAA - else -> null - } - if (type != null) { - DnsResolver.getInstance().query( - SagerNet.underlyingNetwork, - domain, - type, - DnsResolver.FLAG_NO_RETRY, - Dispatchers.IO.asExecutor(), - signal, - callback - ) - } else { - DnsResolver.getInstance().query( - SagerNet.underlyingNetwork, - domain, - DnsResolver.FLAG_NO_RETRY, - Dispatchers.IO.asExecutor(), - signal, - callback - ) - } - } else { - runOnIoDispatcher { - // 老版本系统,继续用阻塞的 InetAddress + override fun onError(error: DnsResolver.DnsException) { try { - val u = SagerNet.underlyingNetwork - val answer = try { - u?.getAllByName(domain) - } catch (e: UnknownHostException) { - null - } ?: InetAddress.getAllByName(domain) - if (answer != null) { - ctx.success(answer.mapNotNull { it.hostAddress }.joinToString("\n")) + val cause = error.cause + if (cause is ErrnoException) { + ctx.errnoCode(cause.errno) } else { + Logs.w(error) ctx.errnoCode(114514) } - } catch (e: UnknownHostException) { - ctx.errorCode(RCODE_NXDOMAIN) } catch (e: Exception) { Logs.w(e) ctx.errnoCode(114514) } } } + + val type = when { + network.endsWith("4") -> DnsResolver.TYPE_A + network.endsWith("6") -> DnsResolver.TYPE_AAAA + else -> null + } + if (type != null) { + DnsResolver.getInstance().query( + SagerNet.underlyingNetwork, + domain, + type, + DnsResolver.FLAG_NO_RETRY, + Dispatchers.IO.asExecutor(), + signal, + callback + ) + } else { + DnsResolver.getInstance().query( + SagerNet.underlyingNetwork, + domain, + DnsResolver.FLAG_NO_RETRY, + Dispatchers.IO.asExecutor(), + signal, + callback + ) + } } -} \ No newline at end of file +} diff --git a/app/src/main/java/moe/matsuri/nb4a/plugin/Plugins.kt b/app/src/main/java/moe/matsuri/nb4a/plugin/Plugins.kt index a584cadfa0..2561fd2083 100644 --- a/app/src/main/java/moe/matsuri/nb4a/plugin/Plugins.kt +++ b/app/src/main/java/moe/matsuri/nb4a/plugin/Plugins.kt @@ -5,7 +5,6 @@ import android.content.pm.PackageInfo import android.content.pm.PackageManager import android.content.pm.ProviderInfo import android.net.Uri -import android.os.Build import android.widget.Toast import io.nekohasekai.sagernet.SagerNet import io.nekohasekai.sagernet.plugin.PluginManager.loadString @@ -101,11 +100,8 @@ object Plugins { .build() private fun getExtPluginOld(pluginId: String): List { - var flags = PackageManager.GET_META_DATA - if (Build.VERSION.SDK_INT >= 24) { - flags = - flags or PackageManager.MATCH_DIRECT_BOOT_UNAWARE or PackageManager.MATCH_DIRECT_BOOT_AWARE - } + val flags = PackageManager.GET_META_DATA or + PackageManager.MATCH_DIRECT_BOOT_UNAWARE or PackageManager.MATCH_DIRECT_BOOT_AWARE val list1 = SagerNet.application.packageManager.queryIntentContentProviders( Intent(ACTION_NATIVE_PLUGIN, buildUri(pluginId, "io.nekohasekai.sagernet")), flags ) diff --git a/app/src/main/java/moe/matsuri/nb4a/proxy/anytls/AnyTLSFmt.kt b/app/src/main/java/moe/matsuri/nb4a/proxy/anytls/AnyTLSFmt.kt index fc1a947e2e..d58e406e53 100644 --- a/app/src/main/java/moe/matsuri/nb4a/proxy/anytls/AnyTLSFmt.kt +++ b/app/src/main/java/moe/matsuri/nb4a/proxy/anytls/AnyTLSFmt.kt @@ -1,5 +1,6 @@ package moe.matsuri.nb4a.proxy.anytls +import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.ktx.blankAsNull import io.nekohasekai.sagernet.ktx.linkBuilder import io.nekohasekai.sagernet.ktx.toLink @@ -7,6 +8,30 @@ import io.nekohasekai.sagernet.ktx.urlSafe import moe.matsuri.nb4a.SingBoxOptions import moe.matsuri.nb4a.utils.listByLineOrComma import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import org.json.JSONObject + +fun parseSingBoxAnyTLS(json: JSONObject): AnyTLSBean? = parseSingBoxAnyTLSMap(buildMap { + for (key in json.keys()) put(key, json.get(key)) +}) + +internal fun parseSingBoxAnyTLSMap(json: Map): AnyTLSBean? { + if (json["type"] != "anytls") return null + val server = json["server"]?.toString()?.takeIf { it.isNotBlank() } ?: return null + val port = (json["server_port"] as? Number)?.toInt()?.takeIf { it > 0 } ?: return null + val tls = json["tls"] as? Map<*, *> + val utls = tls?.get("utls") as? Map<*, *> + return AnyTLSBean().apply { + name = json["tag"]?.toString()?.takeIf { it.isNotBlank() } + serverAddress = server + serverPort = port + password = json["password"]?.toString() ?: "" + sni = tls?.get("server_name")?.toString() ?: "" + allowInsecure = tls?.get("insecure") as? Boolean ?: false + utlsFingerprint = utls?.get("fingerprint")?.toString() ?: "" + alpn = (tls?.get("alpn") as? List<*>)?.joinToString("\n") ?: "" + initializeDefaultValues() + } +} fun buildSingBoxOutboundAnyTLSBean(bean: AnyTLSBean): SingBoxOptions.Outbound_AnyTLSOptions { return SingBoxOptions.Outbound_AnyTLSOptions().apply { @@ -18,7 +43,7 @@ fun buildSingBoxOutboundAnyTLSBean(bean: AnyTLSBean): SingBoxOptions.Outbound_An tls = SingBoxOptions.OutboundTLSOptions().apply { enabled = true server_name = bean.sni.blankAsNull() - if (bean.allowInsecure) insecure = true + insecure = bean.allowInsecure || DataStore.globalAllowInsecure alpn = bean.alpn.blankAsNull()?.listByLineOrComma() bean.certificates.blankAsNull()?.let { certificate = it diff --git a/app/src/main/java/moe/matsuri/nb4a/utils/JavaUtil.java b/app/src/main/java/moe/matsuri/nb4a/utils/JavaUtil.java index 1e3614736f..8f5a7eeb55 100644 --- a/app/src/main/java/moe/matsuri/nb4a/utils/JavaUtil.java +++ b/app/src/main/java/moe/matsuri/nb4a/utils/JavaUtil.java @@ -1,10 +1,7 @@ package moe.matsuri.nb4a.utils; -import android.annotation.SuppressLint; -import android.annotation.TargetApi; import android.app.Application; import android.content.Context; -import android.os.Build; import android.text.TextUtils; import android.webkit.WebView; @@ -14,7 +11,6 @@ import java.io.File; import java.io.RandomAccessFile; -import java.lang.reflect.Method; import java.nio.channels.FileLock; import java.util.Arrays; import java.util.HashSet; @@ -69,15 +65,11 @@ public static String unescapeString(CharSequence encodedString) { // Webview Utils public static void handleWebviewDir(Context context) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { - return; - } try { Set pathSet = new HashSet<>(); String suffix; String dataPath = context.getDataDir().getAbsolutePath(); String webViewDir = "/app_webview"; - String huaweiWebViewDir = "/app_hws_webview"; String lockFile = "/webview_data.lock"; String processName = Application.getProcessName(); if (!BuildConfig.APPLICATION_ID.equals(processName)) {//判断不等于默认进程名称 @@ -85,18 +77,11 @@ public static void handleWebviewDir(Context context) { WebView.setDataDirectorySuffix(suffix); suffix = "_" + suffix; pathSet.add(dataPath + webViewDir + suffix + lockFile); - if (checkIsHuaweiRom()) { - pathSet.add(dataPath + huaweiWebViewDir + suffix + lockFile); - } } else { //主进程 suffix = "_" + processName; pathSet.add(dataPath + webViewDir + lockFile);//默认未添加进程名后缀 pathSet.add(dataPath + webViewDir + suffix + lockFile);//系统自动添加了进程名后缀 - if (checkIsHuaweiRom()) {//部分华为手机更改了webview目录名 - pathSet.add(dataPath + huaweiWebViewDir + lockFile); - pathSet.add(dataPath + huaweiWebViewDir + suffix + lockFile); - } } for (String path : pathSet) { File file = new File(path); @@ -110,7 +95,6 @@ public static void handleWebviewDir(Context context) { } } - @TargetApi(Build.VERSION_CODES.P) private static void tryLockOrRecreateFile(File file) { try { FileLock tryLock = new RandomAccessFile(file, "rw").getChannel().tryLock(); @@ -139,26 +123,8 @@ private static void createFile(File file, boolean deleted) { } } - private static boolean checkIsHuaweiRom() { - return Build.MANUFACTURER.contains("HUAWEI"); - } - - @SuppressLint("PrivateApi") public static String getProcessName() { - if (Build.VERSION.SDK_INT >= 28) - return Application.getProcessName(); - - // Using the same technique as Application.getProcessName() for older devices - // Using reflection since ActivityThread is an internal API - - try { - Class activityThread = Class.forName("android.app.ActivityThread"); - String methodName = "currentProcessName"; - Method getProcessName = activityThread.getDeclaredMethod(methodName); - return (String) getProcessName.invoke(null); - } catch (Exception e) { - return BuildConfig.APPLICATION_ID; - } + return Application.getProcessName(); } // Old hutool Utils diff --git a/app/src/main/java/moe/matsuri/nb4a/utils/KotlinUtil.kt b/app/src/main/java/moe/matsuri/nb4a/utils/KotlinUtil.kt index 14f2240fdf..02ada7b64d 100644 --- a/app/src/main/java/moe/matsuri/nb4a/utils/KotlinUtil.kt +++ b/app/src/main/java/moe/matsuri/nb4a/utils/KotlinUtil.kt @@ -7,6 +7,7 @@ import androidx.appcompat.content.res.AppCompatResources import io.nekohasekai.sagernet.SagerNet import io.nekohasekai.sagernet.ktx.Logs import java.io.File +import java.util.Locale // SagerNet Class @@ -50,9 +51,9 @@ fun Context.getDrawableByName(name: String?): Drawable? { fun Long.toBytesString(): String { val size = this.toDouble() return when { - this >= GB -> String.format("%.2f GiB", size / GB) - this >= MB -> String.format("%.2f MiB", size / MB) - this >= KB -> String.format("%.2f KiB", size / KB) + this >= GB -> String.format(Locale.getDefault(), "%.2f GiB", size / GB) + this >= MB -> String.format(Locale.getDefault(), "%.2f MiB", size / MB) + this >= KB -> String.format(Locale.getDefault(), "%.2f KiB", size / KB) else -> "$this Bytes" } } diff --git a/app/src/main/java/moe/matsuri/nb4a/utils/WebViewUtil.kt b/app/src/main/java/moe/matsuri/nb4a/utils/WebViewUtil.kt index ebce693e47..ab5865cb77 100644 --- a/app/src/main/java/moe/matsuri/nb4a/utils/WebViewUtil.kt +++ b/app/src/main/java/moe/matsuri/nb4a/utils/WebViewUtil.kt @@ -1,6 +1,5 @@ package moe.matsuri.nb4a.utils -import android.os.Build import android.webkit.WebResourceError import android.webkit.WebResourceRequest import android.webkit.WebResourceResponse @@ -13,7 +12,7 @@ object WebViewUtil { fun onReceivedError( view: WebView?, request: WebResourceRequest?, error: WebResourceError? ) { - if (Build.VERSION.SDK_INT >= 23 && error != null) { + if (error != null) { Logs.e("WebView error description: ${error.description}") } Logs.e("WebView error: ${error.toString()}") diff --git a/app/src/main/res/drawable/ic_action_navigate_next.xml b/app/src/main/res/drawable/ic_action_navigate_next.xml new file mode 100644 index 0000000000..02dc82bced --- /dev/null +++ b/app/src/main/res/drawable/ic_action_navigate_next.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_asteria_foreground.xml b/app/src/main/res/drawable/ic_asteria_foreground.xml new file mode 100644 index 0000000000..fbef43b860 --- /dev/null +++ b/app/src/main/res/drawable/ic_asteria_foreground.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_asteria_monochrome.xml b/app/src/main/res/drawable/ic_asteria_monochrome.xml new file mode 100644 index 0000000000..1712d4c125 --- /dev/null +++ b/app/src/main/res/drawable/ic_asteria_monochrome.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/layout_backup.xml b/app/src/main/res/layout/layout_backup.xml index 4f85a5825f..412f345645 100644 --- a/app/src/main/res/layout/layout_backup.xml +++ b/app/src/main/res/layout/layout_backup.xml @@ -1,13 +1,23 @@ - + android:layout_height="match_parent" + android:orientation="vertical"> - + android:layout_height="wrap_content" /> + + + + - - - - - - - - - - - - - - -