From 1fcdd9c58de0939d13de7040cbd426bf30107151 Mon Sep 17 00:00:00 2001 From: Naveax Date: Fri, 4 Sep 2026 00:08:34 +0300 Subject: [PATCH] fix(app): align Android VPN runtime integration Remove obsolete UI-process runtime plumbing and reuse the native service-owned notification and generation-scoped control path. --- android/app/src/main/AndroidManifest.xml | 3 - .../kotlin/com/paladinvpn/app/MainActivity.kt | 116 ++++-------------- lib/logic/notification_service.dart | 5 +- 3 files changed, 25 insertions(+), 99 deletions(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 1476797..0b45736 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -10,9 +10,6 @@ - - when (call.method) { - // Doze and App Standby stop background services of apps that - // are not exempt. A VPN has to be exempt to survive the screen - // going off, so surface the real state rather than guessing. "isIgnoringBatteryOptimizations" -> result.success(isIgnoringBatteryOptimizations()) "requestIgnoreBatteryOptimizations" -> result.success(requestIgnoreBatteryOptimizations()) - "setSessionExpiry" -> { - val expiresAtMs = call.argument("expiresAtMs")?.toLong() ?: 0L - if (expiresAtMs > 0) { - sendSessionExpiryToDaemon(expiresAtMs) - } - result.success(null) - } - // Is our VPN runtime actually alive? The service lives in - // :RunSoLibXrayDaemon, a separate process, so its statics are - // NOT visible here -- this is the only honest check. - "isVpnRuntimeAlive" -> result.success(isVpnRuntimeAlive()) else -> result.notImplemented() } } @@ -118,7 +101,6 @@ class MainActivity : FlutterActivity() { super.onDestroy() } - /** True when the OS has exempted us from Doze / App Standby. */ private fun isIgnoringBatteryOptimizations(): Boolean { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return true return try { @@ -129,11 +111,6 @@ class MainActivity : FlutterActivity() { } } - /** - * Opens the system exemption prompt. Android shows this as a single dialog; - * it cannot be granted silently, so the caller must treat a false return as - * "ask again later" rather than a denial. - */ private fun requestIgnoreBatteryOptimizations(): Boolean { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return true if (isIgnoringBatteryOptimizations()) return true @@ -146,7 +123,6 @@ class MainActivity : FlutterActivity() { ) true } catch (_: Exception) { - // Some OEM builds hide the direct intent; fall back to the list. try { startActivity(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)) true @@ -156,58 +132,6 @@ class MainActivity : FlutterActivity() { } } - /** Pushes the session-expiry deadline to the VPN service process. */ - private fun sendSessionExpiryToDaemon(expiresAtMs: Long) { - try { - val intent = Intent(this, XrayVPNService::class.java) - intent.putExtra("SESSION_EXPIRES_AT_MS", expiresAtMs) - startService(intent) - } catch (_: Exception) { - // Daemon not reachable; its persisted expiry still applies. - } - } - - /** - * Whether the VPN runtime is genuinely alive. - * - * The Xray service runs in :RunSoLibXrayDaemon, a separate process, so its - * statics (AppConfigs.V2RAY_CONFIG, V2RAY_STATE) are NOT visible from here -- - * reading them yields a fresh, empty copy. getRunningAppProcesses still - * returns our *own* processes, which makes it the one reliable signal. - */ - private fun isVpnRuntimeAlive(): Map { - var processAlive = false - try { - val am = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager - processAlive = am.runningAppProcesses - ?.any { it.processName.endsWith(VPN_PROCESS_SUFFIX) } == true - } catch (_: Exception) { - } - - var vpnTransport = false - try { - val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - vpnTransport = cm.allNetworks.any { network -> - cm.getNetworkCapabilities(network) - ?.hasTransport(NetworkCapabilities.TRANSPORT_VPN) == true - } - } catch (_: Exception) { - } - - return mapOf( - "processAlive" to processAlive, - "vpnTransport" to vpnTransport, - ) - } - - /** Removes a notification left behind by a runtime that is no longer alive. */ - private fun cancelVpnNotification() { - try { - NotificationManagerCompat.from(this).cancel(NOTIFICATION_ID) - } catch (_: Exception) { - } - } - private fun updateVpnNotification(title: String, text: String, actionLabel: String) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) != @@ -216,15 +140,6 @@ class MainActivity : FlutterActivity() { return } - // The foreground notification (id 1) is owned by XrayVPNService via - // startForeground. Posting to it from this process while the service is - // gone leaves a frozen countdown nothing will ever update again. - val alive = isVpnRuntimeAlive()["processAlive"] == true - if (!alive) { - cancelVpnNotification() - return - } - ensureNotificationChannel() val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { @@ -234,17 +149,28 @@ class MainActivity : FlutterActivity() { } val launchIntent = packageManager.getLaunchIntentForPackage(packageName) - launchIntent?.action = "FROM_DISCONNECT_BTN" launchIntent?.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK - val stopIntent = Intent(this, XrayVPNService::class.java) - stopIntent.putExtra("COMMAND", AppConfigs.V2RAY_SERVICE_COMMANDS.STOP_SERVICE) - val stopPendingIntent = PendingIntent.getService(this, 0, stopIntent, flags) + // The VPN service owns the authoritative Disconnect PendingIntent and + // embeds the current runtime-generation token in it. Never recreate an + // unscoped STOP intent from the UI process: a stale notification could + // otherwise stop a newer tunnel. Reuse the live service action when the + // platform exposes it; on older Android versions omit the action rather + // than weaken generation scoping. + val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val serviceStopAction = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + manager.activeNotifications + .firstOrNull { it.id == NOTIFICATION_ID } + ?.notification + ?.actions + ?.firstOrNull() + ?.actionIntent + } else { + null + } - // Public lock-screen preview: keep the VPN status visible but hide the - // session countdown/speed so usage metadata never reaches the lock screen. val publicVersion = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID) .setSmallIcon(R.drawable.notification_status_icon) .setContentTitle("Revolt VPN") @@ -255,7 +181,6 @@ class MainActivity : FlutterActivity() { .setSmallIcon(R.drawable.notification_status_icon) .setContentTitle(title) .setContentText(text) - .addAction(0, actionLabel, stopPendingIntent) .setColorized(true) .setColor(0xFF0D1117.toInt()) .setOngoing(true) @@ -267,6 +192,10 @@ class MainActivity : FlutterActivity() { .setVisibility(NotificationCompat.VISIBILITY_PRIVATE) .setPublicVersion(publicVersion) + if (serviceStopAction != null) { + builder.addAction(0, actionLabel, serviceStopAction) + } + if (launchIntent != null) { builder.setContentIntent(PendingIntent.getActivity(this, 0, launchIntent, flags)) } @@ -473,7 +402,6 @@ class MainActivity : FlutterActivity() { companion object { private const val NOTIFICATION_ID = 1 - private const val VPN_PROCESS_SUFFIX = ":RunSoLibXrayDaemon" - private const val NOTIFICATION_CHANNEL_ID = "XRAY_SERVICE_CHANNEL" + private const val NOTIFICATION_CHANNEL_ID = "REVOLT_VPN_SERVICE" } } diff --git a/lib/logic/notification_service.dart b/lib/logic/notification_service.dart index d9e9f8c..88f46f1 100644 --- a/lib/logic/notification_service.dart +++ b/lib/logic/notification_service.dart @@ -16,16 +16,17 @@ class NotificationService { /// Sets the notification's content text to [text]. /// /// The native side rebuilds the whole notification, so skip the round-trip - /// when the text has not changed. + /// when the text has not changed. A failed native update is intentionally + /// not cached so the next tick can retry it. static Future updateTimer(String text) async { if (text == _lastText) return; - _lastText = text; try { await _channel.invokeMethod('updateNotificationText', { 'title': 'Revolt VPN', 'text': text, 'disconnectLabel': 'Disconnect', }); + _lastText = text; } catch (_) { // Cosmetic. }