feat(android): libbox 隧道重写对接真实 1.13.12 API,APK 编译通过(Task 5)

原生层照 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 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-22 19:28:24 +08:00
parent 2a8426c357
commit 126c06833d
10 changed files with 512 additions and 381 deletions
+8 -28
View File
@@ -1,3 +1,9 @@
plugins {
id "com.android.application"
id "kotlin-android"
id "dev.flutter.flutter-gradle-plugin"
}
def localProperties = new Properties() def localProperties = new Properties()
def localPropertiesFile = rootProject.file('local.properties') def localPropertiesFile = rootProject.file('local.properties')
if (localPropertiesFile.exists()) { 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') def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
if (flutterVersionCode == null) { if (flutterVersionCode == null) {
flutterVersionCode = '1' flutterVersionCode = '1'
@@ -21,10 +22,6 @@ if (flutterVersionName == null) {
flutterVersionName = '1.0' flutterVersionName = '1.0'
} }
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"
android { android {
namespace "com.pangolin.pangolin_vpn" namespace "com.pangolin.pangolin_vpn"
compileSdkVersion flutter.compileSdkVersion compileSdkVersion flutter.compileSdkVersion
@@ -57,17 +54,6 @@ android {
signingConfig signingConfigs.debug 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 { flutter {
@@ -75,21 +61,15 @@ flutter {
} }
dependencies { dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
// ── sing-box libboxgomobile AAR)────────────────────────────── // ── sing-box libboxgomobile AAR)──────────────────────────────
// 产物由 app/kernel/build-android.sh 生成: // 产物由 app/kernel/build-android.sh 生成:
// cd app/kernel && ./build-android.sh // cd app/kernel && ./build-android.sh
// 产物路径(相对于本 build.gradle):../../../app/kernel/dist/android/libbox.aar // 路径:client/android/app → ../../.. → repo root → app/kernel/dist/android/
// // Kotlin stdlib 由 kotlin-android 插件自动引入,无需显式声明。
// 若文件不存在,构建会报错:请先运行 build-android.sh 生成产物。
// 路径说明:client/android/app → ../../.. → repo root → app/kernel/dist/android/
def libboxAar = file("${projectDir}/../../../app/kernel/dist/android/libbox.aar") def libboxAar = file("${projectDir}/../../../app/kernel/dist/android/libbox.aar")
if (libboxAar.exists()) { if (libboxAar.exists()) {
implementation files(libboxAar) implementation files(libboxAar)
} else { } else {
// AAR 尚未构建 — 保留占位;IDE 会报红线,但不影响非 libbox 代码编辑。
// 运行 `app/kernel/build-android.sh` 后重新 sync 即可。
logger.warn("⚠ libbox.aar not found at ${libboxAar.absolutePath}") logger.warn("⚠ libbox.aar not found at ${libboxAar.absolutePath}")
logger.warn(" Run: cd app/kernel && ./build-android.sh") logger.warn(" Run: cd app/kernel && ./build-android.sh")
} }
@@ -23,7 +23,7 @@
<application <application
android:label="穿山甲" android:label="穿山甲"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"> android:icon="@drawable/ic_launcher">
<!-- 主 Activity --> <!-- 主 Activity -->
<activity <activity
@@ -0,0 +1,124 @@
package com.pangolin.pangolin_vpn
import android.content.Context
import android.net.ConnectivityManager
import android.net.LinkProperties
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.util.Log
import libbox.InterfaceUpdateListener
import java.net.NetworkInterface
/**
* DefaultNetworkMonitor — 上报「底层默认网络」给 sing-box 内核。
*
* sing-box 在 Android 上不自己枚举接口(PlatformInterface.usePlatformAutoDetectInterfaceControl=true),
* 它通过 [InterfaceUpdateListener.updateDefaultInterface] 得知当前默认出口接口(名字+index),
* 据此把出站 socket 绑定到底层物理网络(避免 TUN 路由环路)。
*
* **缺这个监听 → 内核启动期拿不到默认接口 → 报 "no available network interface",隧道连不上。**
* 故 [start] 必须在 CommandServer.startOrReloadService 之前调用,并把首个默认网络同步上报。
*
* 自包含实现:直接用 ConnectivityManager.registerNetworkCallbackAPI 21+),
* 不依赖 SFA 的 Application/协程框架。
*/
object DefaultNetworkMonitor {
private const val TAG = "PangolinNetMonitor"
@Volatile private var listener: InterfaceUpdateListener? = null
@Volatile private var defaultNetwork: Network? = null
private var connectivity: ConnectivityManager? = null
private var callback: ConnectivityManager.NetworkCallback? = null
/** 注册默认网络回调,并立即同步一次当前网络。幂等。 */
fun start(context: Context) {
if (callback != null) return
val cm = context.applicationContext
.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
connectivity = cm
val cb = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
defaultNetwork = network
notifyUpdate(network)
}
override fun onLinkPropertiesChanged(network: Network, lp: LinkProperties) {
defaultNetwork = network
notifyUpdate(network)
}
override fun onLost(network: Network) {
if (defaultNetwork == network) {
defaultNetwork = null
notifyUpdate(null)
}
}
}
callback = cb
// 要求一个具备 INTERNET 能力的网络(等价于默认网络)。registerNetworkCallback 支持 API 21+。
val request = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
try {
cm.registerNetworkCallback(request, cb)
} catch (e: Exception) {
Log.e(TAG, "registerNetworkCallback failed: $e")
}
// 同步当前活动网络(回调可能不会立刻触发 onAvailable)。
val active = cm.activeNetwork
if (active != null) {
defaultNetwork = active
notifyUpdate(active)
}
}
fun stop() {
val cm = connectivity
val cb = callback
if (cm != null && cb != null) {
try { cm.unregisterNetworkCallback(cb) } catch (_: Exception) {}
}
callback = null
connectivity = null
defaultNetwork = null
}
/** 内核注册/注销监听(PlatformInterface.start/closeDefaultInterfaceMonitor 调用)。 */
fun setListener(l: InterfaceUpdateListener?) {
listener = l
if (l != null) notifyUpdate(defaultNetwork)
}
/** 把网络的接口名 + index 上报内核;网络为 null 时上报空("",-1)。 */
private fun notifyUpdate(network: Network?) {
val l = listener ?: return
if (network == null) {
l.updateDefaultInterface("", -1, false, false)
return
}
val cm = connectivity ?: return
// LinkProperties 可能短暂为 null,重试几次拿接口名。
for (i in 0 until 10) {
val lp = cm.getLinkProperties(network)
val name = lp?.interfaceName
if (name == null) {
try { Thread.sleep(100) } catch (_: InterruptedException) {}
continue
}
val index = try {
NetworkInterface.getByName(name)?.index ?: -1
} catch (_: Exception) {
-1
}
l.updateDefaultInterface(name, index, false, false)
return
}
Log.w(TAG, "notifyUpdate: interface name unavailable after retries")
}
}
@@ -68,14 +68,14 @@ class MainActivity : FlutterActivity() {
} }
"selectOutbound" -> { "selectOutbound" -> {
val tag = call.arguments as? String ?: "auto" val tag = call.arguments as? String ?: "auto"
Log.d(TAG, "selectOutbound: tag=$tag (stub — 11G)") PangolinVpnService.instance?.selectOutbound(tag)
// TODO(11G): 通过 libbox CommandClient 切换出口 Log.d(TAG, "selectOutbound: tag=$tag")
result.success(null) result.success(null)
} }
"getActiveOutbound" -> { "getActiveOutbound" -> {
Log.d(TAG, "getActiveOutbound (stub — 11G)") val active = PangolinVpnService.instance?.currentActiveOutbound() ?: "auto"
// TODO(11G): 从 libbox CommandClient 查询当前出口 tag Log.d(TAG, "getActiveOutbound -> $active")
result.success("auto") result.success(active)
} }
"setKillSwitch" -> { "setKillSwitch" -> {
val on = call.arguments as? Boolean ?: false val on = call.arguments as? Boolean ?: false
@@ -6,57 +6,69 @@ import android.app.NotificationManager
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.TrafficStats
import android.net.VpnService import android.net.VpnService
import android.os.Build import android.os.Build
import android.os.ParcelFileDescriptor import android.os.ParcelFileDescriptor
import android.system.OsConstants
import android.util.Log import android.util.Log
import go.libbox.BoxService import go.Seq
import go.libbox.CommandClient import libbox.CommandClient
import go.libbox.CommandClientHandler import libbox.CommandClientHandler
import go.libbox.CommandClientOptions import libbox.CommandClientOptions
import go.libbox.Libbox import libbox.CommandServer
import go.libbox.OutboundGroupIterator import libbox.CommandServerHandler
import go.libbox.PlatformInterface import libbox.ConnectionEvents
import go.libbox.ProcessInfo import libbox.ConnectionOwner
import go.libbox.StatusMessage import libbox.InterfaceUpdateListener
import go.libbox.StringIterator import libbox.Libbox
import go.libbox.TunOptions import libbox.LocalDNSTransport
import java.util.concurrent.Executors import libbox.LogIterator
import java.util.concurrent.ScheduledExecutorService import libbox.NetworkInterfaceIterator
import java.util.concurrent.TimeUnit 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 import java.util.concurrent.atomic.AtomicBoolean
/** /**
* PangolinVpnService — VPN 前台服务,封装 sing-box libbox 隧道。 * PangolinVpnService — VPN 前台服务,封装 sing-box libbox 隧道。
* *
* ## 生命周期 * ## 架构(sing-box 1.13.x libboxCommandServer 模型)
* START → handleStart() → 后台线程 → startLibbox() → [BoxService.start()] * Libbox.setup(SetupOptions) —— 进程级一次,设可写目录 + 命令 socket
* → VpnEventBus.postStatus("on") * CommandServer(handler, platformInterface).start()
* STOP → doStop() → [BoxService.close()] → 关 TUN fd → postStatus("off") * commandServer.startOrReloadService(configJson, OverrideOptions()) —— 起内核,回调 openTun
* REVOKE → onRevoke() → postStatus("error") → doStop() * 停止:closeService() → close()
* *
* ## 通信 * 本类同时实现 [PlatformInterface]openTun/protect/接口监听…)与 [CommandServerHandler]。
* Dart 通过 [MainActivity] MethodChannel 发送 start/stop intent * 统计与切节点经独立的 [CommandClient](同进程连内核命令 socket)
* 状态与统计经 [VpnEventBus] 推回 Flutter EventChannel。
* *
* ## 注意:libbox.aar API * ## 与 Dart 通信
* 依赖 app/kernel/dist/android/libbox.aar(由 build-android.sh 产出)。 * 状态/统计经 [VpnEventBus] 推回 Flutter EventChannel(契约见 lib/bridge/vpn_bridge.dart)。
* 方法签名注释标注 "// libbox API" 处若编译失败,请对照实际 libbox.aar 中 * selectOutbound/getActiveOutbound 由 [MainActivity] 通过 [instance] 调用。
* go.libbox.* 的 javadoc 微调参数名;整体架构不变。
*/ */
class PangolinVpnService : VpnService() { class PangolinVpnService : VpnService(), PlatformInterface, CommandServerHandler {
companion object { companion object {
private const val TAG = "PangolinVpnService" private const val TAG = "PangolinVpnService"
const val ACTION_START = "com.pangolin.vpn.START" 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" const val EXTRA_CONFIG = "config_json"
private const val NOTIFICATION_CHANNEL_ID = "pangolin_vpn_channel" private const val NOTIFICATION_CHANNEL_ID = "pangolin_vpn_channel"
private const val NOTIFICATION_ID = 1001 private const val NOTIFICATION_ID = 1001
/** 同进程内供 MainActivity 调用 selectOutbound/getActiveOutbound。 */
@Volatile var instance: PangolinVpnService? = null
private set
fun startVpn(context: Context, configJson: String) { fun startVpn(context: Context, configJson: String) {
val intent = Intent(context, PangolinVpnService::class.java).apply { val intent = Intent(context, PangolinVpnService::class.java).apply {
action = ACTION_START action = ACTION_START
@@ -67,7 +79,6 @@ class PangolinVpnService : VpnService() {
} else { } else {
context.startService(intent) context.startService(intent)
} }
Log.d(TAG, "startVpn: intent dispatched, configLen=${configJson.length}")
} }
fun stopVpn(context: Context) { fun stopVpn(context: Context) {
@@ -75,37 +86,35 @@ class PangolinVpnService : VpnService() {
action = ACTION_STOP action = ACTION_STOP
} }
context.startService(intent) context.startService(intent)
Log.d(TAG, "stopVpn: intent dispatched")
} }
} }
// ── 内核与 TUN 状态 ─────────────────────────────────────────── // ── 内核与 TUN 状态 ───────────────────────────────────────────
@Volatile private var commandServer: CommandServer? = null
@Volatile private var statsClient: CommandClient? = null
@Volatile private var tunPfd: ParcelFileDescriptor? = null
/** libbox BoxService 内核实例(主线程以外创建,通过 volatile 保证可见性) */ /** 最近一次 writeGroups 缓存的「代理组当前出口」与 urltest 延迟,供 getActiveOutbound/stats 用。 */
@Volatile private var boxService: BoxService? = null @Volatile private var activeOutbound: String = "auto"
@Volatile private var latestUrltest: List<Pair<String, Int>> = emptyList()
/** TUN 文件描述符持有者(VpnService.Builder.establish() 返回值) */ /** 缓存的「可手动选择的出口组」tag(selector 组)。selectOutbound 据此切换。 */
@Volatile var currentTunPfd: ParcelFileDescriptor? = null @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 val stopping = AtomicBoolean(false)
private var libboxSetupDone = false
// ── 生命周期 ──────────────────────────────────────────────── // ── 生命周期 ────────────────────────────────────────────────
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
Log.i(TAG, "onCreate") instance = this
// gomobile 绑定需要 Android Context(否则部分调用崩溃)。
Seq.setContext(applicationContext)
createNotificationChannel() createNotificationChannel()
} }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Log.i(TAG, "onStartCommand: action=${intent?.action}")
return when (intent?.action) { return when (intent?.action) {
ACTION_START -> { ACTION_START -> {
val configJson = intent.getStringExtra(EXTRA_CONFIG) ?: "{}" val configJson = intent.getStringExtra(EXTRA_CONFIG) ?: "{}"
@@ -116,23 +125,19 @@ class PangolinVpnService : VpnService() {
doStop() doStop()
START_NOT_STICKY START_NOT_STICKY
} }
else -> { else -> START_NOT_STICKY
Log.w(TAG, "onStartCommand: unknown action, falling through")
START_NOT_STICKY
}
} }
} }
override fun onRevoke() { override fun onRevoke() {
// 系统撤销 VPN 权限(如用户在设置里关闭)→ 推 error 再清理
Log.w(TAG, "onRevoke: permission revoked by system") Log.w(TAG, "onRevoke: permission revoked by system")
VpnEventBus.postStatus("error") VpnEventBus.postStatus("error")
doStop() doStop()
} }
override fun onDestroy() { override fun onDestroy() {
Log.i(TAG, "onDestroy")
doStop() doStop()
instance = null
super.onDestroy() super.onDestroy()
} }
@@ -143,12 +148,12 @@ class PangolinVpnService : VpnService() {
startForeground(NOTIFICATION_ID, buildNotification("正在连接…")) startForeground(NOTIFICATION_ID, buildNotification("正在连接…"))
VpnEventBus.postStatus("connecting") VpnEventBus.postStatus("connecting")
// libbox 内核启动必须在后台线程(不能阻塞 onStartCommand // 内核启动必须在后台线程(不能阻塞 onStartCommandopenTun 回调会同步等 TUN 建好)。
Thread { Thread {
try { try {
startLibbox(configJson) startKernel(configJson)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "startLibbox failed: $e", e) Log.e(TAG, "startKernel failed: $e", e)
VpnEventBus.postStatus("error") VpnEventBus.postStatus("error")
updateNotification("连接失败") updateNotification("连接失败")
doStop() doStop()
@@ -156,305 +161,311 @@ class PangolinVpnService : VpnService() {
}.apply { name = "pangolin-libbox-start" }.start() }.apply { name = "pangolin-libbox-start" }.start()
} }
/** private fun startKernel(configJson: String) {
* 在后台线程内: ensureLibboxSetup()
* 1. 构造 PlatformInterfaceinner 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) // 先起默认接口监听,再起内核——否则内核启动期拿不到默认出口,报 no available network interface。
// 如果编译报错,可能 API 为: new BoxService(platform, configJson) DefaultNetworkMonitor.start(applicationContext)
// 或: BoxService.newService(platform, configJson)
val service = Libbox.newBoxService(platform, configJson, false) // libbox API
service.start() // libbox API
boxService = service
Log.i(TAG, "BoxService started")
// 启动统计客户端 val server = Libbox.newCommandServer(this, this)
startCommandClientOrFallback() server.start()
commandServer = server
server.startOrReloadService(configJson, OverrideOptions())
Log.i(TAG, "sing-box service started")
startStatsClient()
// 更新通知与状态
updateNotification("加速已开启") updateNotification("加速已开启")
VpnEventBus.postStatus("on") 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)")
}
/** // ── 统计 + 切节点:CommandClient ──────────────────────────────
* 优先使用 libbox CommandClient 获取精确统计;若连接失败则退回 Android TrafficStats。
*/ private fun startStatsClient() {
private fun startCommandClientOrFallback() {
try { 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) { } catch (e: Exception) {
Log.w(TAG, "CommandClient unavailable ($e), falling back to TrafficStats") Log.w(TAG, "stats CommandClient failed: $e")
startTrafficStatsFallback()
} }
} }
private fun startCommandClient() { /** 由 MainActivity(同进程)调用:切换出口节点到 [tag](在缓存的可选组内)。 */
val options = CommandClientOptions() fun selectOutbound(tag: String) {
// Command 0 = STATUS(含流量统计);间隔 1 秒(纳秒) val group = selectableGroup
// libbox API: options.Command / options.StatusInterval 为 gomobile 暴露的字段 if (group.isEmpty()) {
options.command = 0 // libbox API field Log.w(TAG, "selectOutbound: 无可选出口组(config 可能只有 urltest,无 selector)")
options.statusInterval = 1_000_000_000L // libbox API field: nanoseconds return
}
val handler = object : CommandClientHandler { try {
override fun connected() { statsClient?.selectOutbound(group, tag)
Log.d(TAG, "CommandClient connected") Log.i(TAG, "selectOutbound(group=$group, tag=$tag)")
} } catch (e: Exception) {
override fun disconnected(message: String) { Log.w(TAG, "selectOutbound failed: $e")
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")
} }
/** fun currentActiveOutbound(): String = activeOutbound
* 兜底:用 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 -> private inner class StatsHandler : CommandClientHandler {
exec.scheduleAtFixedRate({ override fun connected() { Log.d(TAG, "stats client connected") }
if (stopping.get()) return@scheduleAtFixedRate override fun disconnected(message: String?) { Log.d(TAG, "stats client disconnected: $message") }
try { override fun clearLogs() {}
val rx = TrafficStats.getUidRxBytes(uid).let { if (it < 0) 0L else it } override fun writeLogs(messageList: LogIterator?) {}
val tx = TrafficStats.getUidTxBytes(uid).let { if (it < 0) 0L else it } override fun setDefaultLogLevel(level: Int) {}
val rxDiff = (rx - lastRx).coerceAtLeast(0) override fun initializeClashMode(modeList: StringIterator?, currentMode: String?) {}
val txDiff = (tx - lastTx).coerceAtLeast(0) override fun updateClashMode(newMode: String?) {}
totalRx += rxDiff override fun writeConnectionEvents(message: ConnectionEvents?) {}
totalTx += txDiff
lastRx = rx
lastTx = tx
val stats = mapOf<String, Any>( override fun writeStatus(message: StatusMessage) {
"uploadBytes" to totalTx, val stats = mapOf<String, Any>(
"downloadBytes" to totalRx, "uploadBytes" to message.uplinkTotal,
"uploadSpeed" to txDiff.toDouble(), "downloadBytes" to message.downlinkTotal,
"downloadSpeed" to rxDiff.toDouble(), "uploadSpeed" to message.uplink.toDouble(),
"urltestResults" to emptyList<Any>() "downloadSpeed" to message.downlink.toDouble(),
) "urltestResults" to latestUrltest.map {
VpnEventBus.postStats(stats) mapOf<String, Any>("tag" to it.first, "delayMs" to it.second)
} catch (e: Exception) {
Log.w(TAG, "TrafficStats error: $e")
} }
}, 1L, 1L, TimeUnit.SECONDS) )
VpnEventBus.postStats(stats)
}
override fun writeGroups(groups: OutboundGroupIterator?) {
if (groups == null) return
val urltest = mutableListOf<Pair<String, Int>>()
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() { private fun doStop() {
if (!stopping.compareAndSet(false, true)) { if (!stopping.compareAndSet(false, true)) return
Log.d(TAG, "doStop: already stopping, skip") Log.i(TAG, "doStop")
return
}
Log.i(TAG, "doStop: shutting down tunnel")
// 1. 停统计(先于 BoxService,避免最后一帧读到已关闭的 fd) try { statsClient?.disconnect() } catch (_: Exception) {}
statsExecutor?.apply { statsClient = null
shutdownNow()
try { awaitTermination(500, TimeUnit.MILLISECONDS) } catch (_: InterruptedException) {}
}
statsExecutor = null
try { try { commandServer?.closeService() } catch (e: Exception) { Log.w(TAG, "closeService: $e") }
commandClient?.disconnect() // libbox API try { commandServer?.close() } catch (e: Exception) { Log.w(TAG, "close: $e") }
} catch (e: Exception) { commandServer = null
Log.w(TAG, "commandClient.disconnect failed: $e")
}
commandClient = null
// 2. 关闭 BoxService 内核(会在内部关闭它持有的 TUN fd 引用) DefaultNetworkMonitor.stop()
try {
boxService?.close() // libbox API
} catch (e: Exception) {
Log.w(TAG, "boxService.close failed: $e")
}
boxService = null
// 3. 关闭 ParcelFileDescriptor(持有底层 fd;必须在 BoxService.close 之后) try { tunPfd?.close() } catch (_: Exception) {}
try { tunPfd = null
currentTunPfd?.close()
} catch (e: Exception) {
Log.w(TAG, "tunPfd.close failed: $e")
}
currentTunPfd = null
// 4. 停前台 + 自我停止
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
stopForeground(true) stopForeground(true)
stopSelf() stopSelf()
VpnEventBus.postStatus("off") VpnEventBus.postStatus("off")
Log.i(TAG, "doStop: done")
} }
// ── PlatformInterfaceinner class,持有 VpnService.Builder 访问权)──── // ══════════════════════════════════════════════════════════════
// PlatformInterface 实现(1.13.12 共 15 法)
// ══════════════════════════════════════════════════════════════
/** override fun openTun(options: TunOptions): Int {
* sing-box libbox 平台回调实现。 if (prepare(this) != null) throw Exception("android: missing vpn permission")
*
* [openTun]: 用 TunOptions 配置 VpnService.Builder,调用 establish() 取 TUN fd。
* [autoDetectInterfaceControl]: 调用 VpnService.protect(fd) 防止 TUN 流量形成环路。
*/
private inner class PangolinPlatformInterface : PlatformInterface {
/** val builder = Builder()
* libbox 回调 — 创建 TUN 接口并返回 fd。 .setSession("Pangolin")
* .setMtu(options.getMTU())
* 注:TunOptions 的具体 getter 方法名(inet4Address / mtu 等)
* 需与实际 libbox.aar 中 go.libbox.TunOptions 的 javadoc 对齐。
* 若方法不存在,请使用 options 对应的实际方法名替换。
*/
override fun openTun(options: TunOptions): Int {
val builder = Builder() // VpnService.Builderinner class,此处可直接调用)
// ── 地址配置 ────────────────────────────────────────── if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) builder.setMetered(false)
// libbox API: TunOptions.inet4Address() 返回 CIDR 字符串(如 "172.19.0.1/30"
// 若 API 名不同(如 getInet4Address())请对应修改 // 地址(RoutePrefixIterator
val inet4 = tryGetInet4(options) val inet4 = options.inet4Address
if (inet4.isNotEmpty()) { while (inet4.hasNext()) {
val slash = inet4.indexOf('/') val a = inet4.next(); builder.addAddress(a.address(), a.prefix())
if (slash > 0) { }
builder.addAddress(inet4.substring(0, slash), inet4.substring(slash + 1).toInt()) val inet6 = options.inet6Address
} else { while (inet6.hasNext()) {
builder.addAddress(inet4, 30) val a = inet6.next(); builder.addAddress(a.address(), a.prefix())
}
if (options.autoRoute) {
// DNS1.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<libbox.NetworkInterface>()
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 { box.flags = flags
// 兜底:使用 PoC 静态配置的地址 box.type = Libbox.InterfaceTypeOther
builder.addAddress("172.19.0.1", 30) 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)
// ── 路由:将所有流量导入 TUNsing-box auto_route = true)────
builder.addRoute("0.0.0.0", 0) // IPv4 全部流量
builder.addRoute("::", 0) // IPv6 全部流量
// ── DNSsing-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) { } 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 { override fun findConnectionOwner(
options.inet6Address() ipProtocol: Int, sourceAddress: String, sourcePort: Int,
} catch (e: Exception) { destinationAddress: String, destinationPort: Int,
"" ): ConnectionOwner {
} // 我们的配置不含进程匹配规则,核心不会调用;保守抛出。
throw Exception("findConnectionOwner not supported")
}
private fun tryGetMtu(options: TunOptions): Int = try { override fun includeAllNetworks(): Boolean = false
// libbox API: options.mtu() returns Int32/Int
options.mtu().toInt().coerceIn(576, 65535) override fun clearDNSCache() {}
} catch (e: Exception) {
9000 // 兜底:与 PoC 配置对齐 override fun readWIFIState(): WIFIState? = null
}
override fun localDNSTransport(): LocalDNSTransport? = null
override fun systemCertificates(): StringIterator = StringArray(emptyList<String>().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<String>) : 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<libbox.NetworkInterface>
) : NetworkInterfaceIterator {
override fun hasNext(): Boolean = it.hasNext()
override fun next(): libbox.NetworkInterface = it.next()
} }
// ── 通知 ───────────────────────────────────────────────────── // ── 通知 ─────────────────────────────────────────────────────
@@ -462,46 +473,36 @@ class PangolinVpnService : VpnService() {
private fun createNotificationChannel() { private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel( val channel = NotificationChannel(
NOTIFICATION_CHANNEL_ID, NOTIFICATION_CHANNEL_ID, "穿山甲加速", NotificationManager.IMPORTANCE_LOW
"穿山甲加速",
NotificationManager.IMPORTANCE_LOW
).apply { ).apply {
description = "穿山甲加速状态" description = "穿山甲加速状态"
setShowBadge(false) setShowBadge(false)
} }
getSystemService(NotificationManager::class.java) getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
.createNotificationChannel(channel)
} }
} }
private fun updateNotification(contentText: String) { private fun updateNotification(text: String) {
getSystemService(NotificationManager::class.java) getSystemService(NotificationManager::class.java).notify(NOTIFICATION_ID, buildNotification(text))
.notify(NOTIFICATION_ID, buildNotification(contentText))
} }
private fun buildNotification(contentText: String): Notification { private fun buildNotification(contentText: String): Notification {
val pendingFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { val pendingFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
} else { } else {
@Suppress("DEPRECATION") @Suppress("DEPRECATION") PendingIntent.FLAG_UPDATE_CURRENT
PendingIntent.FLAG_UPDATE_CURRENT
} }
val openIntent = PendingIntent.getActivity( val openIntent = PendingIntent.getActivity(
this, 0, this, 0, Intent(this, MainActivity::class.java), pendingFlags
Intent(this, MainActivity::class.java),
pendingFlags
) )
val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Notification.Builder(this, NOTIFICATION_CHANNEL_ID) Notification.Builder(this, NOTIFICATION_CHANNEL_ID)
} else { } else {
@Suppress("DEPRECATION") @Suppress("DEPRECATION") Notification.Builder(this)
Notification.Builder(this)
} }
return builder return builder
.setContentTitle("穿山甲") .setContentTitle("穿山甲")
.setContentText(contentText) // "加速已开启" / "正在连接…" / "连接失败" .setContentText(contentText)
.setSmallIcon(android.R.drawable.ic_dialog_info) .setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentIntent(openIntent) .setContentIntent(openIntent)
.setOngoing(true) .setOngoing(true)
@@ -0,0 +1,16 @@
<!-- 占位启动图标(vector,全 API 可用)。正式图标待设计接入后替换。 -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#0F1117"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#E0884F"
android:pathData="M54,28 L80,82 L28,82 Z" />
<path
android:fillColor="#0F1117"
android:pathData="M54,52 L66,76 L42,76 Z" />
</vector>
+2 -15
View File
@@ -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 { allprojects {
repositories { repositories {
google() google()
@@ -18,12 +5,12 @@ allprojects {
} }
} }
rootProject.buildDir = '../build' rootProject.buildDir = "../build"
subprojects { subprojects {
project.buildDir = "${rootProject.buildDir}/${project.name}" project.buildDir = "${rootProject.buildDir}/${project.name}"
} }
subprojects { subprojects {
project.evaluationDependsOn(':app') project.evaluationDependsOn(":app")
} }
tasks.register("clean", Delete) { tasks.register("clean", Delete) {
+4
View File
@@ -1,3 +1,7 @@
org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true android.useAndroidX=true
android.enableJetifier=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
@@ -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
+22 -8
View File
@@ -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") includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
def properties = new Properties()
assert localPropertiesFile.exists() repositories {
localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) } google()
mavenCentral()
gradlePluginPortal()
}
}
def flutterSdkPath = properties.getProperty("flutter.sdk") plugins {
assert flutterSdkPath != null, "flutter.sdk not set in local.properties" id "dev.flutter.flutter-plugin-loader" version "1.0.0"
apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle" id "com.android.application" version "8.6.0" apply false
id "org.jetbrains.kotlin.android" version "2.2.0" apply false
}
include ":app"