diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index 618a377..888e084 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -12,6 +12,7 @@ android {
ndkVersion = flutter.ndkVersion
compileOptions {
+ isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
@@ -59,3 +60,7 @@ kotlin {
flutter {
source = "../.."
}
+
+dependencies {
+ coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
+}
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index b91cc55..1639dc7 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -2,7 +2,9 @@
-
+
+
+
diff --git a/assets/notification_banner.png b/assets/notification_banner.png
new file mode 100644
index 0000000..73b9416
Binary files /dev/null and b/assets/notification_banner.png differ
diff --git a/lib/components/notification.dart b/lib/components/notification.dart
new file mode 100644
index 0000000..ad1b6b8
--- /dev/null
+++ b/lib/components/notification.dart
@@ -0,0 +1,69 @@
+import 'dart:io';
+import 'package:flutter_local_notifications/flutter_local_notifications.dart';
+
+class VpnNotificationManager {
+ static final FlutterLocalNotificationsPlugin _plugin =
+ FlutterLocalNotificationsPlugin();
+ static bool _initialized = false;
+
+ static Future init() async {
+ if (_initialized) return;
+
+ const androidSettings =
+ AndroidInitializationSettings('@mipmap/ic_launcher');
+ const initSettings = InitializationSettings(android: androidSettings);
+
+ await _plugin.initialize(initSettings);
+
+ // Request POST_NOTIFICATIONS permission on Android 13+
+ if (Platform.isAndroid) {
+ final androidPlugin = _plugin
+ .resolvePlatformSpecificImplementation<
+ AndroidFlutterLocalNotificationsPlugin>();
+ await androidPlugin?.requestNotificationsPermission();
+ }
+
+ _initialized = true;
+ }
+
+ static Future showOrUpdateStatus({
+ required String timeLeft,
+ required String speedKbps,
+ }) async {
+ if (!_initialized) await init();
+
+ const androidDetails = AndroidNotificationDetails(
+ 'paladin_vpn_status',
+ 'VPN Status',
+ channelDescription: 'Persistent notification shown while VPN is active',
+ importance: Importance.low,
+ priority: Priority.low,
+ // Non-dismissable while VPN is running
+ ongoing: true,
+ autoCancel: false,
+ // Silent updates — no sound or vibration each time stats refresh
+ playSound: false,
+ enableVibration: false,
+ onlyAlertOnce: true,
+ // Hide the auto-generated timestamp
+ showWhen: false,
+ // Show on lock screen
+ visibility: NotificationVisibility.public,
+ );
+
+ const notificationDetails = NotificationDetails(android: androidDetails);
+
+ await _plugin.show(
+ 888, // Fixed ID — always updates the same notification
+ 'Paladin VPN Active',
+ 'Time: $timeLeft • $speedKbps',
+ notificationDetails,
+ );
+ }
+
+ static Future cancel() async {
+ if (!_initialized) return;
+ await _plugin.cancel(888);
+ }
+}
+
diff --git a/lib/logic/session_timer.dart b/lib/logic/session_timer.dart
index 55a92f3..883fc20 100644
--- a/lib/logic/session_timer.dart
+++ b/lib/logic/session_timer.dart
@@ -5,6 +5,7 @@ import 'package:http/http.dart' as http;
import 'package:paladinvpn/logic/app_config.dart';
import 'package:paladinvpn/logic/vpn_connection.dart';
import 'package:paladinvpn/logic/crypto_service.dart';
+import 'package:paladinvpn/components/notification.dart';
/// SessionTimer acts as the client-side session authority for the VPN tunnel.
///
@@ -51,6 +52,16 @@ class SessionTimer extends ChangeNotifier {
return '${gb.toStringAsFixed(2)} GB';
}
+ String get formattedDataUsed {
+ if (_usedBytes == 0) return '0.00 MB';
+ if (_usedBytes > 1024 * 1024 * 1024) {
+ final gb = _usedBytes / (1024 * 1024 * 1024);
+ return '${gb.toStringAsFixed(2)} GB';
+ }
+ final mb = _usedBytes / (1024 * 1024);
+ return '${mb.toStringAsFixed(2)} MB';
+ }
+
double get progress {
// Max duration is generally 60 or 90 minutes. We'll base the ring purely on data usage or just standard time
// For UI simplicity, we'll return data progress:
@@ -119,6 +130,11 @@ class SessionTimer extends ChangeNotifier {
}
_lastUsedBytes = _usedBytes;
+ VpnNotificationManager.showOrUpdateStatus(
+ timeLeft: formatted,
+ speedKbps: '${_currentSpeedKbps.toStringAsFixed(2)} KB/s',
+ );
+
notifyListeners();
} else if (response.statusCode == 404) {
// Peer not found on server (expired/deleted natively)
@@ -159,6 +175,7 @@ class SessionTimer extends ChangeNotifier {
_timer = null;
_remainingSeconds = 0;
_currentSpeedKbps = 0.0;
+ VpnNotificationManager.cancel();
notifyListeners();
}
diff --git a/pubspec.lock b/pubspec.lock
index 8e40e06..70790f8 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -81,6 +81,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.9"
+ dbus:
+ dependency: transitive
+ description:
+ name: dbus
+ sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.7.12"
fake_async:
dependency: transitive
description:
@@ -134,6 +142,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.2"
+ flutter_local_notifications:
+ dependency: "direct main"
+ description:
+ name: flutter_local_notifications
+ sha256: "674173fd3c9eda9d4c8528da2ce0ea69f161577495a9cc835a2a4ecd7eadeb35"
+ url: "https://pub.dev"
+ source: hosted
+ version: "17.2.4"
+ flutter_local_notifications_linux:
+ dependency: transitive
+ description:
+ name: flutter_local_notifications_linux
+ sha256: c49bd06165cad9beeb79090b18cd1eb0296f4bf4b23b84426e37dd7c027fc3af
+ url: "https://pub.dev"
+ source: hosted
+ version: "4.0.1"
+ flutter_local_notifications_platform_interface:
+ dependency: transitive
+ description:
+ name: flutter_local_notifications_platform_interface
+ sha256: "85f8d07fe708c1bdcf45037f2c0109753b26ae077e9d9e899d55971711a4ea66"
+ url: "https://pub.dev"
+ source: hosted
+ version: "7.2.0"
flutter_secure_storage:
dependency: "direct main"
description:
@@ -345,7 +377,7 @@ packages:
source: hosted
version: "1.9.1"
path_provider:
- dependency: transitive
+ dependency: "direct main"
description:
name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
@@ -392,6 +424,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.3.0"
+ petitparser:
+ dependency: transitive
+ description:
+ name: petitparser
+ sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
+ url: "https://pub.dev"
+ source: hosted
+ version: "7.0.2"
platform:
dependency: transitive
description:
@@ -557,6 +597,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.7.11"
+ timezone:
+ dependency: transitive
+ description:
+ name: timezone
+ sha256: "2236ec079a174ce07434e89fcd3fcda430025eb7692244139a9cf54fdcf1fc7d"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.9.4"
typed_data:
dependency: transitive
description:
@@ -716,6 +764,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.0"
+ xml:
+ dependency: transitive
+ description:
+ name: xml
+ sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
+ url: "https://pub.dev"
+ source: hosted
+ version: "6.6.1"
yaml:
dependency: transitive
description:
diff --git a/pubspec.yaml b/pubspec.yaml
index d3c2148..190d1df 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -38,6 +38,8 @@ dependencies:
url_launcher: ^6.3.2
flutter_secure_storage: ^10.3.1
uuid: ^4.5.3
+ flutter_local_notifications: ^17.0.0
+ path_provider: ^2.1.5
dev_dependencies:
flutter_test: