shorter version, but work [AI FULL]

This commit is contained in:
2026-03-19 09:03:44 +07:00
parent 3321036ae5
commit c9b53b1cfe
@@ -1,15 +1,11 @@
package app.netrunner.vpn
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.app.*
import android.content.*
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.net.VpnService
import android.os.Build
import android.os.ParcelFileDescriptor
import android.os.Parcelable
import android.os.*
import android.util.Log
import android.webkit.WebView
import androidx.activity.result.ActivityResult
@@ -41,513 +37,199 @@ data class TunnelConfig @JsonCreator constructor(
@JsonProperty("mtu") val mtu: Int
) : Parcelable
// ------------------------------------------------
// VPN SERVICE
// ------------------------------------------------
class TunVpnService : VpnService() {
private var activeSession: Session? = null
private var vpnInterface: ParcelFileDescriptor? = null
companion object {
const val ACTION_STOP_VPN = "app.netrunner.vpn.STOP_FROM_NOTIFICATION"
@Volatile
var isServiceRunning = false
@Volatile var isServiceRunning = false
}
override fun onCreate() {
super.onCreate()
isServiceRunning = true
Log.d(TAG, "TunVpnService created")
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Log.d(TAG, "onStartCommand called action=${intent?.action}")
startForegroundNotification()
when (intent?.action) {
"START_VPN" -> {
val ip = intent.getStringExtra("ip") ?: ""
val port = intent.getIntExtra("port", 0)
Log.d(TAG, "START_VPN received ip=$ip port=$port")
val config = if (android.os.Build.VERSION.SDK_INT >= 33) {
intent.getParcelableExtra("config", TunnelConfig::class.java)
} else {
@Suppress("DEPRECATION")
intent.getParcelableExtra("config")
}
if (config == null) {
Log.e(TAG, "TunnelConfig is NULL")
} else {
Log.d(TAG, "Config received: $config")
startVpn(config, ip, port)
}
}
"STOP_VPN", ACTION_STOP_VPN -> {
Log.d(TAG, "STOP_VPN received")
stopServiceProperly()
}
else -> {
Log.w(TAG, "Unknown action ${intent?.action}")
val config = if (Build.VERSION.SDK_INT >= 33) intent.getParcelableExtra("config", TunnelConfig::class.java)
else @Suppress("DEPRECATION") intent.getParcelableExtra("config")
config?.let { startVpn(it, ip, port) }
}
"STOP_VPN", ACTION_STOP_VPN -> stopServiceProperly()
}
return START_STICKY
}
private fun sendStatusBroadcast(status: String) {
val intent = Intent("com.netrunner.vpn.STATUS_UPDATE").apply {
putExtra("status", status)
// Указываем пакет нашего приложения, чтобы интент не улетел в другие аппки
// и гарантированно дошел до нашего VpnPlugin
setPackage(packageName)
private fun startVpn(config: TunnelConfig, ip: String, port: Int) {
val builder = Builder().addAddress(config.address, config.prefix)
.addDnsServer(config.dns).addRoute("0.0.0.0", 0).setMtu(config.mtu)
try { builder.addDisallowedApplication(packageName) } catch (e: Exception) { Log.e(TAG, "Exclude fail") }
vpnInterface = builder.establish() ?: return
val fd = vpnInterface!!.detachFd()
try {
activeSession = SessionManager().startMobile("$ip:$port", fd, cacheDir.absolutePath)
triggerVibration()
sendStatusBroadcast("connected")
} catch (e: Exception) {
Log.e(TAG, "Rust fail", e)
stopServiceProperly()
}
sendBroadcast(intent)
Log.d("VPN_DEBUG", ">>> [SERVICE] Broadcast sent: $status")
}
private fun stopServiceProperly() {
stopVpn()
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N) {
stopForeground(android.app.Service.STOP_FOREGROUND_REMOVE)
} else {
@Suppress("DEPRECATION")
stopForeground(true)
}
stopSelf()
}
private fun startVpn(config: TunnelConfig, ip: String, port: Int) {
Log.d(TAG, "startVpn called")
val builder = Builder()
.addAddress(config.address, config.prefix)
.addDnsServer(config.dns)
.addRoute("0.0.0.0", 0)
.setMtu(config.mtu)
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.LOLLIPOP) {
try {
// Исключаем само приложение из проксирования, чтобы не было петли
builder.addDisallowedApplication(packageName)
} catch (e: Exception) {
Log.e(TAG, "Can't exclude app: ${e.message}")
}
}
vpnInterface = builder.establish()
if (vpnInterface == null) {
Log.e(TAG, "builder.establish() returned null")
return
}
val fd = vpnInterface?.detachFd() ?: run {
Log.e(TAG, "detachFd failed")
return
}
try {
val manager = SessionManager()
val cachePath = cacheDir.absolutePath
Log.d(TAG, "Starting Rust Session with cachePath: $cachePath")
activeSession = manager.startMobile("$ip:$port", fd, cachePath)
triggerVibration()
sendStatusBroadcast("connected")
Log.d(TAG, "Session started successfully")
} catch (e: Exception) {
Log.e(TAG, "Rust manager.startMobile failed", e)
sendStatusBroadcast("idle")
stopVpn()
}
}
private fun stopVpn() {
Log.d(TAG, "stopVpn called")
triggerVibration()
sendStatusBroadcast("idle")
activeSession?.stop()
activeSession = null
try {
vpnInterface?.close()
Log.d(TAG, "vpnInterface closed")
} catch (e: Exception) {
Log.e(TAG, "Error closing interface", e)
}
try { vpnInterface?.close() } catch (e: Exception) { }
vpnInterface = null
isServiceRunning = false
sendStatusBroadcast("idle")
stopSelf()
}
private fun sendStatusBroadcast(status: String) {
sendBroadcast(Intent("com.netrunner.vpn.STATUS_UPDATE").apply {
putExtra("status", status)
setPackage(packageName)
})
}
private fun startForegroundNotification() {
val channelId = "vpn_service_channel"
val channelName = "Netrunner VPN Service"
val notificationManager = getSystemService(android.app.NotificationManager::class.java)
val stopIntent = Intent(this, TunVpnService::class.java).apply {
action = ACTION_STOP_VPN
val channelId = "vpn_channel"
val nm = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
nm.createNotificationChannel(NotificationChannel(channelId, "VPN Service", NotificationManager.IMPORTANCE_LOW))
}
val stopPendingIntent = android.app.PendingIntent.getService(
this, 1, stopIntent,
android.app.PendingIntent.FLAG_IMMUTABLE or android.app.PendingIntent.FLAG_UPDATE_CURRENT
)
val stopPI = PendingIntent.getService(this, 1, Intent(this, TunVpnService::class.java).apply { action = ACTION_STOP_VPN },
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
val contentPI = PendingIntent.getActivity(this, 0, packageManager.getLaunchIntentForPackage(packageName), PendingIntent.FLAG_IMMUTABLE)
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
val channel = android.app.NotificationChannel(
channelId, channelName, android.app.NotificationManager.IMPORTANCE_DEFAULT
).apply {
description = "Показывает статус работы VPN"
setShowBadge(false)
}
notificationManager.createNotificationChannel(channel)
}
// Создаем Intent, чтобы при клике на уведомление открывалось приложение
val contentPendingIntent = android.app.PendingIntent.getActivity(
this, 0,
packageManager.getLaunchIntentForPackage(packageName),
android.app.PendingIntent.FLAG_IMMUTABLE
)
val stopAction = android.app.Notification.Action.Builder(
android.R.drawable.ic_menu_close_clear_cancel, // Иконка
"Отключить", // Текст
stopPendingIntent // Действие
).build()
val notification = android.app.Notification.Builder(this, channelId)
.setContentTitle("Netrunner VPN")
.setContentText("Защита активна")
.setSmallIcon(android.R.drawable.ic_dialog_info) // Стандартная системная иконка
.setContentIntent(contentPendingIntent)
.setOngoing(true)
.addAction(stopAction)
.setCategory(android.app.Notification.CATEGORY_SERVICE)
val notification = (if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) Notification.Builder(this, channelId) else Notification.Builder(this))
.setContentTitle("Netrunner VPN").setContentText("Защита активна")
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentIntent(contentPI).setOngoing(true)
.addAction(android.R.drawable.ic_menu_close_clear_cancel, "Отключить", stopPI)
.build()
try {
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startForeground(1, notification, android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE)
} else {
startForeground(1, notification)
}
Log.d(TAG, "Foreground notification started successfully")
} catch (e: Exception) {
Log.e(TAG, "Failed to start foreground: ${e.message}")
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startForeground(1, notification, android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE)
} else startForeground(1, notification)
}
private fun triggerVibration() {
val vibrator = getSystemService(android.content.Context.VIBRATOR_SERVICE) as android.os.Vibrator
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
vibrator.vibrate(android.os.VibrationEffect.createOneShot(500, android.os.VibrationEffect.EFFECT_HEAVY_CLICK))
} else {
@Suppress("DEPRECATION")
vibrator.vibrate(500)
}
}
override fun onTaskRemoved(rootIntent: Intent?) {
Log.d(TAG, "UI process killed (swiped), but VPN process should keep running.")
val v = getSystemService(VIBRATOR_SERVICE) as Vibrator
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) v.vibrate(VibrationEffect.createOneShot(50, VibrationEffect.DEFAULT_AMPLITUDE))
else @Suppress("DEPRECATION") v.vibrate(50)
}
override fun onDestroy() {
Log.d(TAG, "TunVpnService destroyed")
sendStatusBroadcast("idle")
isServiceRunning = false
super.onDestroy()
}
}
// ------------------------------------------------
// TAURI PLUGIN
// ------------------------------------------------
@TauriPlugin
class VpnPlugin(private val activity: Activity) : Plugin(activity) {
companion object {
private var instance: VpnPlugin? = null
fun sendStatus(status: String) {
val hasInstance = (instance != null)
Log.d(TAG, ">>> [PLUGIN COMPANION] sendStatus called: $status | Instance exists: $hasInstance")
if (hasInstance) {
instance?.updateStatus(status)
} else {
Log.e(TAG, "CRITICAL: Cannot send status, VpnPlugin instance is NULL")
}
}
}
private var pendingArgs: VpnArgs? = null
private val statusReceiver = object : android.content.BroadcastReceiver() {
override fun onReceive(context: android.content.Context?, intent: android.content.Intent?) {
val status = intent?.getStringExtra("status") ?: return
Log.d(TAG, ">>> [RECEIVER] Got status from Service: $status")
instance?.updateStatus(status)
private val statusReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
intent?.getStringExtra("status")?.let { updateStatus(it) }
}
}
private fun registerStatusReceiver() {
val filter = IntentFilter("com.netrunner.vpn.STATUS_UPDATE")
if (Build.VERSION.SDK_INT >= 33) {
activity.registerReceiver(statusReceiver, filter, android.content.Context.RECEIVER_NOT_EXPORTED)
} else {
activity.registerReceiver(statusReceiver, filter)
}
Log.d(TAG, "Status receiver registered successfully")
}
override fun load(webView: WebView) {
super.load(webView)
instance = this
Log.d(TAG, "VpnPlugin loaded with WebView and instance assigned")
try { System.loadLibrary("netrunner_client") } catch (e: Exception) { Log.e(TAG, "Lib fail") }
try {
System.loadLibrary("netrunner_client")
Log.d(TAG, "Rust library loaded")
} catch (e: Exception) {
Log.e(TAG, "Failed to load Rust library: ${e.message}")
val filter = IntentFilter("com.netrunner.vpn.STATUS_UPDATE")
if (Build.VERSION.SDK_INT >= 33) {
activity.registerReceiver(statusReceiver, filter, Context.RECEIVER_NOT_EXPORTED)
} else {
activity.registerReceiver(statusReceiver, filter)
}
requestNotificationPermission()
CoroutineScope(Dispatchers.Main).launch {
kotlinx.coroutines.delay(500)
val currentStatus = getHardwareVpnStatus()
updateStatus(currentStatus)
Log.d(TAG, "Plugin reloaded. Current status: $currentStatus")
}
}
private fun isVpnServiceRunning(): Boolean {
val manager = activity.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
@Suppress("DEPRECATION")
return manager.getRunningServices(Integer.MAX_VALUE).any {
it.service.className == TunVpnService::class.java.name
}
}
private fun getHardwareVpnStatus(): String {
val hasTun = try {
java.net.NetworkInterface.getNetworkInterfaces().asSequence().any {
it.isUp && it.name.contains("tun")
}
} catch (e: Exception) { false }
CoroutineScope(Dispatchers.IO).launch {
updateBlocklistIfNeeded()
}
registerStatusReceiver()
// Если интерфейс поднят ИЛИ сервис числится в запущенных — значит мы connected
val isRunning = hasTun || isVpnServiceRunning() || TunVpnService.isServiceRunning
return if (isRunning) "connected" else "idle"
}
private fun updateBlocklistIfNeeded() {
try {
val cachePath = activity.cacheDir.absolutePath
val hostsFile = File(cachePath, "hosts_cache.txt")
val isFresh = hostsFile.exists() &&
(System.currentTimeMillis() - hostsFile.lastModified() < 24 * 60 * 60 * 1000)
if (isFresh) {
Log.d(TAG, "DNS: Blocklist is fresh, skipping download")
return
}
Log.d(TAG, "DNS: Starting blocklist download via Kotlin...")
// Качаем
val url = URL("https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts")
val connection = url.openConnection()
connection.connectTimeout = 15000
connection.readTimeout = 15000
val bytes = connection.getInputStream().use { it.readBytes() }
// Сохраняем
hostsFile.writeBytes(bytes)
Log.d(TAG, "DNS: Blocklist updated! Size: ${bytes.size} bytes")
} catch (e: Exception) {
Log.e(TAG, "DNS: Failed to download blocklist: ${e.message}")
}
}
private fun requestNotificationPermission() {
if (android.os.Build.VERSION.SDK_INT >= 33) {
val permission = "android.permission.POST_NOTIFICATIONS"
if (androidx.core.content.ContextCompat.checkSelfPermission(activity, permission) !=
android.content.pm.PackageManager.PERMISSION_GRANTED) {
androidx.core.app.ActivityCompat.requestPermissions(activity, arrayOf(permission), 101)
}
}
}
@InvokeArg
class VpnArgs {
var config: TunnelConfig? = null
var ip: String = ""
var port: Int = 0
}
private var pendingArgs: VpnArgs? = null
@Command
fun startVpn(invoke: Invoke) {
Log.d(TAG, "startVpn command invoked")
val args = invoke.parseArgs(VpnArgs::class.java)
Log.d(TAG, "Args parsed ip=${args.ip} port=${args.port}")
val config = args.config
if (config == null) {
Log.e(TAG, "Config is null")
invoke.reject("Config is null")
return
}
val permissionIntent = VpnService.prepare(activity)
if (permissionIntent != null) {
Log.d(TAG, "VPN permission required")
val intent = VpnService.prepare(activity)
if (intent != null) {
pendingArgs = args
startActivityForResult(
invoke,
permissionIntent,
"onVpnPermissionResult"
)
invoke.resolve()
return
startActivityForResult(invoke, intent, "onVpnPermissionResult")
} else {
startVpnService(args)
}
Log.d(TAG, "Permission already granted")
startVpnService(args)
invoke.resolve()
}
private fun startVpnService(args: VpnArgs) {
Log.d(TAG, "Starting TunVpnService directly")
val intent = Intent(activity, TunVpnService::class.java).apply {
action = "START_VPN"
putExtra("ip", args.ip)
putExtra("port", args.port)
putExtra("config", args.config)
}
// Для современных Android (API 26+) используем Foreground, для старых - обычный.
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
activity.startForegroundService(intent)
} else {
activity.startService(intent)
}
}
fun updateStatus(status: String) {
val data = JSObject()
data.put("status", status)
Log.d(TAG, ">>> [PLUGIN INSTANCE] trigger() called with event: status-change, data: $status")
try {
// Пробуем вызвать trigger. В Tauri 2.0 он должен прокинуть это в JS
trigger("status-change", data)
Log.d(TAG, ">>> [PLUGIN INSTANCE] trigger() executed successfully")
} catch (e: Exception) {
Log.e(TAG, "!!! [PLUGIN INSTANCE] trigger() FAILED: ${e.message}", e)
action = "START_VPN"; putExtra("ip", args.ip); putExtra("port", args.port); putExtra("config", args.config)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) activity.startForegroundService(intent)
else activity.startService(intent)
}
@ActivityCallback
fun onVpnPermissionResult(invoke: Invoke, result: ActivityResult) {
Log.d(TAG, "VPN permission result received")
if (result.resultCode == Activity.RESULT_OK) {
Log.d(TAG, "VPN permission granted")
pendingArgs?.let {
startVpnService(it)
invoke.resolve()
}
} else {
Log.w(TAG, "VPN permission denied")
invoke.reject("Permission denied")
}
if (result.resultCode == Activity.RESULT_OK) pendingArgs?.let { startVpnService(it) }
pendingArgs = null
invoke.resolve()
}
@Command
fun stopVpn(invoke: Invoke) {
Log.d(TAG, "stopVpn command invoked")
val intent = Intent(activity, TunVpnService::class.java).apply {
action = "STOP_VPN"
}
activity.startService(intent)
Log.d(TAG, "STOP_VPN intent sent")
activity.startService(Intent(activity, TunVpnService::class.java).apply { action = "STOP_VPN" })
invoke.resolve()
}
private fun isVpnProcessRunning(): Boolean {
val manager = activity.getSystemService(android.content.Context.ACTIVITY_SERVICE) as android.app.ActivityManager
val runningProcesses = manager.runningAppProcesses ?: return false
val vpnProcessName = "${activity.packageName}:vpn"
for (processInfo in runningProcesses) {
if (processInfo.processName == vpnProcessName) {
return true
}
}
return false
}
@Command
fun checkActualVpnStatus(invoke: Invoke) {
// Используем activity.getSystemService
val cm = activity.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
var isVpnActive = false
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) {
val activeNetwork = cm.activeNetwork
val capabilities = cm.getNetworkCapabilities(activeNetwork)
// Проверяем наличие TRANSPORT_VPN
isVpnActive = capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_VPN) ?: false
}
val hasTunInterface = try {
java.net.NetworkInterface.getNetworkInterfaces()?.asSequence()?.any {
it.isUp && it.name.contains("tun")
} ?: false
} catch (e: Exception) { false }
val processAlive = isVpnProcessRunning()
// Если хоть один из признаков говорит, что VPN мертв — ставим idle
val status = if (isVpnActive && hasTunInterface && processAlive) "connected" else "idle"
Log.d(TAG, "HARD CHECK: TransportVPN=$isVpnActive, Tun=$hasTunInterface, Proc=$processAlive -> Result=$status")
val res = JSObject()
res.put("status", status)
invoke.resolve(res)
val status = getHardwareVpnStatus()
invoke.resolve(JSObject().apply { put("status", status) })
}
private fun updateStatus(status: String) {
trigger("status-change", JSObject().apply { put("status", status) })
}
@InvokeArg class VpnArgs { var config: TunnelConfig? = null; var ip: String = ""; var port: Int = 0 }
}