Files
EasyTier/tauri-plugin-vpnservice/android/src/main/java/TauriVpnService.kt
T
KKRainbow 686a059b9c Configure one mobile VPN from running TUN instances
Use admitted native members to configure VpnService and attach its
single fd to the core shared TUN. Wait for attachment completion and
record the actual attached members before refreshing VPN state. Report
rejected members in the GUI and preserve the VPN for unchanged healthy
members.

Reconcile ordinary configuration notifications without invalidating an
arbitrary member. Invalidate attachments on instance lifecycle changes,
including restarts that reuse an instance ID. Forward public IPv6
changes alongside proxy route changes to refresh the platform VPN
configuration.

Exclude confirmed IPv4-less members from readiness gating while
retaining retries for unavailable status and pending addresses. Quick
Settings stops all running TUN members and restores that group on the
next start, skipping removed configurations and no_tun networks.

Keep the FFI set_tun_fd call scoped to one instance and report
attachment errors. Cover mobile configuration, member rejection and
replacement, attachment completion, collection notifications, IPv4-less
members, and tile stop/start restoration in GUI regression tests.

Validate with 36 mobile VPN tests, vue-tsc --noEmit, Android GUI cargo
check, workspace clippy, and shared TUN network namespace tests.
2026-10-13 10:23:08 +08:00

224 lines
7.6 KiB
Kotlin

package com.plugin.vpnservice
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Intent
import android.net.VpnService
import android.os.Build
import android.os.ParcelFileDescriptor
import android.os.Bundle
import android.content.pm.ServiceInfo
import androidx.core.app.NotificationCompat
import android.system.OsConstants.AF_INET6
import java.net.InetAddress
import java.util.Arrays
import app.tauri.plugin.JSObject
class TauriVpnService : VpnService() {
companion object {
@JvmField var triggerCallback: (String, JSObject) -> Unit = { _, _ -> }
@JvmField var self: TauriVpnService? = null
@JvmField var ipv4Addr: String? = null
@JvmField var ipv4Addrs: Array<String> = emptyArray()
@JvmField var routes: Array<String> = emptyArray()
@JvmField var dns: String? = null
const val IPV4_ADDR = "IPV4_ADDR"
const val IPV4_ADDRS = "IPV4_ADDRS"
const val ROUTES = "ROUTES"
const val DNS = "DNS"
const val DISALLOWED_APPLICATIONS = "DISALLOWED_APPLICATIONS"
const val MTU = "MTU"
private const val NOTIFICATION_CHANNEL_ID = "easytier_vpn_channel"
private const val NOTIFICATION_ID = 1356
}
private lateinit var vpnInterface: ParcelFileDescriptor
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
println("vpn on start command ${intent?.getExtras()} $intent")
startVpnForegroundService()
var args = intent?.getExtras()
ipv4Addrs = getIpv4Addrs(args)
ipv4Addr = ipv4Addrs.firstOrNull()
routes = args?.getStringArray(ROUTES) ?: emptyArray()
dns = args?.getString(DNS)
vpnInterface = createVpnInterface(args)
println("vpn created ${vpnInterface.fd}")
var event_data = JSObject()
event_data.put("fd", vpnInterface.fd)
triggerCallback("vpn_service_start", event_data)
EasyTierVpnTileService.requestStateUpdate(this)
return START_STICKY
}
override fun onCreate() {
super.onCreate()
self = this
println("vpn on create")
}
override fun onDestroy() {
println("vpn on destroy")
disconnect()
setMainForegroundServiceEnabled(true)
stopForeground(STOP_FOREGROUND_REMOVE)
self = null
EasyTierVpnTileService.requestStateUpdate(this)
super.onDestroy()
}
override fun onRevoke() {
println("vpn on revoke")
disconnect()
setMainForegroundServiceEnabled(true)
stopForeground(STOP_FOREGROUND_REMOVE)
self = null
EasyTierVpnTileService.requestStateUpdate(this)
super.onRevoke()
}
private fun disconnect() {
if (self == this && this::vpnInterface.isInitialized) {
triggerCallback("vpn_service_stop", JSObject())
vpnInterface.close()
}
clearStatus()
}
private fun clearStatus() {
ipv4Addr = null
ipv4Addrs = emptyArray()
routes = emptyArray()
dns = null
}
private fun startVpnForegroundService() {
createNotificationChannel()
val launchIntent = packageManager.getLaunchIntentForPackage(packageName)?.apply {
addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)
}
val contentIntent = launchIntent?.let {
PendingIntent.getActivity(
this,
0,
it,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
}
val notification = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_menu_manage)
.setContentTitle("EasyTier VPN is running")
.setContentText("VPN connection is active")
.setOngoing(true)
.setOnlyAlertOnce(true)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
.apply { contentIntent?.let(::setContentIntent) }
.build()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startForeground(
NOTIFICATION_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE,
)
} else {
startForeground(NOTIFICATION_ID, notification)
}
// A TUN-backed network is now protected by this foreground service, so the
// no-TUN keepalive service is redundant and would show a second notification.
setMainForegroundServiceEnabled(false)
}
private fun setMainForegroundServiceEnabled(enabled: Boolean) {
val intent = Intent().setClassName(packageName, "$packageName.MainForegroundService")
if (!enabled) {
stopService(intent)
return
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(intent)
} else {
startService(intent)
}
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
NOTIFICATION_CHANNEL_ID,
"EasyTier VPN",
NotificationManager.IMPORTANCE_LOW,
)
getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
}
}
private fun getIpv4Addrs(args: Bundle?): Array<String> {
val ipv4Addrs = args
?.getStringArray(IPV4_ADDRS)
?.filter { it.isNotBlank() }
?.toTypedArray()
?: emptyArray()
if (ipv4Addrs.isNotEmpty()) {
return ipv4Addrs
}
return arrayOf(args?.getString(IPV4_ADDR) ?: "10.126.126.1/24")
}
private fun createVpnInterface(args: Bundle?): ParcelFileDescriptor {
var builder = Builder()
.setSession("TauriVpnService")
.setBlocking(false)
var mtu = args?.getInt(MTU) ?: 1500
var ipv4Addrs = getIpv4Addrs(args)
var dns: String? = args?.getString(DNS)
var routes = args?.getStringArray(ROUTES) ?: emptyArray()
var disallowedApplications = args?.getStringArray(DISALLOWED_APPLICATIONS) ?: emptyArray()
println("vpn create vpn interface. mtu: $mtu, ipv4Addrs: ${java.util.Arrays.toString(ipv4Addrs)}, dns:" +
"$dns, routes: ${java.util.Arrays.toString(routes)}," +
"disallowedApplications: ${java.util.Arrays.toString(disallowedApplications)}")
for (ipv4Addr in ipv4Addrs) {
val ipParts = ipv4Addr.split("/")
if (ipParts.size != 2) throw IllegalArgumentException("Invalid IP addr string")
builder.addAddress(ipParts[0], ipParts[1].toInt())
}
builder.allowFamily(AF_INET6)
builder.setMtu(mtu)
dns?.let { builder.addDnsServer(it) }
for (route in routes) {
val ipParts = route.split("/")
if (ipParts.size != 2) throw IllegalArgumentException("Invalid route cidr string")
builder.addRoute(ipParts[0], ipParts[1].toInt())
}
for (app in disallowedApplications) {
builder.addDisallowedApplication(app)
}
return builder.also {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
it.setMetered(false)
}
}
.establish()
?: throw IllegalStateException("Failed to init VpnService")
}
}