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"