From 742f37683ee3a32ebdece497b940cb25c04fbf29 Mon Sep 17 00:00:00 2001 From: esefxdz Date: Sat, 17 Oct 2026 00:02:20 +0300 Subject: [PATCH] feat(session): warn before a session ends and say when protection stops Review findings #1-#3 in internal/APP_REVIEW_2026-10-16.md: every session end dropped the user onto their real IP with no warning, and the app showed no errors at all. - RevoltAlerts (vendored runtime) owns both alerts: a high-importance channel, ids 2 and 3, posted from whichever process is alive - XrayVPNService schedules a 5-minute warning off the deadline it already holds, best effort (a warning that cannot be armed never stops a tunnel), with its own request code so it cannot clobber the expiry alarm - the warning intent is handled above the command dispatch: without that it would fall through to the final else and stop the tunnel 5 minutes early - SessionTimer._doDisconnect takes endedMessage: set for ends the user did not ask for, which both posts the alert and gives the UI a sentence - the API being unreachable no longer disconnects a working tunnel; it is a display state now, with expiry still enforced natively and server-side - status_bar.dart renders connect errors, connecting progress and the session-end reason flutter analyze clean; debug APK builds (Kotlin compiles). Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 18 +++ .../kotlin/com/paladinvpn/app/MainActivity.kt | 26 +++- lib/components/status_bar.dart | 57 +++++++ lib/logic/notification_service.dart | 12 ++ lib/logic/session_timer.dart | 64 +++++--- .../xray/service/XrayVPNService.kt | 107 +++++++++++++ .../flutter_vless/xray/utils/RevoltAlerts.kt | 143 ++++++++++++++++++ 7 files changed, 401 insertions(+), 26 deletions(-) create mode 100644 local_packages/flutter_vless_android-1.1.5/android/src/main/kotlin/com/github/tfox/flutter_vless/xray/utils/RevoltAlerts.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f4f6ea..2ca2eae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,24 @@ Versions follow [semver](https://semver.org/): MAJOR.MINOR.PATCH ## [Unreleased] +### Added +- **Five-minute warning before a session ends.** The VPN service schedules it + off the same deadline it already enforces, so the warning still arrives when + the app is closed, and a support top-up moves it with the deadline. +- **"Disconnected — not protected" notification** whenever a session ends for + any reason other than you tapping disconnect: expiry, the data limit, the + server ending the session, or the tunnel stopping on its own. +- **The app now shows what went wrong.** Connect errors, the reason a session + ended, and progress while connecting appear under the status bar instead of + the button silently going back to idle. + +### Changed +- **A control-plane outage no longer tears down a working tunnel.** The app used + to disconnect itself after about two minutes of failed status polls, putting + you back on your real IP; it now keeps the tunnel up and shows + "Can't reach the server — still connected". Expiry is still enforced by the + VPN service's own deadline and by the server. + ### Fixed - **Rewarded ads no longer break connecting.** With ads enabled, the ad's server-side-verification nonce and the nonce the connect flow waited for were diff --git a/android/app/src/main/kotlin/com/paladinvpn/app/MainActivity.kt b/android/app/src/main/kotlin/com/paladinvpn/app/MainActivity.kt index 07ca37d..ac30ff4 100644 --- a/android/app/src/main/kotlin/com/paladinvpn/app/MainActivity.kt +++ b/android/app/src/main/kotlin/com/paladinvpn/app/MainActivity.kt @@ -20,6 +20,7 @@ import android.os.VibratorManager import android.provider.Settings import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat +import com.github.tfox.flutter_vless.xray.utils.RevoltAlerts import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugin.common.EventChannel @@ -61,14 +62,23 @@ class MainActivity : FlutterActivity() { MethodChannel(flutterEngine.dartExecutor.binaryMessenger, notificationChannel) .setMethodCallHandler { call, result -> - if (call.method == "updateNotificationText") { - val text = call.argument("text") ?: "" - val title = call.argument("title") ?: "Revolt VPN" - val action = call.argument("disconnectLabel") ?: "Disconnect" - updateVpnNotification(title, text, action) - result.success(null) - } else { - result.notImplemented() + when (call.method) { + "updateNotificationText" -> { + val text = call.argument("text") ?: "" + val title = call.argument("title") ?: "Revolt VPN" + val action = call.argument("disconnectLabel") ?: "Disconnect" + updateVpnNotification(title, text, action) + result.success(null) + } + // Session ends only Dart can see (cap exhausted, server + // ended the session, VPN error). The VPN service posts the + // same alert for its own deadline, where this process may + // already be gone. RevoltAlerts owns both. + "showDisconnectedAlert" -> { + RevoltAlerts.postDisconnected(this) + result.success(null) + } + else -> result.notImplemented() } } diff --git a/lib/components/status_bar.dart b/lib/components/status_bar.dart index 707bb52..a539648 100644 --- a/lib/components/status_bar.dart +++ b/lib/components/status_bar.dart @@ -5,6 +5,44 @@ import 'package:revoltvpn/logic/app_colors.dart'; import 'package:revoltvpn/logic/session_timer.dart'; import 'package:revoltvpn/logic/vpn_connection.dart'; +// Nothing rendered the connection's own messages, so they are collected here +// in one place: whatever the user most needs to know right now, and whether it +// is a problem (red) or just progress (dim). +class _StatusDetail { + const _StatusDetail(this.text, {this.isProblem = false}); + + final String text; + final bool isProblem; +} + +_StatusDetail? _detail(VpnConnection vpn, SessionTimer timer) { + final error = vpn.errorMessage; + if (error != null && error.isNotEmpty) { + return _StatusDetail(error.replaceAll('\n', ' '), isProblem: true); + } + + if (vpn.status == VpnStatus.connecting || + vpn.status == VpnStatus.disconnecting) { + return _StatusDetail(vpn.statusMessage); + } + + // The tunnel still carries traffic; only the control plane is missing, so + // this is a warning, not a disconnect (review #2). + if (vpn.status == VpnStatus.connected && timer.serverUnreachable) { + return const _StatusDetail( + 'Can\'t reach the server — still connected', + isProblem: true, + ); + } + + if (vpn.status != VpnStatus.connected) { + final ended = timer.lastEndMessage; + if (ended != null) return _StatusDetail(ended, isProblem: true); + } + + return null; +} + class StatusBar extends StatelessWidget { const StatusBar({super.key}); @@ -24,6 +62,8 @@ class StatusBar extends StatelessWidget { ? 'Connecting…' : (vpn.serverReachable ? 'Ready' : 'Offline')); + final detail = _detail(vpn, timer); + return Padding( padding: const EdgeInsets.symmetric(horizontal: 24), child: Column( @@ -96,6 +136,23 @@ class StatusBar extends StatelessWidget { ], ), ), + // Until this existed, a failed connect, an expired session and a + // cap kill all just turned the button back to idle - the user + // could not tell protected from unprotected (review #3). + if (detail != null) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + detail.text, + textAlign: TextAlign.center, + style: TextStyle( + color: detail.isProblem + ? AppColors.red + : AppColors.textDim, + fontSize: 11, + ), + ), + ), ], ), ); diff --git a/lib/logic/notification_service.dart b/lib/logic/notification_service.dart index 88f46f1..c22a04a 100644 --- a/lib/logic/notification_service.dart +++ b/lib/logic/notification_service.dart @@ -32,6 +32,18 @@ class NotificationService { } } + // Posts the "disconnected - not protected" alert for a session end the user + // did not ask for. The native service posts the same alert (same id) when + // its own deadline fires, because by then this process may be gone; a double + // post replaces rather than stacks. + static Future showDisconnectedAlert() async { + try { + await _channel.invokeMethod('showDisconnectedAlert'); + } catch (_) { + // Informational only - never worth failing a disconnect over. + } + } + /// Forces the next connect to re-post even if the countdown resumes on the /// same value. static void reset() => _lastText = null; diff --git a/lib/logic/session_timer.dart b/lib/logic/session_timer.dart index 035775d..c205da0 100644 --- a/lib/logic/session_timer.dart +++ b/lib/logic/session_timer.dart @@ -36,8 +36,6 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver { static const FlutterSecureStorage _supportStorage = FlutterSecureStorage(); static const int _maxConsecutiveFailures = 3; - static const int _maxOfflineSeconds = 120; - int _offlineSeconds = 0; static const int _pollIntervalSeconds = 60; static const int _backgroundPollIntervalSeconds = 60; @@ -67,6 +65,17 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver { int get usedBytes => _usedBytes; double get currentSpeedKBps => _currentSpeedKBps; + // True once the control plane has missed enough polls in a row to be worth + // telling the user about. It is a display state only: a tunnel that still + // carries traffic is never torn down because the API is unreachable (the + // native deadline and the server both still enforce expiry). + bool get serverUnreachable => _consecutiveFailures >= _maxConsecutiveFailures; + + // Why the last session ended, when the user did not end it themselves. + // Cleared on the next connect. Rendered by status_bar.dart. + String? _lastEndMessage; + String? get lastEndMessage => _lastEndMessage; + String get formatted { final h = (_remainingSeconds ~/ 3600).toString().padLeft(2, '0'); final m = ((_remainingSeconds % 3600) ~/ 60).toString().padLeft(2, '0'); @@ -124,11 +133,17 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver { if (!hadActiveSession || _isDisconnecting) return; if (vpnConnection.status == VpnStatus.error) { - unawaited(_doDisconnect('VPN entered error state')); + unawaited(_doDisconnect( + 'VPN entered error state', + endedMessage: 'The tunnel stopped. You are not protected.', + )); return; } if (vpnConnection.status == VpnStatus.disconnected) { + // No alert here. The runtime going away covers both the native deadline + // (which posts the alert itself, from a process that outlives this one) + // and the notification's Disconnect action, which the user tapped. unawaited(_doDisconnect('VPN tunnel dropped')); } } @@ -145,9 +160,9 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver { _tickCount = 0; _hasSyncedOnce = false; _consecutiveFailures = 0; - _offlineSeconds = 0; _isDisconnecting = false; _appBackgrounded = false; + _lastEndMessage = null; NotificationService.reset(); _supportStateEpoch++; @@ -169,16 +184,11 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver { _reconcileElapsedTime(); } - if (_consecutiveFailures >= _maxConsecutiveFailures) { - _offlineSeconds++; - if (_offlineSeconds >= _maxOfflineSeconds) { - unawaited(_doDisconnect('Server unreachable')); - return; - } - } - if (_hasSyncedOnce && _remainingSeconds <= 0) { - unawaited(_doDisconnect('Session expired')); + unawaited(_doDisconnect( + 'Session expired', + endedMessage: 'Your session ended. You are not protected.', + )); return; } @@ -219,12 +229,17 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver { await _doDisconnect(reason); } - Future _doDisconnect(String reason) async { + // endedMessage is the one sentence the UI shows after an end the user did + // not ask for, and its presence is also what posts the "not protected" + // notification. Both exist so nobody is left on their real IP without being + // told. A user-requested disconnect passes nothing. + Future _doDisconnect(String reason, {String? endedMessage}) async { if (_isDisconnecting) return; _isDisconnecting = true; _sessionEpoch++; debugPrint('[Timer] Disconnecting: $reason'); + _lastEndMessage = endedMessage; _timer?.cancel(); _timer = null; _currentSpeedKBps = 0.0; @@ -237,6 +252,9 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver { notifyListeners(); await vpnConnection.disconnect(); + if (endedMessage != null) { + unawaited(NotificationService.showDisconnectedAlert()); + } _isDisconnecting = false; notifyListeners(); @@ -282,7 +300,11 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver { return; } if (!activeValue) { - await _doDisconnect('Server ended session'); + await _doDisconnect( + 'Server ended session', + endedMessage: + 'The server ended your session. You are not protected.', + ); return; } @@ -313,7 +335,10 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver { _sinceLastSuccessfulSync = Stopwatch()..start(); if (data['cap_exhausted'] == true) { - await _doDisconnect('Data cap reached'); + await _doDisconnect( + 'Data cap reached', + endedMessage: 'Data limit reached. You are not protected.', + ); return; } @@ -322,12 +347,15 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver { await vpnConnection.setNativeSessionDeadline(_remainingAtLastSync); } catch (e) { debugPrint('[Timer] Failed to arm native session deadline: $e'); - await _doDisconnect('Native session deadline unavailable'); + await _doDisconnect( + 'Native session deadline unavailable', + endedMessage: + 'Could not arm the session timer. You are not protected.', + ); return; } if (epoch != _sessionEpoch || _isDisconnecting) return; _consecutiveFailures = 0; - _offlineSeconds = 0; _hasSyncedOnce = true; notifyListeners(); } else { diff --git a/local_packages/flutter_vless_android-1.1.5/android/src/main/kotlin/com/github/tfox/flutter_vless/xray/service/XrayVPNService.kt b/local_packages/flutter_vless_android-1.1.5/android/src/main/kotlin/com/github/tfox/flutter_vless/xray/service/XrayVPNService.kt index 68b694e..bedb116 100644 --- a/local_packages/flutter_vless_android-1.1.5/android/src/main/kotlin/com/github/tfox/flutter_vless/xray/service/XrayVPNService.kt +++ b/local_packages/flutter_vless_android-1.1.5/android/src/main/kotlin/com/github/tfox/flutter_vless/xray/service/XrayVPNService.kt @@ -16,6 +16,7 @@ import android.util.Log import com.github.tfox.flutter_vless.xray.core.XrayCoreManager import com.github.tfox.flutter_vless.xray.dto.XrayConfig import com.github.tfox.flutter_vless.xray.utils.AppConfigs +import com.github.tfox.flutter_vless.xray.utils.RevoltAlerts import org.json.JSONObject import java.io.File @@ -66,6 +67,14 @@ class XrayVPNService : VpnService() { return START_NOT_STICKY } + // Must stay above the command handling below: an intent carrying no + // COMMAND falls through to the final else and stops a live tunnel - in + // this case five minutes early, which is what it warns about. + if (intent.getBooleanExtra(EXTRA_SESSION_WARNING, false)) { + handleSessionWarningAlarm(intent.getStringExtra("RUNTIME_TOKEN").orEmpty()) + return START_NOT_STICKY + } + val command = if (Build.VERSION.SDK_INT >= 33) { intent.getSerializableExtra("COMMAND", AppConfigs.V2RAY_SERVICE_COMMANDS::class.java) } else { @@ -121,6 +130,7 @@ class XrayVPNService : VpnService() { XrayCoreManager.stopCore(this) } cleanup(clearDeadline = true) + RevoltAlerts.cancelAll(this) shuttingDownIntentionally = false currentConfig = config @@ -451,17 +461,81 @@ class XrayVPNService : VpnService() { return } + armExpiryWarning(config.RUNTIME_TOKEN, epochDeadline) + deadlineHandler.removeCallbacks(sessionDeadlineRunnable) if (delayMs == 0L) deadlineHandler.post(sessionDeadlineRunnable) else deadlineHandler.postDelayed(sessionDeadlineRunnable, delayMs) } + // Schedules the five-minute warning off the same deadline the expiry alarm + // holds. Unlike the deadline this is advisory: a warning that cannot be + // scheduled must never stop a working tunnel, so nothing here fails closed. + // Dart re-pushes the deadline on every sync, so this runs about once a minute. + private fun armExpiryWarning(runtimeToken: String, expiresAtEpochMs: Long) { + cancelSessionWarningAlarm() + if (runtimeToken.isEmpty()) return + + val warnAt = expiresAtEpochMs - RevoltAlerts.WARNING_LEAD_MS + // Deadline already inside the warning window (or past): either the alarm + // has fired and its notification should stay up, or this is the short + // bootstrap deadline at connect, which must not warn at all. + if (warnAt <= System.currentTimeMillis()) return + + // More than the lead away, so any warning on screen belongs to an older + // deadline - a support top-up just pushed it out. + RevoltAlerts.cancelExpiryWarning(this) + + try { + val alarmManager = getSystemService(Context.ALARM_SERVICE) as AlarmManager + val pendingIntent = sessionWarningPendingIntent(runtimeToken) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + // Inexact on purpose: minute-level slop is fine for a warning, + // and it keeps this off the SCHEDULE_EXACT_ALARM path. + alarmManager.setAndAllowWhileIdle( + AlarmManager.RTC_WAKEUP, + warnAt, + pendingIntent, + ) + } else { + alarmManager.set(AlarmManager.RTC_WAKEUP, warnAt, pendingIntent) + } + } catch (e: Exception) { + Log.w(TAG, "Failed to schedule session-expiry warning", e) + } + } + + private fun handleSessionWarningAlarm(alarmToken: String) { + val config = currentConfig + if (config == null) { + // The alarm woke a service process with no live runtime; there is + // no session left to warn about. + stopSelf() + return + } + if (alarmToken.isEmpty() || alarmToken != config.RUNTIME_TOKEN) { + Log.w(TAG, "Ignoring session warning for stale runtime generation") + return + } + + val deadline = sessionDeadlineElapsed ?: return + val remaining = deadline - SystemClock.elapsedRealtime() + if (remaining <= 0L || remaining > RevoltAlerts.WARNING_LEAD_MS + WARNING_SLACK_MS) { + Log.w(TAG, "Ignoring session warning outside the warning window") + return + } + + RevoltAlerts.postExpiryWarning(this) + } + private fun clearSessionDeadline() { deadlineHandler.removeCallbacks(sessionDeadlineRunnable) sessionDeadlineElapsed = null sessionDeadlineToken = null clearPersistedDeadline() cancelSessionExpiryAlarm() + cancelSessionWarningAlarm() + RevoltAlerts.cancelExpiryWarning(this) } private fun enforceSessionDeadline() { @@ -476,6 +550,10 @@ class XrayVPNService : VpnService() { } Log.w(TAG, "Native session deadline expired; stopping runtime fail-closed") stopAll(token) + // Only the deadline paths post this natively. A STOP_SERVICE command + // cannot tell a user tap from a Dart-side session end, so Dart posts for + // its own reasons; the shared id means a double post just replaces. + RevoltAlerts.postDisconnected(this) } private fun updateSessionDeadline(intent: Intent) { @@ -561,9 +639,12 @@ class XrayVPNService : VpnService() { } else { clearPersistedDeadline() cancelSessionExpiryAlarm() + cancelSessionWarningAlarm() + RevoltAlerts.cancelExpiryWarning(this) stopForeground(true) stopSelf() } + RevoltAlerts.postDisconnected(this) } private fun scheduleSessionExpiryAlarm(runtimeToken: String, expiresAtEpochMs: Long): Boolean { @@ -607,6 +688,27 @@ class XrayVPNService : VpnService() { } } + private fun cancelSessionWarningAlarm() { + try { + val alarmManager = getSystemService(Context.ALARM_SERVICE) as AlarmManager + alarmManager.cancel(sessionWarningPendingIntent("")) + } catch (e: Exception) { + Log.w(TAG, "Failed to cancel session-warning alarm", e) + } + } + + // A PendingIntent is identified by component plus request code, not extras, + // so the warning needs its own code or it would overwrite - and later + // cancel - the expiry alarm. + private fun sessionWarningPendingIntent(runtimeToken: String): PendingIntent { + val intent = Intent(this, XrayVPNService::class.java) + .putExtra(EXTRA_SESSION_WARNING, true) + .putExtra("RUNTIME_TOKEN", runtimeToken) + val flags = PendingIntent.FLAG_UPDATE_CURRENT or + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0 + return PendingIntent.getService(this, SESSION_WARNING_REQUEST_CODE, intent, flags) + } + private fun sessionExpiryPendingIntent(runtimeToken: String): PendingIntent { val intent = Intent(this, XrayVPNService::class.java) .putExtra(EXTRA_SESSION_EXPIRED, true) @@ -681,6 +783,11 @@ class XrayVPNService : VpnService() { private const val PREF_RUNTIME_TOKEN = "runtime_token" private const val PREF_EXPIRES_AT_MS = "expires_at_ms" private const val EXTRA_SESSION_EXPIRED = "SESSION_EXPIRED" + private const val EXTRA_SESSION_WARNING = "SESSION_WARNING" private const val SESSION_EXPIRY_REQUEST_CODE = 1001 + private const val SESSION_WARNING_REQUEST_CODE = 1002 + // An idle-capable alarm can run late; accept that much drift before + // treating a warning as belonging to a deadline that has moved. + private const val WARNING_SLACK_MS = 60_000L } } diff --git a/local_packages/flutter_vless_android-1.1.5/android/src/main/kotlin/com/github/tfox/flutter_vless/xray/utils/RevoltAlerts.kt b/local_packages/flutter_vless_android-1.1.5/android/src/main/kotlin/com/github/tfox/flutter_vless/xray/utils/RevoltAlerts.kt new file mode 100644 index 0000000..fc085fa --- /dev/null +++ b/local_packages/flutter_vless_android-1.1.5/android/src/main/kotlin/com/github/tfox/flutter_vless/xray/utils/RevoltAlerts.kt @@ -0,0 +1,143 @@ +package com.github.tfox.flutter_vless.xray.utils + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import android.util.Log + +// local_packages/.../xray/utils/RevoltAlerts.kt +// +// The two user-facing session alerts, built in one place because two processes +// post them: XrayVPNService (:RunSoLibXrayDaemon) owns the native deadline and +// can still warn when the UI process is gone, while MainActivity posts the +// "not protected" alert for session ends only Dart can see (cap exhausted, +// server ended the session, VPN error). Both use the same ids, so whichever +// posts second just replaces the first notification instead of stacking. +// +// These are deliberately NOT the foreground-service notification (id 1, +// channel REVOLT_VPN_SERVICE, IMPORTANCE_LOW): an alert the user must notice +// needs its own high-importance channel, and must be dismissible. +object RevoltAlerts { + + const val CHANNEL_ID = "REVOLT_VPN_ALERTS" + const val WARNING_NOTIFICATION_ID = 2 + const val DISCONNECTED_NOTIFICATION_ID = 3 + + // How long before the session deadline the warning fires. + const val WARNING_LEAD_MS = 5 * 60 * 1000L + + private const val TAG = "RevoltAlerts" + private const val LAUNCH_REQUEST_CODE = 2001 + + fun postExpiryWarning(context: Context) { + post( + context, + WARNING_NOTIFICATION_ID, + "Revolt VPN disconnects in 5 minutes", + "Watch an ad to extend your session.", + ) + } + + fun postDisconnected(context: Context) { + post( + context, + DISCONNECTED_NOTIFICATION_ID, + "Revolt VPN disconnected", + "Your traffic is no longer protected. Tap to reconnect.", + ) + } + + fun cancelExpiryWarning(context: Context) = cancel(context, WARNING_NOTIFICATION_ID) + + fun cancelDisconnected(context: Context) = cancel(context, DISCONNECTED_NOTIFICATION_ID) + + fun cancelAll(context: Context) { + cancelExpiryWarning(context) + cancelDisconnected(context) + } + + private fun cancel(context: Context, id: Int) { + try { + manager(context)?.cancel(id) + } catch (e: Exception) { + Log.w(TAG, "Failed to cancel alert $id", e) + } + } + + private fun post(context: Context, id: Int, title: String, text: String) { + if (!canPost(context)) { + Log.w(TAG, "POST_NOTIFICATIONS not granted; dropping alert $id") + return + } + + try { + val manager = manager(context) ?: return + ensureChannel(manager) + + val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Notification.Builder(context, CHANNEL_ID) + } else { + @Suppress("DEPRECATION") + Notification.Builder(context).setPriority(Notification.PRIORITY_HIGH) + } + + val icon = context.resources + .getIdentifier("notification_icon", "drawable", context.packageName) + .takeIf { it != 0 } ?: android.R.drawable.ic_dialog_info + + builder + .setContentTitle(title) + .setContentText(text) + .setStyle(Notification.BigTextStyle().bigText(text)) + .setSmallIcon(icon) + .setAutoCancel(true) + .setOngoing(false) + + launchIntent(context)?.let { builder.setContentIntent(it) } + + manager.notify(id, builder.build()) + } catch (e: Exception) { + Log.w(TAG, "Failed to post alert $id", e) + } + } + + private fun canPost(context: Context): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return true + return context.checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) == + PackageManager.PERMISSION_GRANTED + } + + private fun manager(context: Context): NotificationManager? = + context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager + + // Importance is fixed when the channel is first created, so this asks for + // HIGH up front — a session alert the user never sees is the whole bug + // these notifications exist to fix. + private fun ensureChannel(manager: NotificationManager) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + if (manager.getNotificationChannel(CHANNEL_ID) != null) return + val channel = NotificationChannel( + CHANNEL_ID, + "Session alerts", + NotificationManager.IMPORTANCE_HIGH, + ) + channel.description = "Warns before a session ends and when protection stops." + manager.createNotificationChannel(channel) + } + + private fun launchIntent(context: Context): PendingIntent? { + val intent = context.packageManager.getLaunchIntentForPackage(context.packageName) + ?: return null + intent.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or + Intent.FLAG_ACTIVITY_CLEAR_TOP or + Intent.FLAG_ACTIVITY_NEW_TASK + val flags = PendingIntent.FLAG_UPDATE_CURRENT or + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0 + return PendingIntent.getActivity(context, LAUNCH_REQUEST_CODE, intent, flags) + } +}