diff --git a/client/android/app/build.gradle b/client/android/app/build.gradle index cae0f34..7604ef1 100644 --- a/client/android/app/build.gradle +++ b/client/android/app/build.gradle @@ -45,7 +45,8 @@ android { defaultConfig { applicationId "com.pangolin.pangolin_vpn" - minSdkVersion flutter.minSdkVersion + // libbox requires minSdk 21 (gomobile -androidapi 21) + minSdkVersion Math.max(flutter.minSdkVersion as Integer, 21) targetSdkVersion flutter.targetSdkVersion versionCode flutterVersionCode.toInteger() versionName flutterVersionName @@ -56,6 +57,17 @@ 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 { @@ -64,4 +76,21 @@ 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/ + 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 86c30f5..f47d696 100644 --- a/client/android/app/src/main/AndroidManifest.xml +++ b/client/android/app/src/main/AndroidManifest.xml @@ -1,11 +1,25 @@ - + + + + + + + + + android:permission="android.permission.BIND_VPN_SERVICE" + android:foregroundServiceType="specialUse"> + + + { val configJson = call.arguments as? String ?: "{}" - PangolinVpnService.startVpn(this, configJson) - result.success(null) + handleStartCall(configJson, result) } "stop" -> { PangolinVpnService.stopVpn(this) result.success(null) } "getStatus" -> { - // TODO(11E): 从 VpnService 查询真实状态 - result.success("off") + result.success(VpnEventBus.currentStatus) } "selectOutbound" -> { val tag = call.arguments as? String ?: "auto" - Log.d(TAG, "selectOutbound: tag=$tag") - // TODO(11E): 发送 IPC 到 VpnService → sing-box outbound selector + Log.d(TAG, "selectOutbound: tag=$tag (stub — 11G)") + // TODO(11G): 通过 libbox CommandClient 切换出口 result.success(null) } "getActiveOutbound" -> { - // TODO(11E): 从 VpnService 查询当前出口 tag + Log.d(TAG, "getActiveOutbound (stub — 11G)") + // TODO(11G): 从 libbox CommandClient 查询当前出口 tag result.success("auto") } "setKillSwitch" -> { val on = call.arguments as? Boolean ?: false - Log.d(TAG, "setKillSwitch: on=$on") - // TODO(11E): 配置 VpnService allowedApplications / blockingMode + Log.d(TAG, "setKillSwitch: on=$on (stub — 11G)") + // TODO(11G): 配置 VPN allowedApplications / blockingMode result.success(null) } else -> result.notImplemented() @@ -82,6 +98,8 @@ class MainActivity : FlutterActivity() { statusEventSink?.success(status) } }) + // 立即推送当前状态(订阅时的初始值) + events.success(VpnEventBus.currentStatus) } override fun onCancel(arguments: Any?) { Log.d(TAG, "status channel: onCancel") @@ -112,6 +130,73 @@ class MainActivity : FlutterActivity() { ) } + // ── VPN 权限 + 电池优化 ─────────────────────────────────────── + + /** + * start() MethodChannel 调用入口: + * - 若 VpnService.prepare() 需要用户授权,先弹授权 Activity(result 异步回调) + * - 若已授权,直接启动服务 + */ + private fun handleStartCall(configJson: String, result: MethodChannel.Result) { + // 同步检查:是否需要 VPN 授权弹窗 + val prepareIntent = VpnService.prepare(this) + if (prepareIntent != null) { + Log.i(TAG, "VPN permission not granted, launching prepare intent") + pendingConfigJson = configJson + @Suppress("DEPRECATION") + startActivityForResult(prepareIntent, VPN_PERMISSION_REQUEST_CODE) + // 先返回 null 给 Dart,实际连接状态由 EventChannel 驱动 + result.success(null) + } else { + // 已授权,直接启动 + requestBatteryOptimizationExemptionIfNeeded() + PangolinVpnService.startVpn(this, configJson) + result.success(null) + } + } + + @Deprecated("Deprecated in Java") + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + if (requestCode == VPN_PERMISSION_REQUEST_CODE) { + val config = pendingConfigJson + pendingConfigJson = null + if (resultCode == Activity.RESULT_OK && config != null) { + Log.i(TAG, "VPN permission granted, starting service") + requestBatteryOptimizationExemptionIfNeeded() + PangolinVpnService.startVpn(this, config) + } else { + Log.w(TAG, "VPN permission denied (resultCode=$resultCode)") + VpnEventBus.postStatus("error") + } + } + @Suppress("DEPRECATION") + super.onActivityResult(requestCode, resultCode, data) + } + + /** + * 引导用户豁免电池优化(首次连接时弹一次)。 + * 豁免后后台 30 分钟内系统不会主动杀服务。 + * + * 注:BATTERY_OPTIMIZATION_EXEMPTED 不影响应用核心功能, + * 用户可在设置内随时撤销——VPN 仍可运行,但有被杀风险。 + */ + private fun requestBatteryOptimizationExemptionIfNeeded() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + val pm = getSystemService(PowerManager::class.java) + if (!pm.isIgnoringBatteryOptimizations(packageName)) { + try { + startActivity(Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply { + data = Uri.parse("package:$packageName") + }) + } catch (e: Exception) { + Log.w(TAG, "Cannot launch battery optimization settings: $e") + } + } + } + } + + // ── 清理 ───────────────────────────────────────────────────── + override fun onDestroy() { statusEventSink = null statsEventSink = null 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 c82b64d..54d2723 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,20 +6,44 @@ 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.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 java.util.concurrent.atomic.AtomicBoolean /** - * PangolinVpnService — VPN 服务骨架(空壳) + * PangolinVpnService — VPN 前台服务,封装 sing-box libbox 隧道。 * - * 生命周期占位日志 + 前台通知;实际隧道建立由 11E 完成。 + * ## 生命周期 + * START → handleStart() → 后台线程 → startLibbox() → [BoxService.start()] + * → VpnEventBus.postStatus("on") + * STOP → doStop() → [BoxService.close()] → 关 TUN fd → postStatus("off") + * REVOKE → onRevoke() → postStatus("error") → doStop() * - * 通信: - * - [MainActivity] 通过 [startVpn] / [stopVpn] 发送 Intent 启动/停止本服务 - * - 状态回调通过 [VpnEventBus] (Application 级 LiveData) 传到 MainActivity EventSink + * ## 通信 + * Dart 通过 [MainActivity] MethodChannel 发送 start/stop intent。 + * 状态与统计经 [VpnEventBus] 推回 Flutter EventChannel。 * - * TODO(11E): 在 onStartCommand 中初始化 libbox / sing-box TUN 隧道。 + * ## 注意:libbox.aar API + * 依赖 app/kernel/dist/android/libbox.aar(由 build-android.sh 产出)。 + * 方法签名注释标注 "// libbox API" 处若编译失败,请对照实际 libbox.aar 中 + * go.libbox.* 的 javadoc 微调参数名;整体架构不变。 */ class PangolinVpnService : VpnService() { @@ -43,7 +67,7 @@ class PangolinVpnService : VpnService() { } else { context.startService(intent) } - Log.d(TAG, "startVpn: intent dispatched") + Log.d(TAG, "startVpn: intent dispatched, configLen=${configJson.length}") } fun stopVpn(context: Context) { @@ -55,6 +79,23 @@ class PangolinVpnService : VpnService() { } } + // ── 内核与 TUN 状态 ─────────────────────────────────────────── + + /** libbox BoxService 内核实例(主线程以外创建,通过 volatile 保证可见性) */ + @Volatile private var boxService: BoxService? = null + + /** TUN 文件描述符持有者(VpnService.Builder.establish() 返回值) */ + @Volatile var currentTunPfd: ParcelFileDescriptor? = null + + /** libbox CommandClient(统计与状态轮询) */ + @Volatile private var commandClient: CommandClient? = null + + /** 兜底统计定时器(CommandClient 不可用时启用 TrafficStats 方案) */ + private var statsExecutor: ScheduledExecutorService? = null + + /** 防止 doStop 重入 */ + private val stopping = AtomicBoolean(false) + // ── 生命周期 ──────────────────────────────────────────────── override fun onCreate() { @@ -72,65 +113,377 @@ class PangolinVpnService : VpnService() { START_STICKY } ACTION_STOP -> { - handleStop() + doStop() START_NOT_STICKY } else -> { - Log.w(TAG, "onStartCommand: unknown action=${intent?.action}") + Log.w(TAG, "onStartCommand: unknown action, falling through") 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") - handleStop() + doStop() super.onDestroy() } - override fun onRevoke() { - Log.w(TAG, "onRevoke: VPN permission revoked by system") - // TODO(11E): 通知 Dart 侧 status=error - handleStop() - } - - // ── 内部处理 ──────────────────────────────────────────────── + // ── 启动流程 ───────────────────────────────────────────────── private fun handleStart(configJson: String) { - Log.i(TAG, "handleStart: config=${configJson.take(80)}…") + stopping.set(false) startForeground(NOTIFICATION_ID, buildNotification("正在连接…")) - // TODO(11E): 解析 configJson → 构建 VPN 接口 → 启动 sing-box 内核 - // 状态变化后通过 VpnEventBus.postStatus("connecting") / ("on") 通知 MainActivity + VpnEventBus.postStatus("connecting") + + // libbox 内核启动必须在后台线程(不能阻塞 onStartCommand) + Thread { + try { + startLibbox(configJson) + } catch (e: Exception) { + Log.e(TAG, "startLibbox failed: $e", e) + VpnEventBus.postStatus("error") + updateNotification("连接失败") + doStop() + } + }.apply { name = "pangolin-libbox-start" }.start() } - private fun handleStop() { - Log.i(TAG, "handleStop") + /** + * 在后台线程内: + * 1. 构造 PlatformInterface(inner class,持有 VpnService 引用) + * 2. 新建 BoxService 并 start()(内核会回调 openTun 建 TUN 接口) + * 3. 启动 CommandClient 轮询统计 + * 4. 推送 "on" 状态 + */ + private fun startLibbox(configJson: String) { + val platform = PangolinPlatformInterface() + + // ── 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") + + // 启动统计客户端 + startCommandClientOrFallback() + + // 更新通知与状态 + updateNotification("加速已开启") + VpnEventBus.postStatus("on") + } + + // ── 统计:CommandClient + TrafficStats 兜底 ─────────────────── + + /** + * 优先使用 libbox CommandClient 获取精确统计;若连接失败则退回 Android TrafficStats。 + */ + private fun startCommandClientOrFallback() { + try { + startCommandClient() + } catch (e: Exception) { + Log.w(TAG, "CommandClient unavailable ($e), falling back to TrafficStats") + startTrafficStatsFallback() + } + } + + 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) {} + } + + // 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 + + 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 + + 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") + } + }, 1L, 1L, TimeUnit.SECONDS) + } + 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") + + // 1. 停统计(先于 BoxService,避免最后一帧读到已关闭的 fd) + statsExecutor?.apply { + shutdownNow() + try { awaitTermination(500, TimeUnit.MILLISECONDS) } catch (_: InterruptedException) {} + } + statsExecutor = null + + try { + commandClient?.disconnect() // libbox API + } catch (e: Exception) { + Log.w(TAG, "commandClient.disconnect failed: $e") + } + commandClient = null + + // 2. 关闭 BoxService 内核(会在内部关闭它持有的 TUN fd 引用) + try { + boxService?.close() // libbox API + } catch (e: Exception) { + Log.w(TAG, "boxService.close failed: $e") + } + boxService = null + + // 3. 关闭 ParcelFileDescriptor(持有底层 fd;必须在 BoxService.close 之后) + try { + currentTunPfd?.close() + } catch (e: Exception) { + Log.w(TAG, "tunPfd.close failed: $e") + } + currentTunPfd = null + + // 4. 停前台 + 自我停止 + @Suppress("DEPRECATION") stopForeground(true) stopSelf() - // TODO(11E): 停止 sing-box 内核 → 关闭 TUN fd + + VpnEventBus.postStatus("off") + Log.i(TAG, "doStop: done") } - // ── 前台通知 ──────────────────────────────────────────────── + // ── PlatformInterface(inner class,持有 VpnService.Builder 访问权)──── + + /** + * sing-box libbox 平台回调实现。 + * + * [openTun]: 用 TunOptions 配置 VpnService.Builder,调用 establish() 取 TUN fd。 + * [autoDetectInterfaceControl]: 调用 VpnService.protect(fd) 防止 TUN 流量形成环路。 + */ + private inner class PangolinPlatformInterface : PlatformInterface { + + /** + * 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,此处可直接调用) + + // ── 地址配置 ────────────────────────────────────────── + // 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) + } + } else { + // 兜底:使用 PoC 静态配置的地址 + builder.addAddress("172.19.0.1", 30) + } + + 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") + "" + } + + private fun tryGetInet6(options: TunOptions): String = try { + options.inet6Address() + } catch (e: Exception) { + "" + } + + 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 配置对齐 + } + } + + // ── 通知 ───────────────────────────────────────────────────── private fun createNotificationChannel() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val channel = NotificationChannel( NOTIFICATION_CHANNEL_ID, - "穿山甲 VPN", + "穿山甲加速", NotificationManager.IMPORTANCE_LOW ).apply { - description = "穿山甲 VPN 隧道状态" + description = "穿山甲加速状态" setShowBadge(false) } - val nm = getSystemService(NotificationManager::class.java) - nm.createNotificationChannel(channel) + getSystemService(NotificationManager::class.java) + .createNotificationChannel(channel) } } + private fun updateNotification(contentText: String) { + getSystemService(NotificationManager::class.java) + .notify(NOTIFICATION_ID, buildNotification(contentText)) + } + 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 } val openIntent = PendingIntent.getActivity( @@ -148,7 +501,7 @@ class PangolinVpnService : VpnService() { 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/kotlin/com/pangolin/pangolin_vpn/VpnEventBus.kt b/client/android/app/src/main/kotlin/com/pangolin/pangolin_vpn/VpnEventBus.kt index 6d0eea4..41b6ce6 100644 --- a/client/android/app/src/main/kotlin/com/pangolin/pangolin_vpn/VpnEventBus.kt +++ b/client/android/app/src/main/kotlin/com/pangolin/pangolin_vpn/VpnEventBus.kt @@ -27,11 +27,19 @@ object VpnEventBus { @Volatile private var statusListener: StatusListener? = null @Volatile private var statsListener: StatsListener? = null + /** + * 当前 VPN 状态快照,供 getStatus() 一次性查询使用。 + * 值:off | connecting | on | error + */ + @Volatile var currentStatus: String = "off" + private set + fun setStatusListener(l: StatusListener?) { statusListener = l } fun setStatsListener(l: StatsListener?) { statsListener = l } /** 由 PangolinVpnService(可能在工作线程)调用 */ fun postStatus(status: String) { + currentStatus = status mainHandler.post { statusListener?.onStatus(status) } }