From 64c427cf11d8abe62ab6436905b5756ed37f5a29 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Mon, 22 Jun 2026 18:44:50 +0800 Subject: [PATCH 1/8] =?UTF-8?q?docs(android):=20Android=20=E8=AE=BE?= =?UTF-8?q?=E8=AE=A1=E6=96=B9=E6=A1=88+KillSwitch=20=E7=9F=A5=E8=AF=86?= =?UTF-8?q?=E5=BA=93(HTML)+=E6=96=87=E6=A1=A3=E7=B4=A2=E5=BC=95+=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0=E8=AE=A1=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs/android-client-design.html / killswitch-design.html / index.html - docs/superpowers/plans/2026-06-22-android-client.md - todo/ KillSwitch 三端待办(#1 mac/#2 Android/#3 Windows) Co-Authored-By: Claude Opus 4.8 --- docs/android-client-design.html | 170 +++++ docs/index.html | 121 ++++ docs/killswitch-design.html | 151 ++++ .../plans/2026-06-22-android-client.md | 438 ++++++++++++ todo/todo.html | 645 ++++++++++++++++++ todo/todo.json | 54 ++ 6 files changed, 1579 insertions(+) create mode 100644 docs/android-client-design.html create mode 100644 docs/index.html create mode 100644 docs/killswitch-design.html create mode 100644 docs/superpowers/plans/2026-06-22-android-client.md create mode 100644 todo/todo.html create mode 100644 todo/todo.json diff --git a/docs/android-client-design.html b/docs/android-client-design.html new file mode 100644 index 0000000..ca236ca --- /dev/null +++ b/docs/android-client-design.html @@ -0,0 +1,170 @@ + + + + + +Pangolin Android 客户端设计方案 + + + +
+ +← 文档索引 + +

Pangolin Android 客户端设计方案

+

目标终点:MVP 端到端连通 + 切节点 + KillSwitch(清掉 TODO 11G) · 分支 feature/android · 2026-06-22

+ +
+一句话定性:不是从零开发,而是把已有的 M2 纸面 PoC 推到真机能连真实节点出网,并补齐切节点 + KillSwitch(清掉 TODO 11G)。Flutter UI 全平台共享、本次零改动。 +
+ +

1. 现状盘点

+

之前的任务(commit 83c23f9 "feat(android): 实现 M2 Android VPN 隧道")已写好相当完整的原生层,但从未真正编译运行过

+ +
+

已具备 现成

+
    +
  • Flutter UI 全平台共享(连接键三态 / 节点 / 统计 / 账户都现成,Android 直接复用)。
  • +
  • Channel 契约已冻结(pangolin/vpn MethodChannel + pangolin/vpn/statuspangolin/vpn/stats EventChannel),见 client/lib/bridge/vpn_bridge.dart
  • +
  • MainActivity.kt —— 通道注册 + VPN 授权流程(VpnService.prepare)+ 电池优化豁免引导。
  • +
  • PangolinVpnService.kt —— VpnService + libbox BoxService 集成、openTun、统计(CommandClient + TrafficStats 兜底)、前台通知。
  • +
  • VpnEventBus.kt —— Service↔Activity 状态总线(主线程回调)。
  • +
  • AndroidManifest.xml —— VPN 权限、前台服务 specialUse(适配 Android 14)。
  • +
  • build-android.sh —— gomobile 编译 sing-box → libbox.aar(sing-box v1.13.12 / Go 1.24.3 / gomobile pin v0.0.0-20240604…,tag with_quic,with_utls,with_clash_api,with_gvisor)。
  • +
  • poc_config.json —— REALITY 配置模板。
  • +
+
+ +
+

缺口(让它真正能跑要补的)待补

+
    +
  1. libbox.aar 没构建过 —— app/kernel/dist/android/ 为空。需要 Android NDK + gomobile(硬前置)。
  2. +
  3. 原生 libbox API 名是「猜」的 —— 代码里多处注释「若编译失败请对照实际 libbox API 微调」(Libbox.newBoxServiceTunOptions.inet4Address() 等从没被真实 aar 校验过)。
  4. +
  5. Dart 侧没接线 —— vpn_bridge_provider.dart 目前把 Android 落到 VpnBridgeMock(只处理了 macOS/桌面)。
  6. +
  7. 三个 stub —— selectOutbound / getActiveOutbound / setKillSwitch 标了 TODO 11G。
  8. +
  9. 从没在真机/模拟器上端到端连过。
  10. +
+
+ +

2. 整体策略:自底向上「先构建,再对着真实 API 修」

+

最大未知是 libbox.aar 能不能构建出来、原生代码里那些「猜」的 API 名对不对。所以第一步就把这个雷踩掉,而不是最后才发现编译不过。

+

正确性锚点:官方 SagerNet/sing-box-for-android(SFA)的 Kotlin 源 + 构建出的 aar 里 classes.jar 的真实方法签名。现有 PangolinVpnService.kt 结构是照 SFA 写的,只是 API 名没校验过——拿真 aar 一比对即可定。

+ +

3. 里程碑(垂直切片,每片真机可验)

+ + + + + + + + + + +
里程碑目标验收
A. 构建内核跑通 build-android.sh 产出 libbox.aar(arm64/arm/amd64,tag 含 with_clash_apiaar 存在、unzip -l 三 ABI 的 .so
B. 编译链接Dart provider 接 Android→VpnNativeBridge;原生代码对着真 aar 修到 flutter build apk 通过APK 构建成功、flutter analyze 零警告
C. 端到端连通真机一键连 RackNerd 节点,TUN 起、DNS 不劫持失败、能打开被墙站点真机实测科学上网成功;UI 三态正确(连接键 off→connecting→on 严格由内核回调驱动,禁止乐观显示
D. 统计走字stats EventChannel 每秒推上/下行字节 + 速率统计页数字跳动,与系统流量大致吻合
E. 切节点(11G)selectOutbound(tag) 经 libbox CommandClient 做 group 选择(复刻桌面 selectProxy);getActiveOutbound 查询当前出口节点页切换出口不断连、当前出口高亮正确
F. KillSwitch(11G)setKillSwitch(on)L1(strict_route)+ 引导系统 Always-on(L3)开关后内核停止即断网/恢复符合预期;UI 诚实标注「彻底防泄漏需到系统设置开启」
+ +

4. 关键技术决策(与桌面/契约对齐,不自创)

+ + +

5. 三处定调(用户已确认)

+
+

① KillSwitch(里程碑 F)

+

做「L1 + 引导 L3」的组合——setKillSwitch(on)strict_route(与 Windows 桌面统一)+ 在 UI 诚实标注「彻底防泄漏需到系统设置开 Always-on」,并提供跳转引导。不假装 app 内能做到真 KillSwitch。 背景与分级见 KillSwitch 设计知识库

+
+
+

② 工具链

+

默认降 Go 到 1.24.3、保 gomobile pin,贴 sing-box 官方 SFA 验证组合;不升 pin。(本机现为 Go 1.26.1,gomobile pin 是 2024-06 旧版,新 Go + 旧 gomobile 可能 gomobile bind 失败。)

+
+
+

③ 测试基线(里程碑 C)

+

先 x86_64 模拟器跑通(环境最干净、最贴官方验证、排除真机变量)→ Vivo X200(Android 16 / API 36,AOSP 系,主力真机)华为 HarmonyOS 4.2(兼容 Android 版,次要兼容性抽查)。华为后台保活激进、电池优化引导 Intent 需单独适配。

+
+ +

6. 已知风险 / 坑

+
    +
  1. Go 1.26 vs gomobile 旧 pin:若 gomobile bind 失败,按定调降 Go 1.24.3(保 pin)。
  2. +
  3. 当前没有设备连着adb devices 为空)——里程碑 C 起需插真机/开模拟器。模拟器为 x86_64,aar 必须含 amd64(脚本已含)。
  4. +
  5. libbox API 漂移:现有 .kt 方法名是猜的,里程碑 B 逐一对平(TunOptions getter 已 try-catch 兜底)。
  6. +
  7. Android 14/16 前台服务 specialUse:manifest 已声明;targetSdk 拉到 36 时复测 FGS 启动是否被限。国内分发免审;未来上 Play 需补用途说明。
  8. +
  9. 电池优化 / 厂商保活:现有代码会弹豁免引导;华为/Vivo 后台管理激进,需引导用户允许后台 + 自启动,且厂商设置页深链可能落不准。
  10. +
+ +

7. 不在本次范围(YAGNI)

+ + +

8. 参考位置索引

+ + + + + + + + + + + + + + + +
主题文件
桥接契约client/lib/bridge/vpn_bridge.dart
平台分派(需加 Android→Native)client/lib/bridge/vpn_bridge_provider.dart
连接状态机(调 bridge.start)client/lib/state/connection_provider.dart
取 configclient/lib/services/connect_api.dart
Android 通道注册client/android/app/src/main/kotlin/com/pangolin/pangolin_vpn/MainActivity.kt
Android VPN 服务client/android/app/src/main/kotlin/com/pangolin/pangolin_vpn/PangolinVpnService.kt
内核构建脚本app/kernel/build-android.sh
内核版本锚点app/kernel/VERSION
桌面参考实现(selectOutbound/killSwitch)client/lib/bridge/desktop_vpn_bridge.dart
服务端 config 渲染server/internal/httpapi/clientconfig.go
KillSwitch 设计知识库docs/killswitch-design.html
+ +
+ + diff --git a/docs/index.html b/docs/index.html new file mode 100644 index 0000000..9131ec1 --- /dev/null +++ b/docs/index.html @@ -0,0 +1,121 @@ + + + + + +Pangolin 文档索引 + + + +
+ +

Pangolin 文档索引

+

全部设计 / 调研 / 排障文档汇总 · 单一入口

+ +
+约定:新的设计文档 / 调研文档统一以 HTML 形式输出(深色家族风格,可直接 file:// 打开),并登记到本索引。历史 .md 文档保留原状、逐步迁移。 +
+ +

设计方案 / Specs

+ +
Pangolin Android 客户端设计方案 HTML
+
把 M2 纸面 PoC 推到真机端到端连通 + 切节点 + KillSwitch(清 TODO 11G)。里程碑 A–F、三处定调。
+
docs/android-client-design.html
+
+ +
Windows 客户端设计 MD
+
Windows 端 sing-box 子进程 + wintun 隧道客户端设计。
+
docs/superpowers/specs/2026-06-21-windows-client-design.md
+
+ +

实现计划 / Plans

+ +
Android 客户端实现计划 MD
+
11 个任务:Go 1.24.3 工具链 → 构建 libbox.aar → 解析真实 API → Dart 接线 → 修原生编译 → 模拟器端到端 → 统计 → 切节点 → KillSwitch → 真机验证。
+
docs/superpowers/plans/2026-06-22-android-client.md
+
+ +
Windows 客户端实现计划 MD
+
Windows 客户端的分步实现计划。
+
docs/superpowers/plans/2026-06-21-windows-client.md
+
+ +

知识库 / 调研

+ +
KillSwitch 设计与跨平台能力矩阵 HTML
+
断网保护 L0–L3 分级模型 + 各平台能力天花板 / 当前实现矩阵。KillSwitch 决策依据。
+
docs/killswitch-design.html
+
+ +
技术方案(总) MD
+
Pangolin 整体技术选型与架构方案。
+
docs/技术方案.md
+
+ +
VPN 内核内嵌 MD
+
sing-box 内核在各端的内嵌方式调研。
+
docs/vpn-core-embedding.md
+
+ +
VPN 测试调研 MD
+
VPN 黑盒测试方法与口径调研。
+
docs/vpn-testing-research.md
+
+ +
VPN 测试计划 MD
+
国内外站点矩阵 + 延迟/连通性测试计划。
+
docs/vpn-test-plan.md
+
+ +

排障 / Runbook

+ +
macOS 系统扩展 realize 失败(code=4)踩坑复盘 HTML
+
PacketTunnel 系统扩展无法激活的三个叠加配置 bug 排查与修复。
+
docs/macos-sysext-realize-troubleshooting.html
+
+ +
P1 macOS 系统扩展 MD
+
macOS 原生 System Extension 隧道方案说明。
+
docs/p1-macos-system-extension.md
+
+ +
节点重建检查单 MD
+
节点重装/重建的操作检查清单。
+
docs/node-rebuild-checklist.md
+
+ +
Scheduler Runbook MD
+
调度器运维手册。
+
docs/runbook-scheduler.md
+
+ +
+ + diff --git a/docs/killswitch-design.html b/docs/killswitch-design.html new file mode 100644 index 0000000..79e8c3c --- /dev/null +++ b/docs/killswitch-design.html @@ -0,0 +1,151 @@ + + + + + +KillSwitch 设计与跨平台能力矩阵(知识库) + + + +
+ +← 文档索引 + +

KillSwitch 设计与跨平台能力矩阵(知识库)

+

Pangolin 客户端 · 断网保护设计依据与现状 · 2026-06-22

+ +
+KillSwitch 不是「一个开关」,而是分层能力——理想态、平台天花板、当前实现是三件不同的事。本文沉淀其设计依据与现状。 +
+ +

1. 本质:fail-closed

+

KillSwitch 的本质是 fail-closed

+
隧道不处于活动状态时,禁止任何流量走明文/默认路径出去。
+

目标是堵住 VPN 掉线瞬间的 IP / DNS 泄漏。没有 KillSwitch 时,隧道一掉,流量静默回落到运营商默认路由,用户真实 IP 与 DNS 查询直接暴露——对科学上网场景是致命泄漏。

+ +

2. 理想设计的 5 条属性

+
    +
  1. 默认关闭(fail-closed):隧道一旦不可用,流量是「被丢弃」而非「放行」。
  2. +
  3. 扛得住进程死亡 ← 最难。app/内核进程崩溃或被系统杀掉后,封锁依然生效。这要求封锁由 OS 内核/框架强制,而不是靠 app 进程内的路由表。
  4. +
  5. 覆盖所有「缺口窗口」:开机后还没连上的窗口、掉线重连的窗口、进程崩溃的窗口——三个都要堵。
  6. +
  7. DNS 防泄漏:DNS 查询也强制走隧道或被一并阻断。
  8. +
  9. 可控范围 + 诚实 UI:可选放行 LAN(局域网打印机等)、可选分应用;并如实告诉用户当前到底保护到了哪一层,不夸大。
  10. +
+ +

3. 能力分级(统一标尺)

+ + + + + + + + +
Level机制能堵什么堵不住什么
L0 无掉线即走明文全泄漏
L1 内核路由绑定(软)路由绑死隧道接口(如 sing-box strict_route进程活着、隧道未建/重连时不泄漏进程一死、接口被拆 → 路由恢复 → 泄漏
L2 OS 防火墙强制(硬)OS 级包过滤规则(NE 框架 / WFP / nftables)阻断非隧道流量进程死了也照堵(规则在 OS 内核,不在 app)开机到规则生效前的窗口
L3 常开 + 开机持久L2 + OS 从开机起自动拉起 VPN 并强制阻断开机窗口也堵,全程 fail-closed(基本无死角)
+

理想态 = L3。

+
+关键认知:L1 与 L2 之间有一条质变线——L1 是「app 进程内的约束」,L2/L3 是「OS 框架的约束」。只有跨过这条线才算「真 KillSwitch」。目前 Pangolin 各平台用的 strict_route 都还停在 L1。 +
+ +

4. 各平台能力天花板

+ + + + + + + + + +
平台机制天花板说明
macOSNetworkExtension System ExtensionL3includeAllNetworks=true 全量入隧道 + NEOnDemandRule 常开 + enforceRoutes。NE 守护进程(neagent)在系统级强制,扛 app 崩溃。Apple 平台原生支持最完整。
iOSNetworkExtensionL3同 NE 原语,甚至更干净;on-demand「按需常开」。⚠️ 目前无 iOS 客户端
AndroidVpnServiceL3,但有条件app 内只能到 L1strict_route);真正的 L2/L3 = 系统设置「始终开启 VPN + 无 VPN 时阻止连接」,OS 级强制、扛崩溃、开机生效。但 app 不能编程开启,只能深链引导用户手动开(或 Device Owner/MDM 下发)。
Windows子进程 sing-box + wintunL2(需开发)现仅 strict_route(L1)。要到 L2 须让 app 装 WFP(Windows Filtering Platform)过滤器阻断非隧道流量;做成系统服务持久化才能扛崩溃。
Linux(非主目标)子进程 + tunL2(需开发)L1 现成;L2 靠 nftables/iptables killswitch 链。
+

一句话:Apple 两端原生能直达 L3;Android 能到 L3 但要用户手动配合;Windows/Linux 要自己写 OS 防火墙规则才能上 L2。

+ +

5. 各平台当前实际实现(代码事实)

+ + + + + + + + + +
平台当前 Level真实状态代码位置
WindowsL1✅ 改 strict_route + 子进程重载 + 退避重连。进程被硬杀仍泄漏。desktop_vpn_bridge.dart:305applyKillSwitchToConfig)、:178
LinuxL1同 Windows(共用 DesktopVpnBridge)。同上
macOSL0❌ stub:setKillSwitch 直接 result(nil)includeAllNetworks()→false。天花板 L3,实际啥都没做。VpnChannel.swift:69PacketTunnelProvider.swift:224
AndroidL0❌ stub:setKillSwitch 只打日志(TODO 11G)。MainActivity.ktsetKillSwitch 分支)
iOS无客户端。
+

注:macOS 自 kUseNativeVpnMacOS=truevpn_bridge_provider.dart:19)起走原生 System Extension,不再走 DesktopVpnBridge。所以 macOS 的 strict_route(Windows 路线)对它不生效,必须走 NE 原生路线。

+ +

6. Pangolin 现实判断与决策

+ + +

7. 关联待办

+

KillSwitch 相关待办见项目 todo//todo list),主要三条:

+ + +

8. 参考实现位置索引

+ + + + + + + + + + + +
主题文件
桥接契约(setKillSwitch 方法签名)client/lib/bridge/vpn_bridge.dart
平台分派(哪个平台走哪个 bridge)client/lib/bridge/vpn_bridge_provider.dart
Windows/Linux 实现(strict_route)client/lib/bridge/desktop_vpn_bridge.dart
macOS 原生通道(stub)client/macos/Runner/VpnChannel.swift
macOS 隧道 Providerclient/macos/PacketTunnel/PacketTunnelProvider.swift
Android 通道(stub)client/android/app/src/main/kotlin/com/pangolin/pangolin_vpn/MainActivity.kt
Android VPN 服务client/android/app/src/main/kotlin/com/pangolin/pangolin_vpn/PangolinVpnService.kt
+ +
+ + diff --git a/docs/superpowers/plans/2026-06-22-android-client.md b/docs/superpowers/plans/2026-06-22-android-client.md new file mode 100644 index 0000000..d7e5ad1 --- /dev/null +++ b/docs/superpowers/plans/2026-06-22-android-client.md @@ -0,0 +1,438 @@ +# Pangolin Android 客户端 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 把已有的 M2 Android PoC 推到真机能连真实节点出网,并补齐切节点 + KillSwitch(清 TODO 11G)。 + +**Architecture:** Flutter UI 全平台共享、零改动。Android 走 `VpnService` + 内嵌 sing-box `libbox.aar`(gomobile 绑定),经冻结的 `pangolin/vpn` MethodChannel/EventChannel 与 Dart 通信。配置由服务端渲染、客户端原样下发。本质是「先构建 aar → 对着真实 libbox API 修原生代码到能编译运行 → 接线 Dart → 真机端到端 → 补 11G」。 + +**Tech Stack:** Flutter 3.44 / Dart、Kotlin、Android `VpnService`、sing-box `v1.13.12` libbox(gomobile)、Go 1.24.3、Android NDK。 + +**设计依据:** `docs/android-client-design.html`、`docs/killswitch-design.html`。 + +## Global Constraints + +- sing-box 内核版本 `v1.13.12`(`app/kernel/VERSION` 锚定,禁止脚本内硬编码)。 +- Go 工具链 **降到 `1.24.3`、保 gomobile pin `v0.0.0-20240604150348-70c4c5da7d30`**(贴 SFA 官方验证组合;本机 Go 1.26.1 不动,用官方 `golang.org/dl/go1.24.3` 并存)。 +- libbox build tags:`with_quic,with_utls,with_clash_api,with_gvisor`(脚本默认,勿改)。 +- `minSdkVersion 21`(libbox `-androidapi 21` 要求),`targetSdkVersion` 跟随 flutter。 +- Channel 契约冻结(`pangolin/vpn` + `pangolin/vpn/status` + `pangolin/vpn/stats`),方法签名见 `client/lib/bridge/vpn_bridge.dart`,**不得私改**。 +- 配置来源:服务端 `POST /v1/nodes/:id/connect`,**客户端绝不拼配置**。 +- **禁止乐观显示**:连接键 `on` 必须由 `bridge.statusStream`(内核回调)确认,`start()` 返回 ≠ on。 +- 测试基线:x86_64 模拟器跑通 → Vivo X200(Android 16)主力真机 → 华为 HarmonyOS 4.2 兼容性抽查。 +- NDK:`/Users/wangjia/Library/Android/sdk/ndk/27.0.12077973`(构建前 export `ANDROID_NDK_HOME`)。 + +--- + +### Task 1: Go 1.24.3 工具链就位 + +**Files:** 无(环境配置)。 + +**Interfaces:** +- Produces: 可执行 `go1.24.3`(`$(go env GOPATH)/bin/go1.24.3`),供 Task 2 构建用。 + +- [ ] **Step 1: 安装官方 Go 1.24.3 多版本 shim** + +Run: +```bash +go install golang.org/dl/go1.24.3@latest +go1.24.3 download +``` +Expected: `Downloaded ... go1.24.3 ...`,无错误。 + +- [ ] **Step 2: 验证版本与 NDK** + +Run: +```bash +go1.24.3 version +ls -d /Users/wangjia/Library/Android/sdk/ndk/27.0.12077973 +``` +Expected: `go version go1.24.3 darwin/arm64`;NDK 目录存在。 + +- [ ] **Step 3: 无需 commit**(纯环境,无文件改动)。 + +--- + +### Task 2: 构建 libbox.aar(里程碑 A) + +**Files:** +- 产出: `app/kernel/dist/android/libbox.aar`(git 忽略产物,不入库)。 + +**Interfaces:** +- Consumes: Task 1 的 `go1.24.3`。 +- Produces: `libbox.aar`,含 `go.libbox.*` 类,供 Task 3 解析、Task 5 链接。 + +- [ ] **Step 1: 用 Go 1.24.3 跑构建脚本** + +`build-android.sh` 内部用 `command -v go` 取 Go,需临时让 `go` 指向 1.24.3。用 PATH 注入 shim 目录的方式(不改脚本): + +Run: +```bash +cd /Users/wangjia/code/pangolin/.claude/worktrees/feature+android/app/kernel +export ANDROID_NDK_HOME=/Users/wangjia/Library/Android/sdk/ndk/27.0.12077973 +GOROOT_1243="$(go1.24.3 env GOROOT)" +PATH="${GOROOT_1243}/bin:${PATH}" go version # 确认此 shell 内 go=1.24.3 +PATH="${GOROOT_1243}/bin:${PATH}" bash build-android.sh +``` +Expected: 末尾 `✓ 构建完成: .../dist/android/libbox.aar` + 三个 `.so`(arm64-v8a/armeabi-v7a/x86_64)列出。 +注意:首次会 `git clone sing-box`(约数分钟)+ `gomobile bind`(数分钟~十几分钟)。 + +- [ ] **Step 2: 验证产物三 ABI 齐** + +Run: +```bash +unzip -l app/kernel/dist/android/libbox.aar | grep -E '\.so$' +``` +Expected: 至少 `jni/arm64-v8a/libgojni.so`、`jni/armeabi-v7a/...`、`jni/x86_64/...` 三条。 + +- [ ] **Step 3: 无需 commit**(aar 为忽略产物)。若 `gomobile bind` 失败,记录报错——多半是 Go/gomobile 组合问题,回到 Task 1 复核版本。 + +--- + +### Task 3: 解析真实 libbox API(里程碑 B 发现) + +**Files:** +- 创建: `app/kernel/dist/android/libbox-api.txt`(javap 输出存档,便于 Task 5/9 对照;可入库或留作临时参考)。 + +**Interfaces:** +- Consumes: Task 2 的 `libbox.aar`。 +- Produces: `go.libbox` 关键类的真实方法签名清单(`Libbox`、`BoxService`、`TunOptions`、`PlatformInterface`、`CommandClient`、`CommandClientHandler`、`CommandClientOptions`、`StatusMessage`、`OutboundGroupIterator`)。 + +- [ ] **Step 1: 解包 aar 取 classes.jar,javap 导出签名** + +Run: +```bash +cd /Users/wangjia/code/pangolin/.claude/worktrees/feature+android/app/kernel/dist/android +mkdir -p .inspect && cd .inspect +unzip -o ../libbox.aar classes.jar >/dev/null +for c in Libbox BoxService TunOptions PlatformInterface CommandClient CommandClientHandler CommandClientOptions StatusMessage OutboundGroupIterator BoxService\$Companion; do + echo "===== go.libbox.$c =====" + javap -classpath classes.jar "go.libbox.$c" 2>/dev/null || echo "(类不存在,名称可能不同)" +done | tee ../libbox-api.txt +``` +Expected: 各类方法签名打印到屏幕与 `libbox-api.txt`。重点记录: +- 创建 BoxService 的入口(`Libbox.newService` / `new BoxService(...)` / 参数个数与类型)。 +- `TunOptions` 的 getter 真名(`getInet4Address()` vs `inet4Address()`、`getMTU()`/`getMtu()`)。 +- `CommandClient` 选 outbound 的方法(如 `selectOutbound(group, tag)`)。 +- `StatusMessage` 流量字段(`getUploadTotal()` 等)。 + +- [ ] **Step 2: 比对现有 Kotlin 的「猜测点」** + +Run: +```bash +grep -n "// libbox API" /Users/wangjia/code/pangolin/.claude/worktrees/feature+android/client/android/app/src/main/kotlin/com/pangolin/pangolin_vpn/PangolinVpnService.kt +``` +Expected: 列出所有标注「libbox API」的行;逐条对照 `libbox-api.txt` 标记需改的方法名(用于 Task 5)。 + +- [ ] **Step 3: Commit(存档 API 清单)** + +```bash +cd /Users/wangjia/code/pangolin/.claude/worktrees/feature+android +git add app/kernel/dist/android/libbox-api.txt +git commit -m "docs(android): 存档 libbox.aar 真实 API 签名清单(Task 3)" +``` +(若 `dist/` 被 .gitignore 忽略,改存到 `docs/` 下并相应调整路径。) + +--- + +### Task 4: Dart provider 接 Android → VpnNativeBridge + +**Files:** +- Modify: `client/lib/bridge/vpn_bridge_provider.dart`(约 27 行,加 Android 分派)。 + +**Interfaces:** +- Consumes: 现有 `VpnNativeBridge`(`vpn_bridge.dart`,走 `pangolin/vpn` 通道)。 +- Produces: Android 运行时返回 `VpnNativeBridge` 而非 mock。 + +- [ ] **Step 1: 改平台分派** + +把 `vpn_bridge_provider.dart` 中: +```dart + if (Platform.isMacOS || Platform.isLinux || Platform.isWindows) { + return DesktopVpnBridge(); + } +``` +之前加入 Android 分支: +```dart + if (Platform.isAndroid) { + return VpnNativeBridge(); + } +``` +并把文件顶部注释「其他平台(iOS / Web / 测试)→ VpnBridgeMock」更新为「iOS / Web / 测试 → VpnBridgeMock;Android → VpnNativeBridge」。 + +- [ ] **Step 2: 静态分析** + +Run: +```bash +cd /Users/wangjia/code/pangolin/.claude/worktrees/feature+android/client +flutter analyze lib/bridge/vpn_bridge_provider.dart +``` +Expected: `No issues found!` + +- [ ] **Step 3: Commit** + +```bash +cd /Users/wangjia/code/pangolin/.claude/worktrees/feature+android +git add client/lib/bridge/vpn_bridge_provider.dart +git commit -m "feat(android): Dart provider 接 Android→VpnNativeBridge(Task 4)" +``` + +--- + +### Task 5: 修原生代码对齐真实 API 并编译通过(里程碑 B) + +**Files:** +- Modify: `client/android/app/src/main/kotlin/com/pangolin/pangolin_vpn/PangolinVpnService.kt`(按 Task 3 清单逐处校正方法名)。 +- Modify(按需): `MainActivity.kt`、`VpnEventBus.kt`。 + +**Interfaces:** +- Consumes: Task 2 的 `libbox.aar`(`build.gradle` 已配 `implementation files(libboxAar)`)、Task 3 的 `libbox-api.txt`。 +- Produces: 可编译的 debug APK。 + +- [ ] **Step 1: 按 Task 3 清单校正 `PangolinVpnService.kt` 的 libbox 调用** + +对照 `libbox-api.txt`,逐处替换标注 `// libbox API` 的方法名(`Libbox.newBoxService`/`service.start()`/`TunOptions.inet4Address()`/`mtu()`/`CommandClientOptions` 字段/`StatusMessage` getter 等)为真实签名。`TunOptions` getter 已有 try-catch 兜底,优先改成真名以免走兜底默认值。 + +- [ ] **Step 2: 构建 debug APK(只 arm64 加速首轮)** + +Run: +```bash +cd /Users/wangjia/code/pangolin/.claude/worktrees/feature+android/client +flutter build apk --debug --target-platform android-arm64 +``` +Expected: `✓ Built build/app/outputs/flutter-apk/app-debug.apk`。若报 Kotlin 编译错(未知方法/类型不符)→ 回 Step 1 对照 `libbox-api.txt` 再修。 + +- [ ] **Step 3: 全量 analyze** + +Run: +```bash +flutter analyze +``` +Expected: `No issues found!`(或仅既有无关告警)。 + +- [ ] **Step 4: Commit** + +```bash +cd /Users/wangjia/code/pangolin/.claude/worktrees/feature+android +git add client/android/app/src/main/kotlin/com/pangolin/pangolin_vpn/ +git commit -m "fix(android): PangolinVpnService 对齐真实 libbox API,编译通过(Task 5)" +``` + +--- + +### Task 6: 模拟器冒烟——启动 + 通道连通 + +**Files:** 无代码改动(验证任务)。 + +**Interfaces:** +- Consumes: Task 4/5 产物。 +- Produces: 确认 app 在 x86_64 模拟器启动、通道注册、初始状态 `off`。 + +- [ ] **Step 1: 起 x86_64 模拟器并装运行** + +Run: +```bash +adb devices # 确认有 emulator-xxxx;无则先在 Android Studio 启一个 x86_64 AVD +cd /Users/wangjia/code/pangolin/.claude/worktrees/feature+android/client +flutter run -d emulator --debug # 或 flutter run 选模拟器 +``` +Expected: app 启动到登录/主页,无崩溃。 + +- [ ] **Step 2: 验证通道初始状态** + +观察 logcat: +```bash +adb logcat -s PangolinMainActivity PangolinVpnService flutter | head -40 +``` +Expected: 见 `MethodChannel: getStatus`、status channel `onListen` 推 `off`,UI 连接键为 off 态。 + +- [ ] **Step 3: 无需 commit。** + +--- + +### Task 7: 模拟器端到端连通(里程碑 C) + +**Files:** 无代码改动(验证任务;若发现 openTun/DNS 问题则回 Task 5 修)。 + +**Interfaces:** +- Consumes: 服务端真实 config(经登录→选节点→`fetchConfig` 自动获取)。 +- Produces: 真隧道连通、可访问被墙站点。 + +- [ ] **Step 1: 登录并连接** + +在 app 内登录(真账号)→ 选 RackNerd 节点 → 点连接。授予 VPN 授权弹窗。 +观察: +```bash +adb logcat -s PangolinVpnService | grep -E "openTun|BoxService started|postStatus" +``` +Expected: `openTun: fd=...` → `BoxService started` → status `on`;连接键转 on(计时开始)。 + +- [ ] **Step 2: 验证真出网 + DNS(铁律)** + +在模拟器浏览器或 `adb shell` 访问被墙站点: +```bash +adb shell curl -s -o /dev/null -w "%{http_code} %{time_total}s\n" https://www.google.com +``` +Expected: `200 ...`。若超时但能连其它国内站 → 多半 DNS 劫持规则缺失,检查服务端下发 config 的 `route.rules` 首条是否 `hijack-dns`。 + +- [ ] **Step 3: 断开验证** + +点断开 → status `off`、连接键复位、`doStop` 日志干净(无重入崩溃)。 + +- [ ] **Step 4: 无代码则无 commit;若 Task 5 文件有修复则 commit。** + +--- + +### Task 8: 统计走字(里程碑 D) + +**Files:** 验证为主;若字段不准则 Modify `PangolinVpnService.kt`(`writeStatus` 映射)。 + +**Interfaces:** +- Consumes: libbox `CommandClient` STATUS(或 TrafficStats 兜底)。 +- Produces: `pangolin/vpn/stats` 每秒一帧,UI 统计页跳动。 + +- [ ] **Step 1: 观察 stats 帧** + +连接状态下: +```bash +adb logcat -s PangolinVpnService | grep -E "CommandClient connected|TrafficStats fallback" +``` +Expected: 见 `CommandClient connected`(优先)或兜底日志;UI 统计页上/下行字节与速率每秒更新。 + +- [ ] **Step 2: 校正字段(如需)** + +若速率恒 0 或字节不增 → 对照 Task 3 的 `StatusMessage` getter 真名修 `writeStatus` 映射,重编重测。 + +- [ ] **Step 3: Commit(如有修复)** + +```bash +git add client/android/app/src/main/kotlin/com/pangolin/pangolin_vpn/PangolinVpnService.kt +git commit -m "fix(android): 校正统计字段映射(Task 8)" +``` + +--- + +### Task 9: 切节点 selectOutbound + getActiveOutbound(里程碑 E / 11G) + +**Files:** +- Modify: `PangolinVpnService.kt`(加 libbox CommandClient group 选择 + 查询)。 +- Modify: `MainActivity.kt`(`selectOutbound`/`getActiveOutbound` 从 stub 改为转发到 Service)。 + +**Interfaces:** +- Consumes: Task 3 的 CommandClient 选 outbound API 签名;服务端 config 的出口组名(`auto` urltest + `reality-out`/`hy2-out`)。 +- Produces: `selectOutbound(tag)` 切换不断连;`getActiveOutbound()` 返回当前出口 tag。语义复刻桌面 `desktop_vpn_bridge.dart` 的 `selectProxy`/`getProxies`。 + +- [ ] **Step 1: 确认组名** + +```bash +grep -n "\"tag\"\|selector\|urltest\|proxyTags" /Users/wangjia/code/pangolin/.claude/worktrees/feature+android/server/internal/httpapi/clientconfig.go +``` +Expected: 确认可选组名(当前为 urltest `auto`;如需手动选需服务端含 `selector` 组——若没有则 selectOutbound 作用于 `auto` 组内成员或触发服务端补 selector,按实际 config 决定)。 + +- [ ] **Step 2: 实现 Service 侧 selectOutbound/getActiveOutbound** + +在 `PangolinVpnService` 暴露静态/单例方法,用 libbox `CommandClient`(按 Task 3 真名)执行 group 选择与查询;`MainActivity` 的对应 MethodChannel 分支从 stub 改为调用之。 + +- [ ] **Step 3: 编译 + 真机/模拟器验证** + +Run: +```bash +cd client && flutter build apk --debug --target-platform android-arm64 +``` +连接后在节点页切换出口:观察不断连、`getActiveOutbound` 返回值随之变、UI 高亮正确。 + +- [ ] **Step 4: Commit** + +```bash +git add client/android/app/src/main/kotlin/com/pangolin/pangolin_vpn/ +git commit -m "feat(android): selectOutbound/getActiveOutbound 经 libbox CommandClient 切节点(Task 9,11G)" +``` + +--- + +### Task 10: KillSwitch L1 + 引导系统 Always-on(里程碑 F / 11G) + +**Files:** +- Modify: `MainActivity.kt`(`setKillSwitch` 从 stub 改为:缓存偏好 + 触发以 `strict_route` 重启隧道;并暴露跳系统 Always-on 设置的方法)。 +- Modify: `PangolinVpnService.kt`(启动时按偏好将 config TUN `strict_route` 置位——可复用桌面 `applyKillSwitchToConfig` 的等价 JSON 改写逻辑,在 Kotlin 侧实现)。 +- Modify(UI 文案): `client/lib/l10n/strings_zh.dart` + `strings_en.dart`(KillSwitch 旁诚实标注「彻底防泄漏需到系统设置开启 Always-on」)。 + +**Interfaces:** +- Consumes: 设计依据 `docs/killswitch-design.html`(L1 语义 + 引导 L3)。 +- Produces: `setKillSwitch(on)` 落 `strict_route`(与 Windows 一致)+ 系统 Always-on 引导入口。 + +- [ ] **Step 1: Kotlin 侧实现 strict_route 改写** + +在 Service 启动前对 configJson 做 JSON 改写:`inbounds` 中 `type==tun` 的项设 `strict_route = on`。`setKillSwitch(on)` 若隧道在跑则以新偏好重启(短暂重连,与桌面一致)。 + +- [ ] **Step 2: Always-on 引导入口** + +`setKillSwitch(on)` 为 true 时(或 UI 按钮)跳转系统 VPN 设置: +```kotlin +startActivity(Intent("android.settings.VPN_SETTINGS")) +``` +(部分 ROM 落不到「Always-on」子页,落到 VPN 列表即可;华为/Vivo 单独适配留 Task 11。) + +- [ ] **Step 3: UI 文案诚实标注** + +在 KillSwitch 开关旁加说明文案(走 l10n,**无红线词**),明确 app 内 = L1、彻底防泄漏需系统 Always-on。 + +- [ ] **Step 4: 编译 + analyze + 验证** + +Run: +```bash +cd client && flutter build apk --debug --target-platform android-arm64 && flutter analyze +``` +连接下开 KillSwitch:观察重连后 `strict_route` 生效(停内核即断网);点引导跳到系统 VPN 设置。 + +- [ ] **Step 5: Commit** + +```bash +git add client/android/app/src/main/kotlin/com/pangolin/pangolin_vpn/ client/lib/l10n/ +git commit -m "feat(android): KillSwitch L1(strict_route)+引导系统 Always-on(Task 10,11G)" +``` + +--- + +### Task 11: 真机验证(Vivo X200 + 华为 4.2) + +**Files:** 验证为主;厂商保活引导若需适配则 Modify `MainActivity.kt`。 + +**Interfaces:** +- Consumes: 全部前序产物。 +- Produces: 真机端到端连通 + 后台保活验证 + 厂商电池优化引导适配。 + +- [ ] **Step 1: Vivo X200(Android 16,主力)全链路** + +插线 `adb devices` 确认 → `flutter run -d --release` 或装 release APK。跑通:登录→连接→访问被墙站→切节点→KillSwitch→断开。重点验证 **targetSdk 高版本下前台服务 specialUse 不被限**、后台挂 30 分钟不被杀(需在 Vivo 设置允许后台+自启动)。 + +- [ ] **Step 2: 华为 HarmonyOS 4.2(兼容抽查)** + +装 APK 跑通连接主链路。华为后台保活更激进——验证电池优化引导 Intent 是否落对页面;若落不到,加华为定制 Intent 兜底(`Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` 之外的 ROM 专属页)。 + +- [ ] **Step 3: Commit(如有厂商适配)** + +```bash +git add client/android/app/src/main/kotlin/com/pangolin/pangolin_vpn/MainActivity.kt +git commit -m "fix(android): 厂商(Vivo/华为)后台保活引导适配(Task 11)" +``` + +- [ ] **Step 4: 标记 todo #2 完成待验收** + +```bash +node ~/.claude/skills/todo/todo.mjs status 2 done +``` + +--- + +## 验收总览(对应里程碑) + +| 里程碑 | Task | 完成判据 | +|---|---|---| +| A 构建内核 | 2 | libbox.aar 三 ABI 齐 | +| B 编译链接 | 3–5 | debug APK 构建成功、analyze 零警告 | +| C 端到端连通 | 6–7 | 模拟器实测科学上网、UI 三态正确 | +| D 统计走字 | 8 | stats 每秒跳动且数值合理 | +| E 切节点 | 9 | 切出口不断连、当前出口正确 | +| F KillSwitch | 10 | strict_route 生效 + Always-on 引导 + 诚实文案 | +| 真机 | 11 | Vivo X200 全链路 + 华为兼容抽查 + 后台保活 | diff --git a/todo/todo.html b/todo/todo.html new file mode 100644 index 0000000..98ab342 --- /dev/null +++ b/todo/todo.html @@ -0,0 +1,645 @@ + + + + + +feature+windows — 项目 TODO + + + + +
+
+

feature+windows — 项目 TODO

+
生成于 2026-06-22 · 真相源 todo/todo.json
+
+
3全部
+
3待开始
+
0开发中
+
0待验收
+
0已验收
+ +
+
+
+ +
+
+ 重要度 + + + + +
+
+
+ 状态 + + + + + +
+
+
+ 改动等级 + + + + +
+
+
+ 平台 / 标签 + +
+
+ +
+ + +
+
+ 📋 待开始 3 + ▴ 收起 +
+
+
    + +
  • +
    + macOS 原生 KillSwitch 补齐 L0→L3 +
    + 待开始 + 重要 + 二级 + + +
    +
    + +
    现状 L0(stub):VpnChannel.swift:69 setKillSwitch 直接 result(nil)、includeAllNetworks()→false。天花板 L3。改 NetworkExtension:includeAllNetworks=true + NEOnDemandRule 常开 + enforceRoutes,由 neagent 系统级强制、扛 app 崩溃。性价比最高的跨端缺口。依据见 docs/killswitch-design.md §4-6。
    + + + +
  • + +
  • +
    + Android KillSwitch L1 + 引导系统 Always-on +
    + 待开始 + 重要 + 二级 + + +
    +
    + +
    现状 L0(stub):MainActivity.kt setKillSwitch 只打日志(TODO 11G)。目标:app 内做到 L1(config TUN strict_route,复刻 Windows 的 applyKillSwitchToConfig 语义) + 深链引导用户开系统设置「始终开启 VPN + 无 VPN 时阻止连接」拿 L3,UI 诚实标注「彻底防泄漏需在系统设置开启」。属 Android 客户端 11G。依据见 docs/killswitch-design.md §4-6。
    + + + +
  • + +
  • +
    + Windows KillSwitch L1→L2 (WFP 防火墙强制) +
    + 待开始 + 一般 / 优化 + 一级 + + +
    +
    + +
    现状 L1:desktop_vpn_bridge.dart strict_route + 子进程重载,进程被硬杀仍泄漏。目标 L2:app 装 WFP(Windows Filtering Platform) 过滤器阻断非隧道流量,做成系统服务持久化才能扛崩溃。工作量较大,backlog。依据见 docs/killswitch-design.md §4-6。
    + + + +
  • +
+
+
+
+
+ 🔨 开发中 0 + ▴ 收起 +
+
+
    +

    暂无条目

    +
+
+
+
+
+ 🔍 待验收 0 + ▴ 收起 +
+
+
    +

    暂无条目

    +
+
+
+
+
+ ✅ 已验收 0 + ▾ 展开 +
+ +
+ +
+ + + + + + + \ No newline at end of file diff --git a/todo/todo.json b/todo/todo.json new file mode 100644 index 0000000..48578ce --- /dev/null +++ b/todo/todo.json @@ -0,0 +1,54 @@ +{ + "meta": { + "title": "feature+windows — 项目 TODO", + "updated_at": "2026-06-22T09:32:56.170Z" + }, + "seq": 3, + "items": [ + { + "id": 1, + "title": "macOS 原生 KillSwitch 补齐 L0→L3", + "desc": "现状 L0(stub):VpnChannel.swift:69 setKillSwitch 直接 result(nil)、includeAllNetworks()→false。天花板 L3。改 NetworkExtension:includeAllNetworks=true + NEOnDemandRule 常开 + enforceRoutes,由 neagent 系统级强制、扛 app 崩溃。性价比最高的跨端缺口。依据见 docs/killswitch-design.md §4-6。", + "level": "mid", + "tier": 2, + "tags": [ + "mac" + ], + "status": "open", + "created_at": "2026-06-22T09:32:42.340Z", + "done": false, + "completed_at": null, + "version": null + }, + { + "id": 2, + "title": "Android KillSwitch L1 + 引导系统 Always-on", + "desc": "现状 L0(stub):MainActivity.kt setKillSwitch 只打日志(TODO 11G)。目标:app 内做到 L1(config TUN strict_route,复刻 Windows 的 applyKillSwitchToConfig 语义) + 深链引导用户开系统设置「始终开启 VPN + 无 VPN 时阻止连接」拿 L3,UI 诚实标注「彻底防泄漏需在系统设置开启」。属 Android 客户端 11G。依据见 docs/killswitch-design.md §4-6。", + "level": "mid", + "tier": 2, + "tags": [ + "Android" + ], + "status": "open", + "created_at": "2026-06-22T09:32:56.098Z", + "done": false, + "completed_at": null, + "version": null + }, + { + "id": 3, + "title": "Windows KillSwitch L1→L2 (WFP 防火墙强制)", + "desc": "现状 L1:desktop_vpn_bridge.dart strict_route + 子进程重载,进程被硬杀仍泄漏。目标 L2:app 装 WFP(Windows Filtering Platform) 过滤器阻断非隧道流量,做成系统服务持久化才能扛崩溃。工作量较大,backlog。依据见 docs/killswitch-design.md §4-6。", + "level": "low", + "tier": 1, + "tags": [ + "Windows" + ], + "status": "open", + "created_at": "2026-06-22T09:32:56.169Z", + "done": false, + "completed_at": null, + "version": null + } + ] +} From 82741ce0c4d26db18fc0e117653ede8c13de1349 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Mon, 22 Jun 2026 18:48:58 +0800 Subject: [PATCH 2/8] =?UTF-8?q?feat(android):=20Dart=20provider=20?= =?UTF-8?q?=E6=8E=A5=20Android=E2=86=92VpnNativeBridge(Task=204)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- client/lib/bridge/vpn_bridge_provider.dart | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/client/lib/bridge/vpn_bridge_provider.dart b/client/lib/bridge/vpn_bridge_provider.dart index 9eebecb..571e264 100644 --- a/client/lib/bridge/vpn_bridge_provider.dart +++ b/client/lib/bridge/vpn_bridge_provider.dart @@ -1,6 +1,7 @@ // vpn_bridge_provider.dart — VpnBridge 平台分派 // // macOS / Linux / Windows → DesktopVpnBridge(真实子进程) +// Android → VpnNativeBridge(内嵌 libbox 的 VpnService,走 pangolin/vpn 通道) // 其他平台(iOS / Web / 测试)→ VpnBridgeMock import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -24,6 +25,9 @@ final vpnBridgeProvider = Provider((ref) { if (Platform.isMacOS && kUseNativeVpnMacOS) { return VpnNativeBridge(); } + if (Platform.isAndroid) { + return VpnNativeBridge(); + } if (Platform.isMacOS || Platform.isLinux || Platform.isWindows) { return DesktopVpnBridge(); } From f298b207fe34b1ed0dd532d585b193a46925ce55 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Mon, 22 Jun 2026 18:48:58 +0800 Subject: [PATCH 3/8] =?UTF-8?q?fix(android/kernel):=20gomobile=20=E6=94=B9?= =?UTF-8?q?=E7=94=A8=20SagerNet=20fork=20v0.1.12=20+=20=E8=A1=A5=20linknam?= =?UTF-8?q?e=20tags?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原 pin golang.org/x/mobile@v0.0.0-20240604...70c4c5da7d30 是失效 revision; 对照 sing-box v1.13.12 go.mod 与 cmd/internal/build_libbox,官方用 github.com/sagernet/gomobile@v0.1.12;补 badlinkname,tfogo_checklinkname0 以适配 Go 1.24 的 //go:linkname 限制。Task 2 blocker 修复。 Co-Authored-By: Claude Opus 4.8 --- app/kernel/VERSION | 10 +++++++--- app/kernel/build-android.sh | 13 ++++++++----- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/app/kernel/VERSION b/app/kernel/VERSION index d493e89..dec1492 100644 --- a/app/kernel/VERSION +++ b/app/kernel/VERSION @@ -5,12 +5,16 @@ # SINGBOX_VERSION : 改为目标 GitHub tag(如 v1.14.0) # 同步更新 fetch-desktop-bin.sh 中的桌面 SHA256 说明 # GO_VERSION : 与官方 SFA/SFI CI 保持同步(go1.x.y) -# GOMOBILE_VERSION : 对应 golang.org/x/mobile 的 pseudo-version 或 tag -# go install golang.org/x/mobile/cmd/gomobile@ +# GOMOBILE_MODULE : gomobile 模块路径。sing-box 官方用 SagerNet fork +# (github.com/sagernet/gomobile),非上游 golang.org/x/mobile。 +# 对照 sing-box 的 go.mod 与 cmd/internal/build_libbox。 +# GOMOBILE_VERSION : GOMOBILE_MODULE 的版本 tag(与 sing-box go.mod 同步) +# go install ${GOMOBILE_MODULE}/cmd/gomobile@ # WINTUN_VERSION : Wintun 驱动版本(Windows 专用),保持与 sing-box 推荐版本同步 # 下载: https://www.wintun.net/builds/wintun-.zip SINGBOX_VERSION=v1.13.12 GO_VERSION=1.24.3 -GOMOBILE_VERSION=v0.0.0-20240604150348-70c4c5da7d30 +GOMOBILE_MODULE=github.com/sagernet/gomobile +GOMOBILE_VERSION=v0.1.12 WINTUN_VERSION=0.14.1 diff --git a/app/kernel/build-android.sh b/app/kernel/build-android.sh index 33e1fb3..06bd88f 100644 --- a/app/kernel/build-android.sh +++ b/app/kernel/build-android.sh @@ -46,7 +46,10 @@ OUT_AAR="${DIST_DIR}/libbox.aar" BUILD_SRC="${SCRIPT_DIR}/.build/singbox" # ── Build tags ──────────────────────────────────────────────────────────────── -BUILD_TAGS="with_quic,with_utls,with_clash_api,with_gvisor" +# 功能 tags(最小集:REALITY/VLESS + Hysteria2(quic) + uTLS + Clash API + gVisor)。 +# badlinkname,tfogo_checklinkname0 = Go 1.23+ 的 //go:linkname 限制放行 +# (对齐 sing-box cmd/internal/build_libbox 的 sharedTags,缺则 Go 1.24 编译失败)。 +BUILD_TAGS="with_quic,with_utls,with_clash_api,with_gvisor,badlinkname,tfogo_checklinkname0" # 取消注释以增加功能(会增大 .so 体积): # BUILD_TAGS="${BUILD_TAGS},with_grpc" # BUILD_TAGS="${BUILD_TAGS},with_ech" @@ -81,10 +84,10 @@ else printf '⚠ 未设置 ANDROID_NDK_HOME / ANDROID_HOME,gomobile init 可能失败\n' >&2 fi -# ── 安装 gomobile ───────────────────────────────────────────────────────────── -printf '==> 安装 gomobile@%s…\n' "${GOMOBILE_VERSION}" -go install "golang.org/x/mobile/cmd/gomobile@${GOMOBILE_VERSION}" -go install "golang.org/x/mobile/cmd/gobind@${GOMOBILE_VERSION}" +# ── 安装 gomobile(SagerNet fork,见 VERSION 注释)──────────────────────────── +printf '==> 安装 %s/cmd/gomobile@%s…\n' "${GOMOBILE_MODULE}" "${GOMOBILE_VERSION}" +go install "${GOMOBILE_MODULE}/cmd/gomobile@${GOMOBILE_VERSION}" +go install "${GOMOBILE_MODULE}/cmd/gobind@${GOMOBILE_VERSION}" printf '==> gomobile init…\n' gomobile init From 2a8426c357013ddcd2fb0004b6a3b595c92a9070 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Mon, 22 Jun 2026 18:59:14 +0800 Subject: [PATCH 4/8] =?UTF-8?q?fix(android/kernel):=20=E8=A1=A5=20-checkli?= =?UTF-8?q?nkname=3D0=20ldflags=20+=20JDK=20=E8=87=AA=E5=8A=A8=E6=8E=A2?= =?UTF-8?q?=E6=B5=8B,=E6=9E=84=E5=BB=BA=E5=87=BA=2040MB=20libbox.aar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 对齐 sing-box build_libbox sharedFlags 的 -ldflags(含 -checklinkname=0 放行 badlinkname 的 //go:linkname;-trimpath/-buildvcs=false/版本戳); JDK 自动选 Android Studio JBR(macOS /usr/bin/javac 仅桩)。Task 2 完成: 三 ABI(arm64/armeabi-v7a/x86_64) libgojni.so 齐。 Co-Authored-By: Claude Opus 4.8 --- app/kernel/build-android.sh | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/app/kernel/build-android.sh b/app/kernel/build-android.sh index 06bd88f..f98f161 100644 --- a/app/kernel/build-android.sh +++ b/app/kernel/build-android.sh @@ -84,6 +84,22 @@ else printf '⚠ 未设置 ANDROID_NDK_HOME / ANDROID_HOME,gomobile init 可能失败\n' >&2 fi +# ── JDK(gomobile bind 末段要 javac/jar 编译 Java 绑定)───────────────────────── +# macOS 的 /usr/bin/javac 仅为桩(无系统 JDK 时报 "Unable to locate a Java Runtime")。 +# 若未显式设 JAVA_HOME,优先用 Android Studio 自带 JBR(OpenJDK 21)。 +if [[ -z "${JAVA_HOME:-}" ]]; then + AS_JBR="/Applications/Android Studio.app/Contents/jbr/Contents/Home" + if [[ -x "${AS_JBR}/bin/javac" ]]; then + export JAVA_HOME="${AS_JBR}" + export PATH="${JAVA_HOME}/bin:${PATH}" + printf ' JDK: %s(Android Studio JBR)\n' "${JAVA_HOME}" + else + printf '⚠ 未设 JAVA_HOME 且未找到 Android Studio JBR,gomobile bind 末段 javac 可能失败\n' >&2 + fi +else + printf ' JDK: %s(JAVA_HOME)\n' "${JAVA_HOME}" +fi + # ── 安装 gomobile(SagerNet fork,见 VERSION 注释)──────────────────────────── printf '==> 安装 %s/cmd/gomobile@%s…\n' "${GOMOBILE_MODULE}" "${GOMOBILE_VERSION}" go install "${GOMOBILE_MODULE}/cmd/gomobile@${GOMOBILE_VERSION}" @@ -120,10 +136,22 @@ printf ' tags : %s\n' "${BUILD_TAGS}" mkdir -p "${DIST_DIR}" +# ldflags 对齐 sing-box cmd/internal/build_libbox 的 sharedFlags: +# -checklinkname=0 放行 badlinkname 的 //go:linkname(Go 1.23+ 默认 checklinkname=1 +# 会拒 os.checkPidfdOnce 等内部符号引用,缺则链接失败) +# defaultGODEBUG=multipathtcp=0 关 MPTCP(与官方一致) +# -X ...constant.Version 版本戳 +# -s -w -buildid= 裁剪符号表,产物更小 +LDFLAGS="-X github.com/sagernet/sing-box/constant.Version=${SINGBOX_VERSION} -X internal/godebug.defaultGODEBUG=multipathtcp=0 -s -w -buildid= -checklinkname=0" + ( cd "${BUILD_SRC}" go mod download gomobile bind \ + -v \ + -trimpath \ + -buildvcs=false \ + -ldflags "${LDFLAGS}" \ -target android/arm64,android/arm,android/amd64 \ -androidapi 21 \ -tags "${BUILD_TAGS}" \ From 126c06833deace69fe79b78bb00a8ab7631fc6e1 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Mon, 22 Jun 2026 19:28:24 +0800 Subject: [PATCH 5/8] =?UTF-8?q?feat(android):=20libbox=20=E9=9A=A7?= =?UTF-8?q?=E9=81=93=E9=87=8D=E5=86=99=E5=AF=B9=E6=8E=A5=E7=9C=9F=E5=AE=9E?= =?UTF-8?q?=201.13.12=20API,APK=20=E7=BC=96=E8=AF=91=E9=80=9A=E8=BF=87(Tas?= =?UTF-8?q?k=205)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原生层照 PoC 旧/想象 API 写,与真实 sing-box 1.13.12 libbox 差距是架构级: - 包名 libbox(非 go.libbox);无 BoxService,改 Libbox.setup + CommandServer 模型 - PangolinVpnService 重写:实现完整 PlatformInterface(15法)+CommandServerHandler - 新增 DefaultNetworkMonitor(上报默认接口,缺则报 no available network) - openTun 适配真实 TunOptions(RoutePrefixIterator/getMTU/StringBox DNS) - 统计/切节点经 CommandClient(uplinkTotal/selectOutbound);MainActivity 接线 构建环境矩阵打通:Gradle 8.7(Java21)+声明式插件迁移+AGP 8.6+Kotlin 2.2.0+占位图标 Co-Authored-By: Claude Opus 4.8 --- client/android/app/build.gradle | 36 +- .../android/app/src/main/AndroidManifest.xml | 2 +- .../pangolin_vpn/DefaultNetworkMonitor.kt | 124 ++++ .../com/pangolin/pangolin_vpn/MainActivity.kt | 10 +- .../pangolin_vpn/PangolinVpnService.kt | 649 +++++++++--------- .../app/src/main/res/drawable/ic_launcher.xml | 16 + client/android/build.gradle | 17 +- client/android/gradle.properties | 4 + .../gradle/wrapper/gradle-wrapper.properties | 5 + client/android/settings.gradle | 30 +- 10 files changed, 512 insertions(+), 381 deletions(-) create mode 100644 client/android/app/src/main/kotlin/com/pangolin/pangolin_vpn/DefaultNetworkMonitor.kt create mode 100644 client/android/app/src/main/res/drawable/ic_launcher.xml create mode 100644 client/android/gradle/wrapper/gradle-wrapper.properties diff --git a/client/android/app/build.gradle b/client/android/app/build.gradle index 7604ef1..658e5e7 100644 --- a/client/android/app/build.gradle +++ b/client/android/app/build.gradle @@ -1,3 +1,9 @@ +plugins { + id "com.android.application" + id "kotlin-android" + id "dev.flutter.flutter-gradle-plugin" +} + def localProperties = new Properties() def localPropertiesFile = rootProject.file('local.properties') if (localPropertiesFile.exists()) { @@ -6,11 +12,6 @@ if (localPropertiesFile.exists()) { } } -def flutterRoot = localProperties.getProperty('flutter.sdk') -if (flutterRoot == null) { - throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") -} - def flutterVersionCode = localProperties.getProperty('flutter.versionCode') if (flutterVersionCode == null) { flutterVersionCode = '1' @@ -21,10 +22,6 @@ if (flutterVersionName == null) { flutterVersionName = '1.0' } -apply plugin: 'com.android.application' -apply plugin: 'kotlin-android' -apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" - android { namespace "com.pangolin.pangolin_vpn" compileSdkVersion flutter.compileSdkVersion @@ -57,17 +54,6 @@ android { signingConfig signingConfigs.debug } } - - // libbox.aar 包含 arm64-v8a / armeabi-v7a / x86_64 三个 ABI; - // 若只需调试,可缩减 abiFilters 以加快构建速度。 - // splits { - // abi { - // enable true - // reset() - // include 'arm64-v8a', 'x86_64' - // universalApk true - // } - // } } flutter { @@ -75,21 +61,15 @@ flutter { } dependencies { - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" - // ── sing-box libbox(gomobile AAR)────────────────────────────── // 产物由 app/kernel/build-android.sh 生成: // cd app/kernel && ./build-android.sh - // 产物路径(相对于本 build.gradle):../../../app/kernel/dist/android/libbox.aar - // - // 若文件不存在,构建会报错:请先运行 build-android.sh 生成产物。 - // 路径说明:client/android/app → ../../.. → repo root → app/kernel/dist/android/ + // 路径:client/android/app → ../../.. → repo root → app/kernel/dist/android/ + // Kotlin stdlib 由 kotlin-android 插件自动引入,无需显式声明。 def libboxAar = file("${projectDir}/../../../app/kernel/dist/android/libbox.aar") if (libboxAar.exists()) { implementation files(libboxAar) } else { - // AAR 尚未构建 — 保留占位;IDE 会报红线,但不影响非 libbox 代码编辑。 - // 运行 `app/kernel/build-android.sh` 后重新 sync 即可。 logger.warn("⚠ libbox.aar not found at ${libboxAar.absolutePath}") logger.warn(" Run: cd app/kernel && ./build-android.sh") } diff --git a/client/android/app/src/main/AndroidManifest.xml b/client/android/app/src/main/AndroidManifest.xml index f47d696..8e0188f 100644 --- a/client/android/app/src/main/AndroidManifest.xml +++ b/client/android/app/src/main/AndroidManifest.xml @@ -23,7 +23,7 @@ + android:icon="@drawable/ic_launcher"> { val tag = call.arguments as? String ?: "auto" - Log.d(TAG, "selectOutbound: tag=$tag (stub — 11G)") - // TODO(11G): 通过 libbox CommandClient 切换出口 + PangolinVpnService.instance?.selectOutbound(tag) + Log.d(TAG, "selectOutbound: tag=$tag") result.success(null) } "getActiveOutbound" -> { - Log.d(TAG, "getActiveOutbound (stub — 11G)") - // TODO(11G): 从 libbox CommandClient 查询当前出口 tag - result.success("auto") + val active = PangolinVpnService.instance?.currentActiveOutbound() ?: "auto" + Log.d(TAG, "getActiveOutbound -> $active") + result.success(active) } "setKillSwitch" -> { val on = call.arguments as? Boolean ?: false diff --git a/client/android/app/src/main/kotlin/com/pangolin/pangolin_vpn/PangolinVpnService.kt b/client/android/app/src/main/kotlin/com/pangolin/pangolin_vpn/PangolinVpnService.kt index 54d2723..6c96801 100644 --- a/client/android/app/src/main/kotlin/com/pangolin/pangolin_vpn/PangolinVpnService.kt +++ b/client/android/app/src/main/kotlin/com/pangolin/pangolin_vpn/PangolinVpnService.kt @@ -6,57 +6,69 @@ import android.app.NotificationManager import android.app.PendingIntent import android.content.Context import android.content.Intent -import android.net.TrafficStats import android.net.VpnService import android.os.Build import android.os.ParcelFileDescriptor +import android.system.OsConstants import android.util.Log -import go.libbox.BoxService -import go.libbox.CommandClient -import go.libbox.CommandClientHandler -import go.libbox.CommandClientOptions -import go.libbox.Libbox -import go.libbox.OutboundGroupIterator -import go.libbox.PlatformInterface -import go.libbox.ProcessInfo -import go.libbox.StatusMessage -import go.libbox.StringIterator -import go.libbox.TunOptions -import java.util.concurrent.Executors -import java.util.concurrent.ScheduledExecutorService -import java.util.concurrent.TimeUnit +import go.Seq +import libbox.CommandClient +import libbox.CommandClientHandler +import libbox.CommandClientOptions +import libbox.CommandServer +import libbox.CommandServerHandler +import libbox.ConnectionEvents +import libbox.ConnectionOwner +import libbox.InterfaceUpdateListener +import libbox.Libbox +import libbox.LocalDNSTransport +import libbox.LogIterator +import libbox.NetworkInterfaceIterator +import libbox.Notification as LibboxNotification +import libbox.OutboundGroupIterator +import libbox.OverrideOptions +import libbox.PlatformInterface +import libbox.SetupOptions +import libbox.StatusMessage +import libbox.StringIterator +import libbox.SystemProxyStatus +import libbox.TunOptions +import libbox.WIFIState +import java.util.Collections import java.util.concurrent.atomic.AtomicBoolean /** * PangolinVpnService — VPN 前台服务,封装 sing-box libbox 隧道。 * - * ## 生命周期 - * START → handleStart() → 后台线程 → startLibbox() → [BoxService.start()] - * → VpnEventBus.postStatus("on") - * STOP → doStop() → [BoxService.close()] → 关 TUN fd → postStatus("off") - * REVOKE → onRevoke() → postStatus("error") → doStop() + * ## 架构(sing-box 1.13.x libbox,CommandServer 模型) + * Libbox.setup(SetupOptions) —— 进程级一次,设可写目录 + 命令 socket + * CommandServer(handler, platformInterface).start() + * commandServer.startOrReloadService(configJson, OverrideOptions()) —— 起内核,回调 openTun + * 停止:closeService() → close() * - * ## 通信 - * Dart 通过 [MainActivity] MethodChannel 发送 start/stop intent。 - * 状态与统计经 [VpnEventBus] 推回 Flutter EventChannel。 + * 本类同时实现 [PlatformInterface](openTun/protect/接口监听…)与 [CommandServerHandler]。 + * 统计与切节点经独立的 [CommandClient](同进程连内核命令 socket)。 * - * ## 注意:libbox.aar API - * 依赖 app/kernel/dist/android/libbox.aar(由 build-android.sh 产出)。 - * 方法签名注释标注 "// libbox API" 处若编译失败,请对照实际 libbox.aar 中 - * go.libbox.* 的 javadoc 微调参数名;整体架构不变。 + * ## 与 Dart 通信 + * 状态/统计经 [VpnEventBus] 推回 Flutter EventChannel(契约见 lib/bridge/vpn_bridge.dart)。 + * selectOutbound/getActiveOutbound 由 [MainActivity] 通过 [instance] 调用。 */ -class PangolinVpnService : VpnService() { +class PangolinVpnService : VpnService(), PlatformInterface, CommandServerHandler { companion object { private const val TAG = "PangolinVpnService" const val ACTION_START = "com.pangolin.vpn.START" - const val ACTION_STOP = "com.pangolin.vpn.STOP" + const val ACTION_STOP = "com.pangolin.vpn.STOP" const val EXTRA_CONFIG = "config_json" private const val NOTIFICATION_CHANNEL_ID = "pangolin_vpn_channel" private const val NOTIFICATION_ID = 1001 + /** 同进程内供 MainActivity 调用 selectOutbound/getActiveOutbound。 */ + @Volatile var instance: PangolinVpnService? = null + private set + fun startVpn(context: Context, configJson: String) { val intent = Intent(context, PangolinVpnService::class.java).apply { action = ACTION_START @@ -67,7 +79,6 @@ class PangolinVpnService : VpnService() { } else { context.startService(intent) } - Log.d(TAG, "startVpn: intent dispatched, configLen=${configJson.length}") } fun stopVpn(context: Context) { @@ -75,37 +86,35 @@ class PangolinVpnService : VpnService() { action = ACTION_STOP } context.startService(intent) - Log.d(TAG, "stopVpn: intent dispatched") } } // ── 内核与 TUN 状态 ─────────────────────────────────────────── + @Volatile private var commandServer: CommandServer? = null + @Volatile private var statsClient: CommandClient? = null + @Volatile private var tunPfd: ParcelFileDescriptor? = null - /** libbox BoxService 内核实例(主线程以外创建,通过 volatile 保证可见性) */ - @Volatile private var boxService: BoxService? = null + /** 最近一次 writeGroups 缓存的「代理组当前出口」与 urltest 延迟,供 getActiveOutbound/stats 用。 */ + @Volatile private var activeOutbound: String = "auto" + @Volatile private var latestUrltest: List> = emptyList() - /** TUN 文件描述符持有者(VpnService.Builder.establish() 返回值) */ - @Volatile var currentTunPfd: ParcelFileDescriptor? = null + /** 缓存的「可手动选择的出口组」tag(selector 组)。selectOutbound 据此切换。 */ + @Volatile private var selectableGroup: String = "" - /** libbox CommandClient(统计与状态轮询) */ - @Volatile private var commandClient: CommandClient? = null - - /** 兜底统计定时器(CommandClient 不可用时启用 TrafficStats 方案) */ - private var statsExecutor: ScheduledExecutorService? = null - - /** 防止 doStop 重入 */ private val stopping = AtomicBoolean(false) + private var libboxSetupDone = false // ── 生命周期 ──────────────────────────────────────────────── override fun onCreate() { super.onCreate() - Log.i(TAG, "onCreate") + instance = this + // gomobile 绑定需要 Android Context(否则部分调用崩溃)。 + Seq.setContext(applicationContext) createNotificationChannel() } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - Log.i(TAG, "onStartCommand: action=${intent?.action}") return when (intent?.action) { ACTION_START -> { val configJson = intent.getStringExtra(EXTRA_CONFIG) ?: "{}" @@ -116,23 +125,19 @@ class PangolinVpnService : VpnService() { doStop() START_NOT_STICKY } - else -> { - Log.w(TAG, "onStartCommand: unknown action, falling through") - START_NOT_STICKY - } + else -> START_NOT_STICKY } } override fun onRevoke() { - // 系统撤销 VPN 权限(如用户在设置里关闭)→ 推 error 再清理 Log.w(TAG, "onRevoke: permission revoked by system") VpnEventBus.postStatus("error") doStop() } override fun onDestroy() { - Log.i(TAG, "onDestroy") doStop() + instance = null super.onDestroy() } @@ -143,12 +148,12 @@ class PangolinVpnService : VpnService() { startForeground(NOTIFICATION_ID, buildNotification("正在连接…")) VpnEventBus.postStatus("connecting") - // libbox 内核启动必须在后台线程(不能阻塞 onStartCommand) + // 内核启动必须在后台线程(不能阻塞 onStartCommand;openTun 回调会同步等 TUN 建好)。 Thread { try { - startLibbox(configJson) + startKernel(configJson) } catch (e: Exception) { - Log.e(TAG, "startLibbox failed: $e", e) + Log.e(TAG, "startKernel failed: $e", e) VpnEventBus.postStatus("error") updateNotification("连接失败") doStop() @@ -156,305 +161,311 @@ class PangolinVpnService : VpnService() { }.apply { name = "pangolin-libbox-start" }.start() } - /** - * 在后台线程内: - * 1. 构造 PlatformInterface(inner class,持有 VpnService 引用) - * 2. 新建 BoxService 并 start()(内核会回调 openTun 建 TUN 接口) - * 3. 启动 CommandClient 轮询统计 - * 4. 推送 "on" 状态 - */ - private fun startLibbox(configJson: String) { - val platform = PangolinPlatformInterface() + private fun startKernel(configJson: String) { + ensureLibboxSetup() - // ── libbox API: Libbox.newBoxService(platformInterface, configJson, needBuildConfig) - // 如果编译报错,可能 API 为: new BoxService(platform, configJson) - // 或: BoxService.newService(platform, configJson) - val service = Libbox.newBoxService(platform, configJson, false) // libbox API - service.start() // libbox API - boxService = service - Log.i(TAG, "BoxService started") + // 先起默认接口监听,再起内核——否则内核启动期拿不到默认出口,报 no available network interface。 + DefaultNetworkMonitor.start(applicationContext) - // 启动统计客户端 - startCommandClientOrFallback() + val server = Libbox.newCommandServer(this, this) + server.start() + commandServer = server + + server.startOrReloadService(configJson, OverrideOptions()) + Log.i(TAG, "sing-box service started") + + startStatsClient() - // 更新通知与状态 updateNotification("加速已开启") VpnEventBus.postStatus("on") } - // ── 统计:CommandClient + TrafficStats 兜底 ─────────────────── + private fun ensureLibboxSetup() { + if (libboxSetupDone) return + val base = filesDir.absolutePath + val work = filesDir.absolutePath + val temp = cacheDir.absolutePath + val options = SetupOptions().apply { + basePath = base + workingPath = work + tempPath = temp + } + Libbox.setup(options) + libboxSetupDone = true + Log.i(TAG, "Libbox.setup done (base=$base)") + } - /** - * 优先使用 libbox CommandClient 获取精确统计;若连接失败则退回 Android TrafficStats。 - */ - private fun startCommandClientOrFallback() { + // ── 统计 + 切节点:CommandClient ────────────────────────────── + + private fun startStatsClient() { try { - startCommandClient() + val options = CommandClientOptions().apply { + statusInterval = 1_000_000_000L // 1s(纳秒) + addCommand(Libbox.CommandStatus) + addCommand(Libbox.CommandGroup) // 取出口组 → getActiveOutbound + urltest 延迟 + } + val client = Libbox.newCommandClient(StatsHandler(), options) + client.connect() + statsClient = client + Log.i(TAG, "stats CommandClient connected") } catch (e: Exception) { - Log.w(TAG, "CommandClient unavailable ($e), falling back to TrafficStats") - startTrafficStatsFallback() + Log.w(TAG, "stats CommandClient failed: $e") } } - private fun startCommandClient() { - val options = CommandClientOptions() - // Command 0 = STATUS(含流量统计);间隔 1 秒(纳秒) - // libbox API: options.Command / options.StatusInterval 为 gomobile 暴露的字段 - options.command = 0 // libbox API field - options.statusInterval = 1_000_000_000L // libbox API field: nanoseconds - - val handler = object : CommandClientHandler { - override fun connected() { - Log.d(TAG, "CommandClient connected") - } - override fun disconnected(message: String) { - Log.d(TAG, "CommandClient disconnected: $message") - // 断开后不改变 VPN 状态——隧道仍在运行,仅统计中断 - } - override fun writeLog(message: String) { - Log.v("libbox", message) - } - override fun writeStatus(status: StatusMessage) { - // libbox API: StatusMessage 提供累计流量与瞬时速率 - val stats = mapOf( - "uploadBytes" to (status.uploadTotal()), // libbox API - "downloadBytes" to (status.downloadTotal()), // libbox API - "uploadSpeed" to (status.uploadSpeed().toDouble()), // libbox API - "downloadSpeed" to (status.downloadSpeed().toDouble()), // libbox API - "urltestResults" to emptyList() - ) - VpnEventBus.postStats(stats) - } - override fun writeGroups(groups: OutboundGroupIterator) { /* 暂不处理 urltest 延迟 */ } - override fun initializeClashMode(modeList: StringIterator, currentMode: String) {} - override fun updateClashMode(newMode: String) {} + /** 由 MainActivity(同进程)调用:切换出口节点到 [tag](在缓存的可选组内)。 */ + fun selectOutbound(tag: String) { + val group = selectableGroup + if (group.isEmpty()) { + Log.w(TAG, "selectOutbound: 无可选出口组(config 可能只有 urltest,无 selector)") + return + } + try { + statsClient?.selectOutbound(group, tag) + Log.i(TAG, "selectOutbound(group=$group, tag=$tag)") + } catch (e: Exception) { + Log.w(TAG, "selectOutbound failed: $e") } - - // libbox API: Libbox.newCommandClient(handler, options) - val client = Libbox.newCommandClient(handler, options) // libbox API - client.connect() // libbox API - commandClient = client - Log.i(TAG, "CommandClient started") } - /** - * 兜底:用 Android TrafficStats API 按 UID 统计网络流量。 - * 由于所有流量都走 VPN,UID 统计近似等于隧道流量。 - */ - private fun startTrafficStatsFallback() { - val uid = android.os.Process.myUid() - var lastRx = TrafficStats.getUidRxBytes(uid) - var lastTx = TrafficStats.getUidTxBytes(uid) - var totalRx = 0L - var totalTx = 0L + fun currentActiveOutbound(): String = activeOutbound - statsExecutor = Executors.newSingleThreadScheduledExecutor().also { exec -> - exec.scheduleAtFixedRate({ - if (stopping.get()) return@scheduleAtFixedRate - try { - val rx = TrafficStats.getUidRxBytes(uid).let { if (it < 0) 0L else it } - val tx = TrafficStats.getUidTxBytes(uid).let { if (it < 0) 0L else it } - val rxDiff = (rx - lastRx).coerceAtLeast(0) - val txDiff = (tx - lastTx).coerceAtLeast(0) - totalRx += rxDiff - totalTx += txDiff - lastRx = rx - lastTx = tx + private inner class StatsHandler : CommandClientHandler { + override fun connected() { Log.d(TAG, "stats client connected") } + override fun disconnected(message: String?) { Log.d(TAG, "stats client disconnected: $message") } + override fun clearLogs() {} + override fun writeLogs(messageList: LogIterator?) {} + override fun setDefaultLogLevel(level: Int) {} + override fun initializeClashMode(modeList: StringIterator?, currentMode: String?) {} + override fun updateClashMode(newMode: String?) {} + override fun writeConnectionEvents(message: ConnectionEvents?) {} - val stats = mapOf( - "uploadBytes" to totalTx, - "downloadBytes" to totalRx, - "uploadSpeed" to txDiff.toDouble(), - "downloadSpeed" to rxDiff.toDouble(), - "urltestResults" to emptyList() - ) - VpnEventBus.postStats(stats) - } catch (e: Exception) { - Log.w(TAG, "TrafficStats error: $e") + override fun writeStatus(message: StatusMessage) { + val stats = mapOf( + "uploadBytes" to message.uplinkTotal, + "downloadBytes" to message.downlinkTotal, + "uploadSpeed" to message.uplink.toDouble(), + "downloadSpeed" to message.downlink.toDouble(), + "urltestResults" to latestUrltest.map { + mapOf("tag" to it.first, "delayMs" to it.second) } - }, 1L, 1L, TimeUnit.SECONDS) + ) + VpnEventBus.postStats(stats) + } + + override fun writeGroups(groups: OutboundGroupIterator?) { + if (groups == null) return + val urltest = mutableListOf>() + var selected = activeOutbound + try { + while (groups.hasNext()) { + val group = groups.next() + // 记录可选组的当前出口(如 selector / urltest 的 selected)。 + if (group.selected.isNotEmpty()) selected = group.selected + if (group.selectable) selectableGroup = group.tag + val items = group.items + while (items.hasNext()) { + val item = items.next() + urltest.add(item.tag to item.getURLTestDelay()) + } + } + } catch (e: Exception) { + Log.w(TAG, "writeGroups parse error: $e") + } + latestUrltest = urltest + activeOutbound = selected } - Log.i(TAG, "TrafficStats fallback started, uid=$uid") } // ── 停止流程 ───────────────────────────────────────────────── - /** - * 幂等停止:关闭统计 → 关闭 BoxService → 关闭 TUN fd → 停前台通知。 - * 由多个入口(ACTION_STOP / onRevoke / onDestroy)调用,通过 [stopping] 防止重入。 - */ private fun doStop() { - if (!stopping.compareAndSet(false, true)) { - Log.d(TAG, "doStop: already stopping, skip") - return - } - Log.i(TAG, "doStop: shutting down tunnel") + if (!stopping.compareAndSet(false, true)) return + Log.i(TAG, "doStop") - // 1. 停统计(先于 BoxService,避免最后一帧读到已关闭的 fd) - statsExecutor?.apply { - shutdownNow() - try { awaitTermination(500, TimeUnit.MILLISECONDS) } catch (_: InterruptedException) {} - } - statsExecutor = null + try { statsClient?.disconnect() } catch (_: Exception) {} + statsClient = null - try { - commandClient?.disconnect() // libbox API - } catch (e: Exception) { - Log.w(TAG, "commandClient.disconnect failed: $e") - } - commandClient = null + try { commandServer?.closeService() } catch (e: Exception) { Log.w(TAG, "closeService: $e") } + try { commandServer?.close() } catch (e: Exception) { Log.w(TAG, "close: $e") } + commandServer = null - // 2. 关闭 BoxService 内核(会在内部关闭它持有的 TUN fd 引用) - try { - boxService?.close() // libbox API - } catch (e: Exception) { - Log.w(TAG, "boxService.close failed: $e") - } - boxService = null + DefaultNetworkMonitor.stop() - // 3. 关闭 ParcelFileDescriptor(持有底层 fd;必须在 BoxService.close 之后) - try { - currentTunPfd?.close() - } catch (e: Exception) { - Log.w(TAG, "tunPfd.close failed: $e") - } - currentTunPfd = null + try { tunPfd?.close() } catch (_: Exception) {} + tunPfd = null - // 4. 停前台 + 自我停止 @Suppress("DEPRECATION") stopForeground(true) stopSelf() VpnEventBus.postStatus("off") - Log.i(TAG, "doStop: done") } - // ── PlatformInterface(inner class,持有 VpnService.Builder 访问权)──── + // ══════════════════════════════════════════════════════════════ + // PlatformInterface 实现(1.13.12 共 15 法) + // ══════════════════════════════════════════════════════════════ - /** - * sing-box libbox 平台回调实现。 - * - * [openTun]: 用 TunOptions 配置 VpnService.Builder,调用 establish() 取 TUN fd。 - * [autoDetectInterfaceControl]: 调用 VpnService.protect(fd) 防止 TUN 流量形成环路。 - */ - private inner class PangolinPlatformInterface : PlatformInterface { + override fun openTun(options: TunOptions): Int { + if (prepare(this) != null) throw Exception("android: missing vpn permission") - /** - * libbox 回调 — 创建 TUN 接口并返回 fd。 - * - * 注:TunOptions 的具体 getter 方法名(inet4Address / mtu 等) - * 需与实际 libbox.aar 中 go.libbox.TunOptions 的 javadoc 对齐。 - * 若方法不存在,请使用 options 对应的实际方法名替换。 - */ - override fun openTun(options: TunOptions): Int { - val builder = Builder() // VpnService.Builder(inner class,此处可直接调用) + val builder = Builder() + .setSession("Pangolin") + .setMtu(options.getMTU()) - // ── 地址配置 ────────────────────────────────────────── - // libbox API: TunOptions.inet4Address() 返回 CIDR 字符串(如 "172.19.0.1/30") - // 若 API 名不同(如 getInet4Address())请对应修改 - val inet4 = tryGetInet4(options) - if (inet4.isNotEmpty()) { - val slash = inet4.indexOf('/') - if (slash > 0) { - builder.addAddress(inet4.substring(0, slash), inet4.substring(slash + 1).toInt()) - } else { - builder.addAddress(inet4, 30) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) builder.setMetered(false) + + // 地址(RoutePrefixIterator) + val inet4 = options.inet4Address + while (inet4.hasNext()) { + val a = inet4.next(); builder.addAddress(a.address(), a.prefix()) + } + val inet6 = options.inet6Address + while (inet6.hasNext()) { + val a = inet6.next(); builder.addAddress(a.address(), a.prefix()) + } + + if (options.autoRoute) { + // DNS(1.13.12:单个 StringBox) + try { + val dns = options.getDNSServerAddress()?.getValue() + if (!dns.isNullOrEmpty()) builder.addDnsServer(dns) + } catch (e: Exception) { + Log.w(TAG, "addDnsServer: $e") + } + + // 路由:优先 inet4RouteAddress;为空则全量 0.0.0.0/0。 + val r4 = options.inet4RouteAddress + if (r4.hasNext()) { + while (r4.hasNext()) { val a = r4.next(); builder.addRoute(a.address(), a.prefix()) } + } else if (options.inet4Address.hasNext()) { + builder.addRoute("0.0.0.0", 0) + } + val r6 = options.inet6RouteAddress + if (r6.hasNext()) { + while (r6.hasNext()) { val a = r6.next(); builder.addRoute(a.address(), a.prefix()) } + } else if (options.inet6Address.hasNext()) { + builder.addRoute("::", 0) + } + + // 分应用(include/exclude package) + val inc = options.includePackage + while (inc.hasNext()) { + try { builder.addAllowedApplication(inc.next()) } catch (e: Exception) { Log.w(TAG, "allow app: $e") } + } + val exc = options.excludePackage + while (exc.hasNext()) { + try { builder.addDisallowedApplication(exc.next()) } catch (e: Exception) { Log.w(TAG, "disallow app: $e") } + } + } + + val pfd = builder.establish() ?: throw Exception("establish() returned null — 未授权或已被撤销") + tunPfd = pfd + Log.i(TAG, "openTun: fd=${pfd.fd}, mtu=${options.mtu}") + return pfd.fd + } + + override fun autoDetectInterfaceControl(fd: Int) { + if (!protect(fd)) throw Exception("VpnService.protect(fd=$fd) failed") + } + + override fun usePlatformAutoDetectInterfaceControl(): Boolean = true + + override fun useProcFS(): Boolean = Build.VERSION.SDK_INT < Build.VERSION_CODES.Q + + override fun startDefaultInterfaceMonitor(listener: InterfaceUpdateListener) { + DefaultNetworkMonitor.setListener(listener) + } + + override fun closeDefaultInterfaceMonitor(listener: InterfaceUpdateListener) { + DefaultNetworkMonitor.setListener(null) + } + + override fun getInterfaces(): NetworkInterfaceIterator { + val list = mutableListOf() + try { + for (ni in Collections.list(java.net.NetworkInterface.getNetworkInterfaces())) { + val box = libbox.NetworkInterface() + box.name = ni.name + box.index = ni.index + runCatching { box.setMTU(ni.mtu) } + box.addresses = StringArray( + ni.interfaceAddresses.mapNotNull { ia -> + val host = ia.address?.hostAddress ?: return@mapNotNull null + "$host/${ia.networkPrefixLength}" + }.iterator() + ) + var flags = 0 + runCatching { + if (ni.isUp) flags = flags or OsConstants.IFF_UP or OsConstants.IFF_RUNNING + if (ni.isLoopback) flags = flags or OsConstants.IFF_LOOPBACK + if (ni.isPointToPoint) flags = flags or OsConstants.IFF_POINTOPOINT + if (ni.supportsMulticast()) flags = flags or OsConstants.IFF_MULTICAST } - } else { - // 兜底:使用 PoC 静态配置的地址 - builder.addAddress("172.19.0.1", 30) + box.flags = flags + box.type = Libbox.InterfaceTypeOther + list.add(box) } - - val inet6 = tryGetInet6(options) - if (inet6.isNotEmpty()) { - val slash = inet6.indexOf('/') - if (slash > 0) { - builder.addAddress(inet6.substring(0, slash), inet6.substring(slash + 1).toInt()) - } - } - - // ── MTU ──────────────────────────────────────────────── - val mtu = tryGetMtu(options) - builder.setMtu(mtu) - - // ── 路由:将所有流量导入 TUN(sing-box auto_route = true)──── - builder.addRoute("0.0.0.0", 0) // IPv4 全部流量 - builder.addRoute("::", 0) // IPv6 全部流量 - - // ── DNS:sing-box 内置 Fake-IP 地址 ─────────────────── - // sing-box 在 172.18.0.0/15 运行 Fake-IP,设置匹配 DNS 服务器 - builder.addDnsServer("198.18.0.2") - - builder.setSession("Pangolin") - builder.setBlocking(true) - - val pfd = builder.establish() - ?: throw Exception("VpnService.Builder.establish() returned null — 可能未授权") - currentTunPfd = pfd - - Log.i(TAG, "openTun: fd=${pfd.fd}, inet4=$inet4, mtu=$mtu") - return pfd.fd - } - - /** - * libbox 回调 — 调用 VpnService.protect() 使 fd 跳过 TUN,防止路由环路。 - * - * 注:gomobile 将 Go `error` 返回翻译为抛出 Exception(无返回值)。 - */ - override fun autoDetectInterfaceControl(fd: Int) { - if (!protect(fd)) { - throw Exception("VpnService.protect(fd=$fd) failed") - } - } - - override fun writeLog(message: String) { - Log.v("libbox", message) - } - - /** 使用 Android VpnService 的 protect() 机制,不需要平台独立的路由探测 */ - override fun usePlatformAutoDetectInterfaceControl(): Boolean = true - - /** 使用 Android 系统的网络变化监听,不使用 libbox 内置监听 */ - override fun usePlatformDefaultInterfaceMonitor(): Boolean = false - - /** 不使用 libbox 内置接口枚举 */ - override fun usePlatformInterfaceGetter(): Boolean = false - - /** - * 进程信息查询(用于 sing-box 的应用规则)。 - * PoC 阶段不需要,返回 null 即可(libbox 会跳过进程匹配规则)。 - */ - override fun findProcessInfo( - networkType: Int, - srcIP: String, - srcPort: Int, - destIP: String, - destPort: Int, - ): ProcessInfo? = null - - // ── TunOptions 安全 getter(兜底处理 API 名不匹配问题)── - - /** 安全读取 inet4Address,若 API 不匹配返回空串,由调用方用兜底值 */ - private fun tryGetInet4(options: TunOptions): String = try { - // libbox API: options.inet4Address() — 若名称不同请修改 - options.inet4Address() } catch (e: Exception) { - Log.w(TAG, "TunOptions.inet4Address() failed: $e") - "" + Log.w(TAG, "getInterfaces: $e") } + return InterfaceArray(list.iterator()) + } - private fun tryGetInet6(options: TunOptions): String = try { - options.inet6Address() - } catch (e: Exception) { - "" - } + override fun findConnectionOwner( + ipProtocol: Int, sourceAddress: String, sourcePort: Int, + destinationAddress: String, destinationPort: Int, + ): ConnectionOwner { + // 我们的配置不含进程匹配规则,核心不会调用;保守抛出。 + throw Exception("findConnectionOwner not supported") + } - private fun tryGetMtu(options: TunOptions): Int = try { - // libbox API: options.mtu() returns Int32/Int - options.mtu().toInt().coerceIn(576, 65535) - } catch (e: Exception) { - 9000 // 兜底:与 PoC 配置对齐 - } + override fun includeAllNetworks(): Boolean = false + + override fun clearDNSCache() {} + + override fun readWIFIState(): WIFIState? = null + + override fun localDNSTransport(): LocalDNSTransport? = null + + override fun systemCertificates(): StringIterator = StringArray(emptyList().iterator()) + + override fun underNetworkExtension(): Boolean = false + + override fun sendNotification(notification: LibboxNotification) { + Log.d(TAG, "libbox notification: ${notification.title} / ${notification.body}") + } + + // ══════════════════════════════════════════════════════════════ + // CommandServerHandler 实现(5 法) + // ══════════════════════════════════════════════════════════════ + + override fun serviceReload() { Log.d(TAG, "serviceReload") } + + override fun serviceStop() { doStop() } + + override fun getSystemProxyStatus(): SystemProxyStatus = SystemProxyStatus().apply { + available = false + enabled = false + } + + override fun setSystemProxyEnabled(isEnabled: Boolean) {} + + override fun writeDebugMessage(message: String?) { Log.d("sing-box", message ?: "") } + + // ── gomobile 迭代器适配 ─────────────────────────────────────── + + private class StringArray(private val it: Iterator) : StringIterator { + override fun len(): Int = 0 // 内核不依赖精确长度 + override fun hasNext(): Boolean = it.hasNext() + override fun next(): String = it.next() + } + + private class InterfaceArray( + private val it: Iterator + ) : NetworkInterfaceIterator { + override fun hasNext(): Boolean = it.hasNext() + override fun next(): libbox.NetworkInterface = it.next() } // ── 通知 ───────────────────────────────────────────────────── @@ -462,46 +473,36 @@ class PangolinVpnService : VpnService() { private fun createNotificationChannel() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val channel = NotificationChannel( - NOTIFICATION_CHANNEL_ID, - "穿山甲加速", - NotificationManager.IMPORTANCE_LOW + NOTIFICATION_CHANNEL_ID, "穿山甲加速", NotificationManager.IMPORTANCE_LOW ).apply { description = "穿山甲加速状态" setShowBadge(false) } - getSystemService(NotificationManager::class.java) - .createNotificationChannel(channel) + getSystemService(NotificationManager::class.java).createNotificationChannel(channel) } } - private fun updateNotification(contentText: String) { - getSystemService(NotificationManager::class.java) - .notify(NOTIFICATION_ID, buildNotification(contentText)) + private fun updateNotification(text: String) { + getSystemService(NotificationManager::class.java).notify(NOTIFICATION_ID, buildNotification(text)) } private fun buildNotification(contentText: String): Notification { val pendingFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT } else { - @Suppress("DEPRECATION") - PendingIntent.FLAG_UPDATE_CURRENT + @Suppress("DEPRECATION") PendingIntent.FLAG_UPDATE_CURRENT } val openIntent = PendingIntent.getActivity( - this, 0, - Intent(this, MainActivity::class.java), - pendingFlags + this, 0, Intent(this, MainActivity::class.java), pendingFlags ) - val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { Notification.Builder(this, NOTIFICATION_CHANNEL_ID) } else { - @Suppress("DEPRECATION") - Notification.Builder(this) + @Suppress("DEPRECATION") Notification.Builder(this) } - return builder .setContentTitle("穿山甲") - .setContentText(contentText) // "加速已开启" / "正在连接…" / "连接失败" + .setContentText(contentText) .setSmallIcon(android.R.drawable.ic_dialog_info) .setContentIntent(openIntent) .setOngoing(true) diff --git a/client/android/app/src/main/res/drawable/ic_launcher.xml b/client/android/app/src/main/res/drawable/ic_launcher.xml new file mode 100644 index 0000000..db0cc1b --- /dev/null +++ b/client/android/app/src/main/res/drawable/ic_launcher.xml @@ -0,0 +1,16 @@ + + + + + + diff --git a/client/android/build.gradle b/client/android/build.gradle index 68b15a8..d2ffbff 100644 --- a/client/android/build.gradle +++ b/client/android/build.gradle @@ -1,16 +1,3 @@ -buildscript { - ext.kotlin_version = '1.9.0' - repositories { - google() - mavenCentral() - } - - dependencies { - classpath 'com.android.tools.build:gradle:8.1.0' - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" - } -} - allprojects { repositories { google() @@ -18,12 +5,12 @@ allprojects { } } -rootProject.buildDir = '../build' +rootProject.buildDir = "../build" subprojects { project.buildDir = "${rootProject.buildDir}/${project.name}" } subprojects { - project.evaluationDependsOn(':app') + project.evaluationDependsOn(":app") } tasks.register("clean", Delete) { diff --git a/client/android/gradle.properties b/client/android/gradle.properties index 2597170..4147ba3 100644 --- a/client/android/gradle.properties +++ b/client/android/gradle.properties @@ -1,3 +1,7 @@ org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError android.useAndroidX=true android.enableJetifier=true +# This builtInKotlin flag was added automatically by Flutter migrator +android.builtInKotlin=false +# This newDsl flag was added automatically by Flutter migrator +android.newDsl=false diff --git a/client/android/gradle/wrapper/gradle-wrapper.properties b/client/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..4518132 --- /dev/null +++ b/client/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip diff --git a/client/android/settings.gradle b/client/android/settings.gradle index 44e62bc..60dcd4a 100644 --- a/client/android/settings.gradle +++ b/client/android/settings.gradle @@ -1,11 +1,25 @@ -include ':app' +pluginManagement { + def flutterSdkPath = { + def properties = new Properties() + file("local.properties").withInputStream { properties.load(it) } + def flutterSdkPath = properties.getProperty("flutter.sdk") + assert flutterSdkPath != null, "flutter.sdk not set in local.properties" + return flutterSdkPath + }() -def localPropertiesFile = new File(rootProject.projectDir, "local.properties") -def properties = new Properties() + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") -assert localPropertiesFile.exists() -localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) } + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} -def flutterSdkPath = properties.getProperty("flutter.sdk") -assert flutterSdkPath != null, "flutter.sdk not set in local.properties" -apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle" +plugins { + id "dev.flutter.flutter-plugin-loader" version "1.0.0" + id "com.android.application" version "8.6.0" apply false + id "org.jetbrains.kotlin.android" version "2.2.0" apply false +} + +include ":app" From c1fddcdf99f5713391452bb3e1fadfafbf7d144a Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Mon, 22 Jun 2026 20:05:50 +0800 Subject: [PATCH 6/8] =?UTF-8?q?docs(android):=20=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E9=93=BE=E8=AE=B0=E5=BD=95(=E9=99=8DGo?= =?UTF-8?q?=E4=BF=9Dpin=E2=86=92SagerNet=20gomobile=20fork+5=E5=B1=82?= =?UTF-8?q?=E7=8E=AF=E5=A2=83=E4=BF=AE=E5=A4=8D)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- docs/android-client-design.html | 4 ++-- docs/superpowers/plans/2026-06-22-android-client.md | 7 ++++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/docs/android-client-design.html b/docs/android-client-design.html index ca236ca..116f0a3 100644 --- a/docs/android-client-design.html +++ b/docs/android-client-design.html @@ -121,8 +121,8 @@

做「L1 + 引导 L3」的组合——setKillSwitch(on)strict_route(与 Windows 桌面统一)+ 在 UI 诚实标注「彻底防泄漏需到系统设置开 Always-on」,并提供跳转引导。不假装 app 内能做到真 KillSwitch。 背景与分级见 KillSwitch 设计知识库

-

② 工具链

-

默认降 Go 到 1.24.3、保 gomobile pin,贴 sing-box 官方 SFA 验证组合;不升 pin。(本机现为 Go 1.26.1,gomobile pin 是 2024-06 旧版,新 Go + 旧 gomobile 可能 gomobile bind 失败。)

+

② 工具链(构建后修正)

+

原定「降 Go 保 pin」——但实测 pin 失效,真实情况:用 golang.org/dl/go1.24.3 当启动器(go.mod 为 go 1.24.7,自动切 1.24.7 编译);gomobile 改用官方 SagerNet fork github.com/sagernet/gomobile@v0.1.12(非上游,对照 sing-box go.mod 确认);bind 加 -checklinkname=0 + tags badlinkname,tfogo_checklinkname0;JDK 用 Android Studio JBR;国内 GOPROXY=goproxy.cn。Android 栈升 Gradle 8.7 + AGP 8.6 + Kotlin 2.2.0 + 声明式插件。详见实现计划。

③ 测试基线(里程碑 C)

diff --git a/docs/superpowers/plans/2026-06-22-android-client.md b/docs/superpowers/plans/2026-06-22-android-client.md index d7e5ad1..6c9e24f 100644 --- a/docs/superpowers/plans/2026-06-22-android-client.md +++ b/docs/superpowers/plans/2026-06-22-android-client.md @@ -13,7 +13,12 @@ ## Global Constraints - sing-box 内核版本 `v1.13.12`(`app/kernel/VERSION` 锚定,禁止脚本内硬编码)。 -- Go 工具链 **降到 `1.24.3`、保 gomobile pin `v0.0.0-20240604150348-70c4c5da7d30`**(贴 SFA 官方验证组合;本机 Go 1.26.1 不动,用官方 `golang.org/dl/go1.24.3` 并存)。 +- Go 工具链:用官方 `golang.org/dl/go1.24.3` 当启动器(本机 Go 1.26.1 不动);sing-box go.mod 为 `go 1.24.7`,GOTOOLCHAIN=auto 会自动下载并切到 **1.24.7** 编译。 +- **gomobile = `github.com/sagernet/gomobile@v0.1.12`**(SagerNet fork,非上游 `golang.org/x/mobile`;对照 sing-box v1.13.12 go.mod 与 `cmd/internal/build_libbox`)。原 VERSION 里的上游 pin 是失效 revision。 +- libbox bind ldflags 必须含 **`-checklinkname=0`**(放行 `badlinkname` 的 `//go:linkname`,Go 1.23+ 默认 checklinkname=1 会拒 `os.checkPidfdOnce` 等,缺则链接失败);build tags 含 `badlinkname,tfogo_checklinkname0`。 +- 构建需 **JDK**(gomobile bind 末段 javac):用 Android Studio JBR(OpenJDK 21);macOS `/usr/bin/javac` 仅桩。 +- 国内网络:`GOPROXY=https://goproxy.cn,direct` + `GOSUMDB=sum.golang.google.cn`(proxy.golang.org 连不上)。 +- Android 构建栈(Flutter 3.44 要求):**Gradle 8.7 + 声明式 plugins block + AGP 8.6.0 + Kotlin 2.2.0**(旧脚手架是 Gradle 8.0/AGP 8.1/命令式插件,全需升级迁移)。 - libbox build tags:`with_quic,with_utls,with_clash_api,with_gvisor`(脚本默认,勿改)。 - `minSdkVersion 21`(libbox `-androidapi 21` 要求),`targetSdkVersion` 跟随 flutter。 - Channel 契约冻结(`pangolin/vpn` + `pangolin/vpn/status` + `pangolin/vpn/stats`),方法签名见 `client/lib/bridge/vpn_bridge.dart`,**不得私改**。 From d0919943f59ea80a04c67c00512b205018a02221 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Mon, 22 Jun 2026 20:43:49 +0800 Subject: [PATCH 7/8] =?UTF-8?q?fix(android):=20=E7=9C=9F=E6=9C=BA=E8=BF=9E?= =?UTF-8?q?=E9=80=9A=E4=BF=AE=E5=A4=8D=E2=80=94=E2=80=94=E8=A1=A5=20ACCESS?= =?UTF-8?q?=5FNETWORK=5FSTATE=20+=20=E7=A0=8D=20IPv6=20zone(Task=207)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vivo X200(Android 16)真机实测两处运行期崩溃并修复: - DefaultNetworkMonitor 用 ConnectivityManager 需 ACCESS_NETWORK_STATE 权限(缺则 SecurityException) - getInterfaces 返回的 IPv6 link-local 含 zone(%rmnet_data0),sing-box netip.ParsePrefix panic;substringBefore('%') 砍掉 真机端到端连通已由用户验证通过。 Co-Authored-By: Claude Opus 4.8 --- client/android/app/src/main/AndroidManifest.xml | 3 +++ .../kotlin/com/pangolin/pangolin_vpn/PangolinVpnService.kt | 4 +++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/client/android/app/src/main/AndroidManifest.xml b/client/android/app/src/main/AndroidManifest.xml index 8e0188f..99b3c2b 100644 --- a/client/android/app/src/main/AndroidManifest.xml +++ b/client/android/app/src/main/AndroidManifest.xml @@ -4,6 +4,9 @@ + + + diff --git a/client/android/app/src/main/kotlin/com/pangolin/pangolin_vpn/PangolinVpnService.kt b/client/android/app/src/main/kotlin/com/pangolin/pangolin_vpn/PangolinVpnService.kt index 6c96801..c881953 100644 --- a/client/android/app/src/main/kotlin/com/pangolin/pangolin_vpn/PangolinVpnService.kt +++ b/client/android/app/src/main/kotlin/com/pangolin/pangolin_vpn/PangolinVpnService.kt @@ -391,7 +391,9 @@ class PangolinVpnService : VpnService(), PlatformInterface, CommandServerHandler runCatching { box.setMTU(ni.mtu) } box.addresses = StringArray( ni.interfaceAddresses.mapNotNull { ia -> - val host = ia.address?.hostAddress ?: return@mapNotNull null + // 砍掉 IPv6 link-local 的 zone 标识(如 %rmnet_data0)—— + // sing-box netip.ParsePrefix 不接受 prefix 内含 zone,否则 panic。 + val host = ia.address?.hostAddress?.substringBefore('%') ?: return@mapNotNull null "$host/${ia.networkPrefixLength}" }.iterator() ) From f3f91cc0ff19e24f8e85438f16093cf28fbbe8a4 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Mon, 22 Jun 2026 23:42:18 +0800 Subject: [PATCH 8/8] =?UTF-8?q?feat(android):=20=E5=90=AF=E5=8A=A8?= =?UTF-8?q?=E5=9B=BE=E6=A0=87=E6=94=B9=E7=94=A8=E5=AE=98=E6=96=B9=E7=A9=BF?= =?UTF-8?q?=E5=B1=B1=E7=94=B2(=E8=87=AA=E9=80=82=E5=BA=94=E5=9B=BE?= =?UTF-8?q?=E6=A0=87)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 前景 path 取自官方 client/assets/logo-mark-white.svg(原样,非重绘),仅加 Android 安全区居中; 背景为 app-icon.svg 的橙色渐变(#C8794A→#9E5630);删除占位三角 drawable/ic_launcher.xml。 Vivo X200/华为 DCO-AL00 真机验证图标显示正常。 Co-Authored-By: Claude Opus 4.8 --- .../android/app/src/main/AndroidManifest.xml | 2 +- .../app/src/main/res/drawable/ic_launcher.xml | 16 ------- .../res/drawable/ic_launcher_background.xml | 20 ++++++++ .../res/drawable/ic_launcher_foreground.xml | 46 +++++++++++++++++++ .../res/mipmap-anydpi-v26/ic_launcher.xml | 6 +++ .../main/res/mipmap-anydpi/ic_launcher.xml | 28 +++++++++++ .../app/src/main/res/values/colors.xml | 5 ++ 7 files changed, 106 insertions(+), 17 deletions(-) delete mode 100644 client/android/app/src/main/res/drawable/ic_launcher.xml create mode 100644 client/android/app/src/main/res/drawable/ic_launcher_background.xml create mode 100644 client/android/app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 client/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 client/android/app/src/main/res/mipmap-anydpi/ic_launcher.xml create mode 100644 client/android/app/src/main/res/values/colors.xml diff --git a/client/android/app/src/main/AndroidManifest.xml b/client/android/app/src/main/AndroidManifest.xml index 99b3c2b..a52e295 100644 --- a/client/android/app/src/main/AndroidManifest.xml +++ b/client/android/app/src/main/AndroidManifest.xml @@ -26,7 +26,7 @@ + android:icon="@mipmap/ic_launcher"> - - - - - diff --git a/client/android/app/src/main/res/drawable/ic_launcher_background.xml b/client/android/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..f16471a --- /dev/null +++ b/client/android/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + diff --git a/client/android/app/src/main/res/drawable/ic_launcher_foreground.xml b/client/android/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..0664c3b --- /dev/null +++ b/client/android/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + diff --git a/client/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/client/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..986e785 --- /dev/null +++ b/client/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/client/android/app/src/main/res/mipmap-anydpi/ic_launcher.xml b/client/android/app/src/main/res/mipmap-anydpi/ic_launcher.xml new file mode 100644 index 0000000..3941e91 --- /dev/null +++ b/client/android/app/src/main/res/mipmap-anydpi/ic_launcher.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + diff --git a/client/android/app/src/main/res/values/colors.xml b/client/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..418d9be --- /dev/null +++ b/client/android/app/src/main/res/values/colors.xml @@ -0,0 +1,5 @@ + + + + #E0884F +