merge: Android M2 VPN 隧道 PangolinVpnService [tsk_w1NvZdmwGPyd]
This commit is contained in:
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,25 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<!-- 网络权限(实际隧道流量) -->
|
||||
<!-- 网络权限(隧道流量) -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<!-- 前台服务(VPN 持续运行所需,API 28+) -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
|
||||
<!--
|
||||
Android 14 (API 34+) 要求前台服务必须声明类型。
|
||||
VPN 不属于任何预定义类型,故使用 specialUse。
|
||||
Play Store 上架时需在 specialUse 说明中填写 VPN 用途(国内合规绕过此字段)。
|
||||
-->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
|
||||
<!--
|
||||
允许应用请求豁免电池优化(后台 30 分钟不被杀必须)。
|
||||
不能在运行时动态请求,必须在 manifest 声明。
|
||||
-->
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||
|
||||
<application
|
||||
android:label="穿山甲"
|
||||
android:name="${applicationName}"
|
||||
@@ -30,18 +44,27 @@
|
||||
</activity>
|
||||
|
||||
<!--
|
||||
VPN 服务(骨架)。
|
||||
VPN 服务。
|
||||
android:permission="android.permission.BIND_VPN_SERVICE" 确保只有系统可 bind。
|
||||
intent-filter android.net.VpnService 是 VpnService 规范要求。
|
||||
TODO(11E): 启用后在设备上需用户授权 VPN 权限(VpnService.prepare())。
|
||||
android:foregroundServiceType="specialUse" — Android 14 (API 34) 强制要求。
|
||||
-->
|
||||
<service
|
||||
android:name=".PangolinVpnService"
|
||||
android:exported="false"
|
||||
android:permission="android.permission.BIND_VPN_SERVICE">
|
||||
android:permission="android.permission.BIND_VPN_SERVICE"
|
||||
android:foregroundServiceType="specialUse">
|
||||
<intent-filter>
|
||||
<action android:name="android.net.VpnService" />
|
||||
</intent-filter>
|
||||
|
||||
<!--
|
||||
Android 14 specialUse 前台服务类型需在此声明用途说明。
|
||||
若目标 API < 34 可忽略此 meta-data;保留不影响低版本兼容性。
|
||||
-->
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="vpn" />
|
||||
</service>
|
||||
|
||||
<meta-data
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
{
|
||||
"_comment": "PoC M2 单节点测试配置(11E / tsk_w1NvZdmwGPyd)",
|
||||
"_usage": "填入真实节点参数后,在 Dart 侧 VpnNativeBridge.start(configJson) 传入",
|
||||
"log": {
|
||||
"level": "warn",
|
||||
"timestamp": true
|
||||
},
|
||||
"inbounds": [
|
||||
{
|
||||
"type": "tun",
|
||||
"tag": "tun-in",
|
||||
"address": ["172.19.0.1/30"],
|
||||
"mtu": 9000,
|
||||
"auto_route": true,
|
||||
"strict_route": true,
|
||||
"stack": "system"
|
||||
}
|
||||
],
|
||||
"outbounds": [
|
||||
{
|
||||
"type": "vless",
|
||||
"tag": "reality-out",
|
||||
"server": "__NODE_HOST__",
|
||||
"server_port": 11443,
|
||||
"uuid": "__REALITY_UUID__",
|
||||
"flow": "xtls-rprx-vision",
|
||||
"tls": {
|
||||
"enabled": true,
|
||||
"server_name": "www.apple.com",
|
||||
"utls": {
|
||||
"enabled": true,
|
||||
"fingerprint": "chrome"
|
||||
},
|
||||
"reality": {
|
||||
"enabled": true,
|
||||
"public_key": "__REALITY_PUBLIC_KEY__",
|
||||
"short_id": "__REALITY_SHORT_ID__"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "hysteria2",
|
||||
"tag": "hy2-out",
|
||||
"server": "__NODE_HOST__",
|
||||
"server_port": 443,
|
||||
"password": "__HY2_PASSWORD__",
|
||||
"tls": {
|
||||
"enabled": true,
|
||||
"insecure": true,
|
||||
"alpn": ["h3"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "urltest",
|
||||
"tag": "auto",
|
||||
"outbounds": ["reality-out", "hy2-out"],
|
||||
"url": "https://www.gstatic.com/generate_204",
|
||||
"interval": "3m",
|
||||
"tolerance": 50
|
||||
},
|
||||
{
|
||||
"type": "block",
|
||||
"tag": "block"
|
||||
},
|
||||
{
|
||||
"type": "direct",
|
||||
"tag": "direct"
|
||||
}
|
||||
],
|
||||
"route": {
|
||||
"rules": [
|
||||
{
|
||||
"ip_cidr": [
|
||||
"10.0.0.0/8",
|
||||
"172.16.0.0/12",
|
||||
"192.168.0.0/16",
|
||||
"127.0.0.0/8"
|
||||
],
|
||||
"outbound": "direct"
|
||||
}
|
||||
],
|
||||
"final": "auto",
|
||||
"auto_detect_interface": true
|
||||
},
|
||||
"dns": {
|
||||
"servers": [
|
||||
{
|
||||
"tag": "remote",
|
||||
"address": "tls://8.8.8.8",
|
||||
"detour": "auto"
|
||||
},
|
||||
{
|
||||
"tag": "local",
|
||||
"address": "223.5.5.5",
|
||||
"detour": "direct"
|
||||
}
|
||||
],
|
||||
"final": "remote",
|
||||
"strategy": "ipv4_only"
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,12 @@
|
||||
package com.pangolin.pangolin_vpn
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.net.VpnService
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.PowerManager
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
@@ -14,8 +21,14 @@ import io.flutter.plugin.common.MethodChannel
|
||||
* EventChannel : pangolin/vpn/status
|
||||
* EventChannel : pangolin/vpn/stats
|
||||
*
|
||||
* MethodChannel 路由到 PangolinVpnService(通过 Intent)。
|
||||
* EventChannel 由 VpnEventBus 驱动(Service → EventBus → EventSink → Flutter)。
|
||||
* ## VPN 权限流程(Android 要求)
|
||||
* 1. [VpnService.prepare] 返回非 null Intent → 需要用户授权
|
||||
* 2. 启动授权 Activity,result code 存 [pendingConfigJson]
|
||||
* 3. [onActivityResult] 回调 RESULT_OK → 正式启动 [PangolinVpnService]
|
||||
* RESULT_CANCELED → 推送 error 状态到 Flutter
|
||||
*
|
||||
* ## 电池优化
|
||||
* 首次连接时引导用户豁免电池优化,确保后台 30 分钟不被杀。
|
||||
*/
|
||||
class MainActivity : FlutterActivity() {
|
||||
|
||||
@@ -24,9 +37,13 @@ class MainActivity : FlutterActivity() {
|
||||
private const val VPN_CHANNEL = "pangolin/vpn"
|
||||
private const val STATUS_CHANNEL = "pangolin/vpn/status"
|
||||
private const val STATS_CHANNEL = "pangolin/vpn/stats"
|
||||
|
||||
private const val VPN_PERMISSION_REQUEST_CODE = 100
|
||||
}
|
||||
|
||||
// 持有 EventSink 引用,以便 VpnEventBus 回调时转发
|
||||
/** 等待 VPN 授权时缓存的 configJson,授权完成后使用 */
|
||||
private var pendingConfigJson: String? = null
|
||||
|
||||
private var statusEventSink: EventChannel.EventSink? = null
|
||||
private var statsEventSink: EventChannel.EventSink? = null
|
||||
|
||||
@@ -40,31 +57,30 @@ class MainActivity : FlutterActivity() {
|
||||
when (call.method) {
|
||||
"start" -> {
|
||||
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
|
||||
|
||||
+382
-29
@@ -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<String, Any>(
|
||||
"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<Any>()
|
||||
)
|
||||
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<String, Any>(
|
||||
"uploadBytes" to totalTx,
|
||||
"downloadBytes" to totalRx,
|
||||
"uploadSpeed" to txDiff.toDouble(),
|
||||
"downloadSpeed" to rxDiff.toDouble(),
|
||||
"urltestResults" to emptyList<Any>()
|
||||
)
|
||||
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)
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user