diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 888e084..4896257 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -47,6 +47,12 @@ android { buildTypes { release { signingConfig = signingConfigs.getByName("release") + isMinifyEnabled = true + isShrinkResources = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) } } } @@ -63,4 +69,5 @@ flutter { dependencies { coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4") + implementation("androidx.core:core-ktx:1.13.1") } diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..3f7cf8a --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,42 @@ +# PaladinVPN ProGuard / R8 Rules +# ============================================================================== +# These rules prevent R8 from stripping classes and methods that are accessed +# via JNI (Java Native Interface) or reflection, which would cause +# UnsatisfiedLinkError / ClassNotFoundException at runtime. + +# ── AmneziaWG Native Backend (JNI) ──────────────────────────────────────────── +# The wireguard_flutter plugin uses org.amnezia.awg for all tunnel operations. +# These classes are loaded via JNI — stripping them will crash the VPN. +-keep class org.amnezia.awg.backend.** { *; } +-keep class org.amnezia.awg.crypto.** { *; } +-keep class org.amnezia.awg.config.** { *; } +-keep class com.wireguard.** { *; } + +# ── Kotlin Coroutines (used by wireguard_flutter plugin) ────────────────────── +-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {} +-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {} + +# ── Flutter / Platform Channels ─────────────────────────────────────────────── +-keep class io.flutter.** { *; } +-keep class io.flutter.embedding.** { *; } +-keep class io.flutter.plugin.** { *; } + +# ── Google Mobile Ads (AdMob) ───────────────────────────────────────────────── +-keep class com.google.android.gms.ads.** { *; } +-dontwarn com.google.android.gms.ads.** + +# ── Klaxon JSON parser (used by wireguard_flutter plugin) ───────────────────── +-keep class com.beust.klaxon.** { *; } + +# ── General Android / Kotlin ────────────────────────────────────────────────── +-keepattributes *Annotation* +-keepattributes SourceFile,LineNumberTable +-keep class kotlin.Metadata { *; } + +# Strip debug logging in release builds +-assumenosideeffects class android.util.Log { + public static boolean isLoggable(java.lang.String, int); + public static int v(...); + public static int d(...); + public static int i(...); +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 1639dc7..021410c 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -4,7 +4,7 @@ - + diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 5c92332..6ba9318 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -1,50 +1,28 @@ +// Root build.gradle.kts — project-level configuration only. +// Application-specific configuration belongs in android/app/build.gradle.kts. + plugins { - id("com.android.application") - // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. - id("dev.flutter.flutter-gradle-plugin") + // The Flutter Gradle Plugin is applied at the app-module level, not here. + id("dev.flutter.flutter-gradle-plugin") apply false } -android { - namespace = "com.esefcloud.vpn" - compileSdk = flutter.compileSdkVersion - ndkVersion = flutter.ndkVersion - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - - defaultConfig { - applicationId = "com.esefcloud.vpn" - minSdk = 21 - targetSdk = flutter.targetSdkVersion - versionCode = flutter.versionCode - versionName = flutter.versionName - } - - packaging { - resources { - excludes += setOf( - "META-INF/versions/9/OSGI-INF/MANIFEST.MF" - ) - } - } - - buildTypes { - release { - // TODO: Add your own signing config for the release build. - // Signing with the debug keys for now, so `flutter run --release` works. - signingConfig = signingConfigs.getByName("debug") - } +allprojects { + repositories { + google() + mavenCentral() } } -kotlin { - compilerOptions { - jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 - } +rootProject.buildDir = "../build" + +subprojects { + project.buildDir = "${rootProject.buildDir}/${project.name}" } -flutter { - source = "../.." +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean", Delete::class) { + delete(rootProject.buildDir) } diff --git a/lib/components/clock_display.dart b/lib/components/clock_display.dart index 85c9a35..80578b2 100644 --- a/lib/components/clock_display.dart +++ b/lib/components/clock_display.dart @@ -7,6 +7,9 @@ import 'package:paladinvpn/logic/session_timer.dart'; /// /// Shows a circular quota-progress arc, a glowing countdown timer, and the /// current transfer speed — all sized to fit within the 260px button. +/// +/// Before the first server sync arrives, a subtle "Syncing…" placeholder +/// is shown instead of a misleading 00:00:00. class ClockDisplay extends StatelessWidget { const ClockDisplay({super.key}); @@ -16,50 +19,76 @@ class ClockDisplay extends StatelessWidget { Widget build(BuildContext context) { return Consumer( builder: (context, timer, _) { + final bool showSyncing = !timer.hasSyncedOnce; + return SizedBox( width: 200, height: 200, child: Stack( alignment: Alignment.center, children: [ - // Center content: countdown + speed + // Center content: countdown + speed (or syncing placeholder) Column( mainAxisSize: MainAxisSize.min, children: [ - // Glowing countdown timer - Text( - timer.formatted, - style: const TextStyle( - fontSize: 40, - fontWeight: FontWeight.w800, - color: Colors.white, - letterSpacing: 2, - shadows: [ - Shadow( - color: Color.fromRGBO(0, 229, 255, 0.5), - blurRadius: 16, + if (showSyncing) ...[ + // Syncing placeholder — spinner + label + const SizedBox( + width: 32, + height: 32, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Color.fromRGBO(0, 229, 255, 0.5), + ), + ), + const SizedBox(height: 10), + const Text( + 'Syncing…', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Color.fromRGBO(0, 229, 255, 0.6), + letterSpacing: 1.5, + ), + ), + ] else ...[ + // Glowing countdown timer + Text( + timer.formatted, + style: const TextStyle( + fontSize: 40, + fontWeight: FontWeight.w800, + color: Colors.white, + letterSpacing: 2, + shadows: [ + Shadow( + color: Color.fromRGBO(0, 229, 255, 0.5), + blurRadius: 16, + ), + ], + ), + ), + const SizedBox(height: 6), + + // Speed indicator + Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.speed, color: Colors.white54, size: 14), + const SizedBox(width: 4), + Text( + timer.currentSpeedKbps > 0.5 + ? '${timer.currentSpeedKbps.toStringAsFixed(1)} KB/s' + : 'Idle', + style: const TextStyle( + fontSize: 12, + color: Colors.white54, + fontWeight: FontWeight.w500, + ), ), ], ), - ), - const SizedBox(height: 6), - - // Speed indicator - Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.speed, color: Colors.white54, size: 14), - const SizedBox(width: 4), - Text( - '${timer.currentSpeedKbps.toStringAsFixed(1)} KB/s', - style: const TextStyle( - fontSize: 12, - color: Colors.white54, - fontWeight: FontWeight.w500, - ), - ), - ], - ), + ], ], ), ], diff --git a/lib/components/connect_button.dart b/lib/components/connect_button.dart index 5af451d..5c23814 100644 --- a/lib/components/connect_button.dart +++ b/lib/components/connect_button.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import 'package:paladinvpn/logic/vpn_connection.dart'; import 'package:paladinvpn/logic/session_timer.dart'; +import 'package:paladinvpn/logic/ad_manager.dart'; import 'package:paladinvpn/components/clock_display.dart'; class ConnectButton extends StatefulWidget { @@ -60,30 +62,51 @@ class _ConnectButtonState extends State void _handleTap() async { if (_inCooldown) return; - setState(() => _inCooldown = true); - Future.delayed(const Duration(seconds: 5), () { - if (mounted) setState(() => _inCooldown = false); - }); - final vpn = context.read(); final timer = context.read(); + final ad = context.read(); if (vpn.status == VpnStatus.connected) { + // Disconnect — apply a short cooldown to prevent rapid toggle spam + HapticFeedback.lightImpact(); vpn.disconnect(); timer.stop(); + setState(() => _inCooldown = true); + Future.delayed(const Duration(seconds: 2), () { + if (mounted) setState(() => _inCooldown = false); + }); } else if (vpn.status == VpnStatus.disconnected || vpn.status == VpnStatus.error) { + // Only attempt connect if an ad is loaded (or we're in dev/web mode) + if (!ad.isAdLoaded && !ad.isAdLoading) { + ad.preloadAd(); // trigger a reload in background + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Ad is not ready yet. Please wait a moment.'), + duration: Duration(seconds: 2), + ), + ); + return; + } + HapticFeedback.lightImpact(); final success = await vpn.connect(); if (success) { timer.start('main_ad'); + // Only apply cooldown on success — failures clear immediately + setState(() => _inCooldown = true); + Future.delayed(const Duration(seconds: 5), () { + if (mounted) setState(() => _inCooldown = false); + }); } + // On failure the button stays active — the error message in + // StatusText tells the user what went wrong. } } @override Widget build(BuildContext context) { - return Consumer2( - builder: (context, vpn, timer, _) { + return Consumer3( + builder: (context, vpn, timer, ad, _) { final isConnected = vpn.status == VpnStatus.connected; final isConnecting = vpn.status == VpnStatus.connecting; @@ -126,7 +149,7 @@ class _ConnectButtonState extends State child: Center( child: isConnected ? const ClockDisplay() - : _buildDisconnectedIcon(vpn.status, _inCooldown), + : _buildDisconnectedIcon(vpn.status, _inCooldown, ad), ), ); }, @@ -136,7 +159,7 @@ class _ConnectButtonState extends State ); } - Widget _buildDisconnectedIcon(VpnStatus status, bool inCooldown) { + Widget _buildDisconnectedIcon(VpnStatus status, bool inCooldown, AdManager ad) { if (status == VpnStatus.connecting || status == VpnStatus.disconnecting) { return const SizedBox( width: 48, @@ -147,19 +170,38 @@ class _ConnectButtonState extends State ), ); } + // Determine the right label for the disconnected state + String label; + IconData icon; + Color tint; + + if (inCooldown) { + label = 'PLEASE WAIT'; + icon = Icons.hourglass_bottom; + tint = Colors.grey; + } else if (status == VpnStatus.error) { + label = 'TRY AGAIN'; + icon = Icons.refresh; + tint = const Color(0xFFEF5350); // soft red — something went wrong + } else if (!ad.isAdLoaded && !ad.isAdLoading) { + label = 'LOADING AD…'; + icon = Icons.hourglass_empty; + tint = const Color.fromRGBO(74, 85, 104, 0.7); + } else { + label = 'TAP TO CONNECT'; + icon = Icons.power_settings_new; + tint = const Color.fromRGBO(74, 85, 104, 0.7); + } + return Column( mainAxisSize: MainAxisSize.min, children: [ - Icon( - inCooldown ? Icons.hourglass_bottom : Icons.power_settings_new, - size: 64, - color: inCooldown ? Colors.grey : const Color.fromRGBO(74, 85, 104, 0.7), - ), + Icon(icon, size: 64, color: tint), const SizedBox(height: 12), Text( - inCooldown ? 'PLEASE WAIT' : 'TAP TO CONNECT', + label, style: TextStyle( - color: inCooldown ? Colors.grey : const Color.fromRGBO(74, 85, 104, 0.9), + color: tint, fontWeight: FontWeight.bold, letterSpacing: 1.5, ), diff --git a/lib/components/notification.dart b/lib/components/notification.dart index ad1b6b8..76af682 100644 --- a/lib/components/notification.dart +++ b/lib/components/notification.dart @@ -26,10 +26,19 @@ class VpnNotificationManager { _initialized = true; } + static String? _lastTimeLeft; + static String? _lastSpeedKbps; + static Future showOrUpdateStatus({ required String timeLeft, required String speedKbps, }) async { + // Skip update if nothing changed — prevents notification flicker + // on Android skins that animate every notification update. + if (timeLeft == _lastTimeLeft && speedKbps == _lastSpeedKbps) return; + _lastTimeLeft = timeLeft; + _lastSpeedKbps = speedKbps; + if (!_initialized) await init(); const androidDetails = AndroidNotificationDetails( diff --git a/lib/components/watch_ad_button.dart b/lib/components/watch_ad_button.dart index 77ea68d..bd74b0e 100644 --- a/lib/components/watch_ad_button.dart +++ b/lib/components/watch_ad_button.dart @@ -15,9 +15,15 @@ class WatchAdButton extends StatelessWidget { child: InkWell( borderRadius: BorderRadius.circular(12), onTap: () async { - final success = await context.read().showAd('bonus_ad'); - if (success && context.mounted) { - context.read().addBonusTime(); // Calls Hivemind API for 30m + 1GB + final adWatched = await context.read().showAd('bonus_ad'); + if (!adWatched || !context.mounted) return; + + // The actual session extension happens server-side via AdMob SSV. + // addBonusTime() syncs the new limits from the server. + final synced = await context.read().addBonusTime(); + if (!context.mounted) return; + + if (synced) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: const Text( @@ -32,6 +38,18 @@ class WatchAdButton extends StatelessWidget { duration: const Duration(seconds: 2), ), ); + } else { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Could not confirm bonus. Please check your connection.', + style: TextStyle(fontWeight: FontWeight.w600), + ), + backgroundColor: Color(0xFF5D4037), + behavior: SnackBarBehavior.floating, + duration: Duration(seconds: 3), + ), + ); } }, child: Container( diff --git a/lib/logic/ad_manager.dart b/lib/logic/ad_manager.dart index 0fc80f7..429e597 100644 --- a/lib/logic/ad_manager.dart +++ b/lib/logic/ad_manager.dart @@ -29,7 +29,8 @@ class AdManager extends ChangeNotifier { _preloadAd(); } - /// Internal helper to load an ad and return a Future when it's ready + /// Loads a rewarded ad in the background and returns a Future when ready. + /// Safe to call multiple times — re-entrant calls wait on the same load. Future _preloadAd() async { // If already loaded, return instantly if (_isAdLoaded) return true; diff --git a/lib/logic/hivemind_service.dart b/lib/logic/hivemind_service.dart index 9a05f8f..8e1cd1b 100644 --- a/lib/logic/hivemind_service.dart +++ b/lib/logic/hivemind_service.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:convert'; +import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'package:paladinvpn/logic/app_config.dart'; import 'package:paladinvpn/logic/crypto_service.dart'; @@ -14,20 +15,8 @@ class HivemindService { final url = Uri.parse('${AppConfig.hivemindApiBase}/session/status?device_id=$deviceId'); - // ── TEMPORARY AD BYPASS HACK FOR TESTING ── - // Since you disabled the crypto signature check on the Python server, - // we can just send a fake "Ad Watched" ping from the app directly. - try { - final customData = jsonEncode({ - 'device_id': deviceId, - 'public_key': keys['publicKey']!, - 'ad_type': 'main_ad', - }); - final fakeAdmobPingUrl = Uri.parse( - '${AppConfig.hivemindApiBase}/admob/callback?signature=test&key_id=test&custom_data=${Uri.encodeComponent(customData)}'); - await http.get(fakeAdmobPingUrl).timeout(const Duration(seconds: 8)); - } catch (_) {} - // ────────────────────────────────────────── + // AdMob SSV is handled server-to-server by Google. + // The app waits for the server to activate the session via the real callback. int attempts = 0; while (attempts < 15) { @@ -36,8 +25,7 @@ class HivemindService { if (response.statusCode == 200) { final data = jsonDecode(response.body); - // Debugging log to see exactly what the server returned - print('[HivemindService] Polling response: ${response.body}'); + debugPrint('[HivemindService] Session active, constructing config…'); if (data['active'] == true && data['client_ip'] != null && data['server_pubkey'] != null) { final clientIp = data['client_ip']; @@ -70,10 +58,10 @@ PersistentKeepalive = ${AppConfig.persistentKeepalive} return configText; } } else { - print('[HivemindService] Non-200 status: ${response.statusCode}'); + debugPrint('[HivemindService] Non-200 status: ${response.statusCode}'); } } catch (e) { - print('[HivemindService] Polling exception: $e'); + debugPrint('[HivemindService] Polling exception: $e'); // Ignore network errors and keep polling } diff --git a/lib/logic/session_timer.dart b/lib/logic/session_timer.dart index 5d11a37..790f63a 100644 --- a/lib/logic/session_timer.dart +++ b/lib/logic/session_timer.dart @@ -7,48 +7,58 @@ 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. -/// -/// It connects to the Hivemind API on the server to sync the true remaining -/// time, data quotas, speed, and throttling status. +/// SessionTimer is the client-side session authority for the VPN tunnel. +/// +/// Polls the Hivemind API to sync remaining time, data quotas, speed, and +/// throttling status. Rather than guessing the session length optimistically, +/// it waits for the first successful sync before the local countdown begins. class SessionTimer extends ChangeNotifier { Timer? _timer; int _tickCount = 0; - + final VpnConnection vpnConnection; - // ── Session State ── + // ── Session State ────────────────────────────────────────────────────────── int _remainingSeconds = 0; - int _quotaBytes = 0; - int _usedBytes = 0; - bool _isThrottled = false; - - // ── Speed State ── - int _lastUsedBytes = 0; - double _currentSpeedKbps = 0.0; // Total speed (RX+TX) + int _quotaBytes = 0; + int _usedBytes = 0; + bool _isThrottled = false; + + // ── Sync / Network Health ────────────────────────────────────────────────── + bool _hasSyncedOnce = false; + int _consecutiveFailures = 0; + bool _isDisconnecting = false; + + /// Maximum consecutive sync failures before the local countdown is paused + /// to avoid showing a false "expired" state while the network is down. + static const int _maxConsecutiveFailures = 3; + + /// Seconds between server polls (reduced from 3 to ease server load). + static const int _pollIntervalSeconds = 5; + + // ── Speed State ──────────────────────────────────────────────────────────── + int _lastUsedBytes = 0; + double _currentSpeedKbps = 0.0; + + // ── Notification dedup ───────────────────────────────────────────────────── + String _lastNotifTime = ''; + String _lastNotifSpeed = ''; SessionTimer({required this.vpnConnection}) { vpnConnection.addListener(_onVpnConnectionChanged); } - /// Fires whenever VpnConnection state changes. - /// Only acts during startup restoration — normal connects go through ConnectButton. - void _onVpnConnectionChanged() { - if (vpnConnection.status == VpnStatus.connected && - !isRunning && - vpnConnection.isStartupRestoration) { - start('resume'); - } - } + // ── Public Getters ───────────────────────────────────────────────────────── - int get remaining => _remainingSeconds; - bool get isRunning => _timer != null && _timer!.isActive; - bool get isExpired => _remainingSeconds <= 0 && !isRunning; - - int get quotaBytes => _quotaBytes; - int get usedBytes => _usedBytes; - int get remainingBytes => _quotaBytes > _usedBytes ? _quotaBytes - _usedBytes : 0; - bool get isThrottled => _isThrottled; + int get remaining => _remainingSeconds; + bool get isRunning => _timer != null && _timer!.isActive; + bool get isExpired => _remainingSeconds <= 0 && !isRunning; + bool get hasSyncedOnce => _hasSyncedOnce; + + int get quotaBytes => _quotaBytes; + int get usedBytes => _usedBytes; + int get remainingBytes => _quotaBytes > _usedBytes ? _quotaBytes - _usedBytes : 0; + bool get isThrottled => _isThrottled; double get currentSpeedKbps => _currentSpeedKbps; String get formatted { @@ -75,51 +85,80 @@ class SessionTimer extends ChangeNotifier { } 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: if (_quotaBytes == 0) return 0.0; return (_quotaBytes - _usedBytes) / _quotaBytes; } + // ── Lifecycle ────────────────────────────────────────────────────────────── + + /// Fires whenever VpnConnection state changes. + /// Only auto-restarts the timer during startup restoration — + /// normal connects are driven by [ConnectButton]. + void _onVpnConnectionChanged() { + if (vpnConnection.status == VpnStatus.connected && + !isRunning && + vpnConnection.isStartupRestoration) { + start('resume'); + } + } + + /// Starts the local countdown and begins server polling. + /// + /// Values are initialised at 0 — the UI shows "Syncing…" until the first + /// successful [_syncWithHivemind] returns real data. Future start(String adType) async { - _remainingSeconds = 3600; // optimistic start - _quotaBytes = 2 * 1024 * 1024 * 1024; - _usedBytes = 0; - _lastUsedBytes = 0; - _currentSpeedKbps = 0.0; - _isThrottled = false; - _tickCount = 0; - + _remainingSeconds = 0; + _quotaBytes = 0; + _usedBytes = 0; + _lastUsedBytes = 0; + _currentSpeedKbps = 0.0; + _isThrottled = false; + _tickCount = 0; + _hasSyncedOnce = false; + _consecutiveFailures = 0; + _isDisconnecting = false; + _timer?.cancel(); _timer = Timer.periodic(const Duration(seconds: 1), _tick); - - // Kick off an immediate sync so the UI shows real data right away + + notifyListeners(); + + // Kick off the first sync — the UI will show real data as soon as it lands. _syncWithHivemind(); } void _tick(Timer t) { - if (_remainingSeconds > 0) { - _remainingSeconds--; + // Only decrement the local clock after the first successful sync + // and while we are not in a network-blackout window. + if (_hasSyncedOnce && _consecutiveFailures < _maxConsecutiveFailures) { + if (_remainingSeconds > 0) { + _remainingSeconds--; + } } _tickCount++; - // Poll the server every 3 seconds to get live stats - if (_tickCount % 3 == 0) { + if (_tickCount % _pollIntervalSeconds == 0) { _syncWithHivemind(); } notifyListeners(); } + // ── Server Sync ──────────────────────────────────────────────────────────── + Future _syncWithHivemind() async { + // Never sync while the user is explicitly disconnecting. + if (_isDisconnecting) return; + try { final deviceId = await CryptoService.getDeviceId(); - final url = Uri.parse('${AppConfig.hivemindApiBase}/session/status?device_id=$deviceId'); - final response = await http.get(url).timeout(const Duration(seconds: 3)); - + final url = Uri.parse( + '${AppConfig.hivemindApiBase}/session/status?device_id=$deviceId'); + final response = await http.get(url).timeout(const Duration(seconds: 5)); + if (response.statusCode == 200) { final data = jsonDecode(response.body); - + final bool active = data['active'] ?? false; if (!active) { // Server killed the session! @@ -129,63 +168,88 @@ class SessionTimer extends ChangeNotifier { } _remainingSeconds = data['expires_in_seconds'] ?? _remainingSeconds; - _quotaBytes = data['quota_bytes'] ?? _quotaBytes; - _usedBytes = data['used_bytes'] ?? _usedBytes; - _isThrottled = data['is_throttled'] ?? false; + _quotaBytes = data['quota_bytes'] ?? _quotaBytes; + _usedBytes = data['used_bytes'] ?? _usedBytes; + _isThrottled = data['is_throttled'] ?? false; - // Calculate speed over the polling interval + // ── Speed calculation over the polling interval ──────────────────── final int deltaBytes = _usedBytes - _lastUsedBytes; - if (_lastUsedBytes > 0 && deltaBytes > 0) { - _currentSpeedKbps = (deltaBytes / 3) / 1024; - } else { + if (_hasSyncedOnce && deltaBytes > 0) { + _currentSpeedKbps = (deltaBytes / _pollIntervalSeconds) / 1024; + } else if (!_hasSyncedOnce) { _currentSpeedKbps = 0.0; } _lastUsedBytes = _usedBytes; - VpnNotificationManager.showOrUpdateStatus( - timeLeft: formatted, - speedKbps: '${_currentSpeedKbps.toStringAsFixed(2)} KB/s', - ); + // ── Network is healthy — reset failure counter ───────────────────── + _consecutiveFailures = 0; + _hasSyncedOnce = true; + + // ── Update notification (only when values actually change) ───────── + final speedText = _currentSpeedKbps > 0.5 + ? '${_currentSpeedKbps.toStringAsFixed(1)} KB/s' + : 'Idle'; + if (formatted != _lastNotifTime || speedText != _lastNotifSpeed) { + _lastNotifTime = formatted; + _lastNotifSpeed = speedText; + VpnNotificationManager.showOrUpdateStatus( + timeLeft: formatted, + speedKbps: speedText, + ); + } notifyListeners(); } else if (response.statusCode == 404) { - // Peer not found on server (expired/deleted natively) + // Peer not found on server (expired / deleted natively) stop(); vpnConnection.disconnect(); + } else { + _markSyncFailure(); } } catch (e) { debugPrint('Hivemind sync error: $e'); - // If we can't reach the server, just let the local timer run + _markSyncFailure(); } } - Future addBonusTime() async { - final ip = vpnConnection.internalIp; - if (ip != null) { - try { - final deviceId = await CryptoService.getDeviceId(); - await http.post( - Uri.parse('${AppConfig.hivemindApiBase}/session/start'), - headers: {'Content-Type': 'application/json'}, - body: jsonEncode({ - 'ip': ip, - 'device_id': deviceId, - 'ad_type': 'bonus_ad' - }), - ).timeout(const Duration(seconds: 5)); - - // Immediately sync to fetch the updated limits and un-throttled status - _syncWithHivemind(); - } catch (e) { - debugPrint('Failed to add bonus time with Hivemind: $e'); - } + /// Increments the failure counter; pauses the local countdown after + /// [_maxConsecutiveFailures] to avoid displaying a false "expired" clock. + void _markSyncFailure() { + _consecutiveFailures++; + if (_consecutiveFailures >= _maxConsecutiveFailures) { + notifyListeners(); // let the UI show "Reconnecting…" } } + // ── Bonus Time ───────────────────────────────────────────────────────────── + + /// Called after the user watches a bonus rewarded ad. + /// + /// The actual session extension is handled server-side via the AdMob SSV + /// callback. This method simply re-syncs so the UI picks up the new + /// limits and un-throttled status. + /// + /// Returns `true` if the sync succeeded, so the UI can decide whether to + /// show a success snackbar. + Future addBonusTime() async { + try { + await _syncWithHivemind(); + return _hasSyncedOnce; + } catch (e) { + debugPrint('Bonus-time sync error: $e'); + return false; + } + } + + // ── Stop ─────────────────────────────────────────────────────────────────── + void stop() { + _isDisconnecting = true; _timer?.cancel(); _timer = null; - _remainingSeconds = 0; + // Keep _remainingSeconds visible so the UI doesn't flash 00:00:00 + // while the tunnel is tearing down. The VpnConnection will drive the + // UI transition, and dispose() will do final cleanup. _currentSpeedKbps = 0.0; VpnNotificationManager.cancel(); notifyListeners(); diff --git a/lib/logic/vpn_connection.dart b/lib/logic/vpn_connection.dart index 1206685..9c74c5c 100644 --- a/lib/logic/vpn_connection.dart +++ b/lib/logic/vpn_connection.dart @@ -138,7 +138,17 @@ class VpnConnection extends ChangeNotifier { // The connect button uses this to decide whether to start the timer. Future connect() async { // Don't do anything if we're already connected or currently connecting - if (_status == VpnStatus.connected || _status == VpnStatus.connecting) return false; + if (_status == VpnStatus.connected || _status == VpnStatus.connecting) { + return false; + } + + // Guard: native plugin never loaded (wrong arch, missing .so) + if (!kIsWeb && !_pluginAvailable) { + _errorMessage = 'VPN service unavailable. The native tunnel library ' + 'could not be loaded on this device.'; + _setStatus(VpnStatus.error, 'Service unavailable'); + return false; + } _setStatus(VpnStatus.connecting, 'Fetching config…'); _errorMessage = null; @@ -150,32 +160,39 @@ class VpnConnection extends ChangeNotifier { return true; } - - - // ── Step 2: Fetch config from Hivemind ── + // ── Step 1: Fetch WireGuard config from Hivemind ──────────────────── String configText; try { configText = await HivemindService.fetchConfigWithPolling(); - - // Parse the internal IP (e.g. "Address = 10.8.0.2/24" or "/32") + + // Parse the internal IP (e.g. "Address = 10.8.0.2/16" or "/32") final regex = RegExp(r'Address\s*=\s*([0-9\.]+)/'); final match = regex.firstMatch(configText); if (match != null) { internalIp = match.group(1); } } catch (e) { - debugPrint('Hivemind API error: $e'); - // Set the exact error as the status message so the user can see it on screen - String errorMsg = e.toString().replaceAll('Exception: ', ''); - if (errorMsg.length > 30) { - errorMsg = '${errorMsg.substring(0, 30)}...'; // keep it somewhat short for the UI + debugPrint('Hivemind config error: $e'); + final raw = e.toString().replaceAll('Exception: ', ''); + // Map the known failures to something the user can act on + if (raw.contains('timed out') || raw.contains('Session not activated')) { + _errorMessage = 'The server did not respond in time.\n' + 'Check your internet connection and try again.'; + _setStatus(VpnStatus.error, 'Server unreachable'); + } else if (raw.contains('SocketException') || + raw.contains('Failed host lookup')) { + _errorMessage = 'Cannot reach the VPN server.\n' + 'Make sure you have an active internet connection.'; + _setStatus(VpnStatus.error, 'No internet'); + } else { + _errorMessage = raw; + final short = raw.length > 35 ? '${raw.substring(0, 35)}…' : raw; + _setStatus(VpnStatus.error, short); } - _errorMessage = e.toString(); - _setStatus(VpnStatus.error, errorMsg); return false; } - // ── Step 3: Start the WireGuard tunnel with the fetched config ───────── + // ── Step 2: Start the WireGuard tunnel with the fetched config ────── try { // Re-initialize to ensure VPN permission is granted before starting await _wireguard.initialize(interfaceName: 'Paladin0'); @@ -188,8 +205,39 @@ class VpnConnection extends ChangeNotifier { return true; } catch (e) { debugPrint('WireGuard tunnel error: $e'); - _errorMessage = 'Tunnel failed to start.\nTry reconnecting.'; - _setStatus(VpnStatus.error, 'Tunnel error'); + final raw = e.toString(); + if (raw.contains('VPN_PERMISSION_REQUESTED')) { + // Native plugin showed the VPN permission dialog — wait and retry + _setStatus(VpnStatus.connecting, 'Waiting for permission…'); + await Future.delayed(const Duration(seconds: 2)); + // Retry the tunnel start once the user has had time to grant permission + try { + await _wireguard.startVpn( + serverAddress: AppConfig.serverEndpoint, + wgQuickConfig: configText, + providerBundleIdentifier: 'com.paladinvpn.app.WireGuardProvider', + ); + _setStatus(VpnStatus.connected, 'Secured'); + return true; + } catch (retryError) { + debugPrint('WireGuard retry error: $retryError'); + _errorMessage = 'VPN permission was denied.\n' + 'Please grant the VPN permission and try again.'; + _setStatus(VpnStatus.error, 'Permission denied'); + return false; + } + } else if (raw.contains('Permission') || raw.contains('permissions')) { + _errorMessage = 'VPN permission was denied.\n' + 'Please grant the VPN permission and try again.'; + _setStatus(VpnStatus.error, 'Permission denied'); + } else if (raw.contains('NameInvalid') || raw.contains('invalid')) { + _errorMessage = 'The WireGuard configuration is invalid.\n' + 'Please contact support.'; + _setStatus(VpnStatus.error, 'Bad config'); + } else { + _errorMessage = 'Tunnel failed to start.\nTry reconnecting.'; + _setStatus(VpnStatus.error, 'Tunnel error'); + } return false; } } diff --git a/lib/screens/intro.dart b/lib/screens/intro.dart index 0e75636..7932935 100644 --- a/lib/screens/intro.dart +++ b/lib/screens/intro.dart @@ -13,12 +13,14 @@ class _IntroScreenState extends State { @override void initState() { super.initState(); - // Wait for 2.5 seconds then smoothly transition to the main screen - Timer(const Duration(milliseconds: 2500), () { + // Transition to the main screen after a brief natural pause. + // The delay is just long enough for the logo to be perceived (≈1.2 s) + // but not so long that it feels like the app is frozen. + Timer(const Duration(milliseconds: 1200), () { if (mounted) { Navigator.of(context).pushReplacement( PageRouteBuilder( - transitionDuration: const Duration(milliseconds: 800), + transitionDuration: const Duration(milliseconds: 600), pageBuilder: (_, __, ___) => const MainScreen(), transitionsBuilder: (_, animation, __, child) { return FadeTransition(opacity: animation, child: child); diff --git a/local_packages/wireguard_flutter/android/src/main/AndroidManifest.xml b/local_packages/wireguard_flutter/android/src/main/AndroidManifest.xml index a2f47b6..d3fba38 100644 --- a/local_packages/wireguard_flutter/android/src/main/AndroidManifest.xml +++ b/local_packages/wireguard_flutter/android/src/main/AndroidManifest.xml @@ -1,2 +1,8 @@ + + + + diff --git a/local_packages/wireguard_flutter/android/src/main/kotlin/billion/group/wireguard_flutter/WireguardFlutterPlugin.kt b/local_packages/wireguard_flutter/android/src/main/kotlin/billion/group/wireguard_flutter/WireguardFlutterPlugin.kt index 5eec1e7..f6b3429 100644 --- a/local_packages/wireguard_flutter/android/src/main/kotlin/billion/group/wireguard_flutter/WireguardFlutterPlugin.kt +++ b/local_packages/wireguard_flutter/android/src/main/kotlin/billion/group/wireguard_flutter/WireguardFlutterPlugin.kt @@ -237,8 +237,13 @@ class WireguardFlutterPlugin : FlutterPlugin, MethodCallHandler, ActivityAware, scope.launch(Dispatchers.IO) { try { if (!havePermission) { + // The permission dialog was already shown by setupTunnel / + // checkPermission. Tell Flutter to wait and retry rather + // than throwing a confusing error while the system dialog + // is still on screen. checkPermission() - throw Exception("Permissions are not given") + flutterError(result, "VPN_PERMISSION_REQUESTED") + return@launch } updateStage("prepare") val inputStream = ByteArrayInputStream(wgQuickConfig.toByteArray()) diff --git a/technical_debt_1.md b/technical_debt_1.md new file mode 100644 index 0000000..f59f139 --- /dev/null +++ b/technical_debt_1.md @@ -0,0 +1,614 @@ +# PaladinVPN — Technical Audit & UX Bug Report + +**Date:** 2025-07-15 +**Scope:** Full codebase — Flutter client (`lib/`), Android native layer (`android/`, `local_packages/wireguard_flutter/`), Python Hivemind server (`server_hivemind_5_0.py`) + +--- + +## 🔴 Critical Blockers +*Things that will crash the app, break the VPN, or get the app rejected by Google Play.* + +--- + +### 1. Conflicting `build.gradle.kts` — Dual `applicationId` Declaration + +**Files:** `android/build.gradle.kts` (root) vs `android/app/build.gradle.kts` (app-level) + +The **root** `build.gradle.kts` incorrectly applies `com.android.application` and declares: +- `namespace = "com.esefcloud.vpn"` +- `applicationId = "com.esefcloud.vpn"` + +The **app-level** `build.gradle.kts` correctly declares: +- `namespace = "com.paladinvpn.app"` +- `applicationId = "com.paladinvpn.app"` + +The root project should NOT apply `com.android.application`. This causes merge conflicts, duplicate `AndroidManifest` merging, and can unpredictably override the `applicationId`. If the package name doesn't match your Play Console listing, Google Play **will reject** the upload. AdMob is also tied to the application ID. + +**Fix:** Remove the `com.android.application` plugin and the `android {}` block from the root `build.gradle.kts`. Those belong only in `android/app/build.gradle.kts`. + +--- + +### 2. Missing Foreground Service Type Declaration (Android 14+) — **REJECTION RISK** + +**Files:** `android/app/src/main/AndroidManifest.xml`, `local_packages/wireguard_flutter/android/src/main/AndroidManifest.xml` + +Your manifest declares: +```xml + + +``` + +But there is **no `` element** declaring `android:foregroundServiceType`. Android 14 (API 34) **requires** every foreground service to declare a type (e.g., `dataSync`, `connectedDevice`, `specialUse`). The AmneziaWG library starts a VPN foreground service internally — without the type declaration in the merged manifest, the service **will crash on launch** on Android 14+ devices. + +Additionally, `FOREGROUND_SERVICE_SPECIAL_USE` triggers a Google Play review requirement: you must submit a written justification. VPN apps typically don't need this permission — the `VpnService` API includes its own persistent notification mechanism that is exempt from the foreground service type requirement. Consider removing `FOREGROUND_SERVICE_SPECIAL_USE` entirely. + +The wireguard_flutter plugin's manifest at `local_packages/wireguard_flutter/android/src/main/AndroidManifest.xml` is **completely empty** — it must include the VpnService declaration or ensure the AmneziaWG AAR ships its own. + +**Fix:** Verify whether the AmneziaWG AAR declares the VpnService in its own manifest. If not, add it to the plugin manifest with the proper `foregroundServiceType`. + +--- + +### 3. AdMob SSV Completely Bypassed — Both Client and Server + +**Files:** `lib/logic/hivemind_service.dart` (lines 21–29), `server_hivemind_5_0.py` (lines 424–438) + +**Client-side** — `hivemind_service.dart` fires a **fake AdMob ping** on every connection: +```dart +// ── TEMPORARY AD BYPASS HACK FOR TESTING ── +final fakeAdmobPingUrl = Uri.parse( + '${AppConfig.hivemindApiBase}/admob/callback?signature=test&key_id=test&custom_data=...'); +await http.get(fakeAdmobPingUrl).timeout(const Duration(seconds: 8)); +``` +The error is swallowed (`catch (_) {}`), so even if the server is unreachable the app proceeds to poll. + +**Server-side** — `server_hivemind_5_0.py` has the **entire** ECDSA signature verification block commented out: +```python +# ⚠️ ADMOB BYPASS (TEMPORARY FOR APP REVIEW) +# DELETE THIS ENTIRE BLOCK WHEN YOU WANT TO ENABLE REAL ADS +``` +The `/api/admob/callback` endpoint processes **all** requests unconditionally. + +**Impact:** Anyone can `curl https://paladinvpn.duckdns.org/api/admob/callback?custom_data=...` and create unlimited 60-minute/2GB sessions without watching an ad. Google Play reviewers or automated testing will detect that the ad flow is non-functional. This is grounds for **immediate rejection** under the "Deceptive Behavior" policy. + +**Fix:** Remove both bypasses. Enable real SSV signature verification on the server. Remove the fake ping from `hivemind_service.dart` — the actual connection flow must wait for Google's server-to-server callback, not a client-side fake. + +--- + +### 4. `app_config.dart` Contains Production Secrets — Verify Git Tracking + +**File:** `lib/logic/app_config.dart` + +The `.gitignore` lists `lib/logic/app_config.dart`, and an example template exists at `app_config.example.dart`. However, the **real file exists in the working tree** with: +- Production server IP: `204.168.246.88` +- Production AdMob unit ID: `ca-app-pub-5671224884648691/9198987725` +- Production AdMob app ID: `ca-app-pub-5671224884648691~1007152454` (in AndroidManifest) +- Production DuckDNS domain: `paladinvpn.duckdns.org` + +**Run immediately:** +```powershell +git ls-files lib/logic/app_config.dart +``` +If it returns the file, it IS tracked — meaning these secrets are in your git history forever. You would need to: +1. Rotate the AdMob IDs (create new ad units) +2. Change the server IP or accept it's public +3. Use `git filter-branch` or `BFG Repo-Cleaner` to scrub history + +--- + +### 5. `/api/session/start` Endpoint Does Not Exist — Bonus Ads Are Broken + +**Files:** `lib/logic/session_timer.dart` → `addBonusTime()`, `server_hivemind_5_0.py` + +The `addBonusTime()` method in SessionTimer does an HTTP POST to: +``` +POST ${AppConfig.hivemindApiBase}/session/start +``` + +The server has **no such route**. The server only has: +- `GET /api/session/status` — status polling +- `POST /api/session/stop` — session cleanup +- `GET /api/admob/callback` — SSV (bypassed) + +The intended flow is: user watches ad → Google calls your server via SSV → server extends the session. But since SSV is bypassed AND the endpoint is missing, **bonus ads do absolutely nothing**. The user watches a 30-second ad, the `showAd()` returns `true`, the snackbar says "+30 minutes added!", but the server session is never extended. After the current session expires, the user is throttled/disconnected regardless. + +**Fix:** Either add the `/api/session/start` endpoint to the server, or — the correct approach — enable real SSV and have the server extend sessions inside the `/api/admob/callback` handler (which it already does for both `main_ad` and `bonus_ad` types). Then `addBonusTime()` only needs to re-sync, not create a session. + +--- + +### 6. Server Public Key Fallback Is a Hardcoded Invalid String + +**File:** `server_hivemind_5_0.py` (~line 480) + +When the server can't fetch the WireGuard public key from `awg0`: +```python +SERVER_PUBKEY = SERVER_PUBKEY_FALLBACK or "server key=" +``` + +The string `"server key="` is not a valid base64 WireGuard key. If this fallback is ever hit, every client connection will fail with an invalid config. The `SERVER_PUBKEY_FALLBACK` is `None` by default, so the fallback-to-fallback string is always used. Either set a real fallback key or fail gracefully with a clear error log. + +--- + +## 🟡 Warnings — Architecture & Reliability +*Race conditions, edge cases, and architecture flaws that could cause a bad user experience.* + +--- + +### 7. Race Condition: Optimistic Client State Before Server Confirmation + +**File:** `lib/logic/session_timer.dart` → `start()` + +```dart +_remainingSeconds = 3600; // optimistic start +_quotaBytes = 2 * 1024 * 1024 * 1024; +``` + +The UI immediately renders "01:00:00" and "2.00 GB" before the first server sync completes (~1–3 seconds). If the server allocated a different duration (e.g., 45 minutes from a previous session that wasn't properly cleaned), the UI flashes wrong data then jumps to the correct value. Users perceive this as a glitch. + +**Fix:** Set initial values to 0 or show a loading state until the first sync returns. Display "Calculating…" or a shimmer placeholder until real data arrives. + +--- + +### 8. Race Condition: In-Flight HTTP Callback During Disconnect + +**File:** `lib/logic/session_timer.dart` + +`_syncWithHivemind()` fires on a `Timer.periodic` every 1 second (sync every 3rd tick). When the user disconnects: +1. `vpn.disconnect()` is called +2. `timer.stop()` cancels the timer +3. But an in-flight `_syncWithHivemind()` HTTP request may still be pending +4. When it completes, it sees `!active` → calls `stop()` and `vpnConnection.disconnect()` again + +The double-disconnect is mostly harmless since `disconnect()` guards against re-entry, but it could cause the cleanup HTTP call (`disconnectAndCleanup()`) to fire twice, and the notification to be cancelled/restored in a flicker. + +**Fix:** Add an `_isDisconnecting` flag checked at the top of `_syncWithHivemind()`. + +--- + +### 9. No Graceful Recovery From Temporary Network Drops + +**File:** `lib/logic/session_timer.dart` → `_tick()` and `_syncWithHivemind()` + +The local `_tick` decrements `_remainingSeconds` **every second regardless of sync status**. If the server is unreachable for 60 seconds: +- The UI countdown hits `00:00:00` +- The user sees "expired" +- But the server session is still valid (it has its own expiry clock) +- When the network returns, the next sync jumps the timer back up — confusing the user + +The catch block silently logs and does nothing: +```dart +} catch (e) { + debugPrint('Hivemind sync error: $e'); + // If we can't reach the server, just let the local timer run +} +``` + +This is **not** "just letting the local timer run" — the local timer is actively decrementing, creating a growing drift from reality. + +**Fix:** Track consecutive sync failures. After 3 failures, pause the local countdown and show "Reconnecting…" in the UI. Resume when sync succeeds. + +--- + +### 10. Permission Dialog + Immediate Failure = Broken UX + +**File:** `local_packages/wireguard_flutter/.../WireguardFlutterPlugin.kt` → `connect()` + +```kotlin +if (!havePermission) { + checkPermission() // launches system VPN permission dialog + throw Exception("Permissions are not given") // immediately fails +} +``` + +`checkPermission()` fires an intent that shows the Android VPN permission dialog. But the code **immediately throws an exception** — before the user even sees the dialog. The Flutter side receives the error, sets status to `VpnStatus.error` with "Tunnel error", and the user sees a failure message. Meanwhile, the system permission dialog is still on screen. The user grants permission, returns to the app, sees "Tunnel error", and must tap Connect again. + +**Fix:** Make `connect()` wait for the permission result (use `startActivityForResult` with a callback/completer pattern) instead of throwing. + +--- + +### 11. 3-Second Polling Per Client — Server Scalability Issue + +**File:** `lib/logic/session_timer.dart` → `_syncWithHivemind()` + +Every connected client hits your Flask server every 3 seconds. With 100 concurrent users, that's ~33 requests/second. Flask's development server is single-threaded — it will queue requests and time out. Even with gunicorn, this is expensive. + +**Fix:** Increase the poll interval to 5–10 seconds. Better: use Server-Sent Events (SSE) or a lightweight WebSocket so the server pushes updates rather than the client polling. + +--- + +### 12. `isVpnChecked` Flag — Single-Use, State Desync on App Restoration + +**File:** `local_packages/wireguard_flutter/.../WireguardFlutterPlugin.kt` + +The `isVpnChecked` flag is set only in the `"start"` method and reset only in `onDetachedFromEngine`. If the Flutter engine is killed and recreated (process death), the flag is `false` but the VPN may still be running. On restart, `VpnConnection._init()` calls `_wireguard.stage()` to detect the running tunnel — this path works, but the native plugin's internal state tracking is fragile. + +--- + +### 13. Server: `management_loop` Blocks on Subprocess Calls + +**File:** `server_hivemind_5_0.py` → `management_loop()` + +The loop calls `get_wireguard_stats()` every 60 seconds, which spawns two Docker exec subprocesses: +```python +transfer_out = run_cmd("awg show awg0 transfer") +allowed_out = run_cmd("awg show awg0 allowed-ips") +``` + +Each `run_cmd` calls `subprocess.run()` synchronously. If Docker is slow or the system is under load, these calls can take seconds. The management loop is single-threaded — during this time, expired sessions are not cleaned up and the Flask server (if running single-process) may be blocked on the GIL. + +**Fix:** Use `subprocess.Popen` with a timeout, or cache stats more aggressively. + +--- + +### 14. Server: `tc filter` Cleanup Is Never Done on Expiry — Possible tc Rule Leak + +**File:** `server_hivemind_5_0.py` → `management_loop()` + +When a session expires in the management loop, the code does: +```python +expired_devices.append(device_id) +``` +Then later: +```python +for device_id in expired_devices: + del active_sessions[device_id] +``` + +But the associated `tc` throttle rules are **never removed** on expiry. The `remove_throttle()` + `remove_wg_peer()` + `release_ip()` calls only happen in `session_stop()` (explicit disconnect from the app). If the app crashes or the user force-stops, expired sessions leave orphaned tc filter/class rules. Over weeks, the tc qdisc accumulates hundreds of dead rules, degrading network performance. + +**Fix:** In the expiry cleanup block, call `remove_throttle()`, `remove_wg_peer()`, and `release_ip()` for each expired device. + +--- + +### 15. No ProGuard/R8 Rules for AmneziaWG Native Library + +**File:** `android/app/build.gradle.kts` + +There is no `minifyEnabled`, no ProGuard configuration, and no keep rules for the `org.amnezia.awg` native library. Release builds may strip JNI bindings, causing `UnsatisfiedLinkError` crashes. + +**Fix:** Add a `proguard-rules.pro` with keep rules for `org.amnezia.awg.backend.*` and `org.amnezia.awg.crypto.*`, and enable minification in `buildTypes { release { ... } }`. + +--- + +## 🟢 Polish & Best Practices +*Minor code cleanliness, structural improvements, and maintenance issues.* + +--- + +### 16. `build.gradle.kts` Root Duplicates `flutter {}` and Kotlin Config + +The root `build.gradle.kts` redundantly declares `flutter { source = "../.." }` and `kotlin { compilerOptions { ... } }` which are already in the app-level file. This is dead config that causes confusion. + +--- + +### 17. `AppConfig` Is Instantiable Despite Being Static-Only + +**File:** `lib/logic/app_config.dart` + +```dart +class AppConfig { + static const String serverIp = '...'; + // ... +} +``` + +Nothing prevents `AppConfig()` from being instantiated. Mark it: +```dart +abstract final class AppConfig { + AppConfig._(); + // ... +} +``` + +--- + +### 18. Debug Banner Disabled Unconditionally + +**File:** `lib/main.dart` + +```dart +debugShowCheckedModeBanner: false, +``` + +This disables the debug banner even during development. Consider: +```dart +debugShowCheckedModeBanner: false, // acceptable for this app's dark theme +``` + +Not a bug, but the debug banner is useful during development for catching accidental release-mode testing. + +--- + +### 19. Privacy Policy Links to GitHub Raw `.md` — May Render Poorly + +**File:** `lib/components/privacy_policy_button.dart` + +```dart +final Uri url = Uri.parse('https://github.com/esefxdz/PaladinVPN/blob/main/PRIVACY_POLICY.md'); +``` + +Google Play requires your privacy policy to be a properly hosted URL. GitHub raw links are generally accepted, but a `.md` file renders as raw text in a browser. Use GitHub Pages or a simple HTML page. + +--- + +### 20. `url_launcher` on Android 11+ Requires `` — Already Done + +Your manifest correctly includes the `` block for `intent.action.VIEW`. No issue here — confirming compliance. + +--- + +### 21. Hardcoded "Helsinki, Finland" Location + +**File:** `lib/components/status_text.dart` + +The server location is hardcoded. If you ever add more servers or move the server, the UI lies. Move this to `AppConfig` or fetch it from the server. + +--- + +### 22. Zero Tests + +**Files:** No `test/` directory, no test files anywhere. + +Despite `flutter_test` being in `dev_dependencies`, there are zero unit tests, widget tests, or integration tests. For a VPN app handling: +- Cryptographic key generation and persistence +- Session state machine transitions +- Network timeout and retry logic +- Ad reward flow + +At minimum, unit-test the crypto service, session timer logic, and config parsing. + +--- + +### 23. `Server Full Picture.txt` in `.gitignore` But Present in Working Tree + +The file `server full picture.txt` is in `.gitignore` but exists locally. It contains sensitive server configuration (IP addresses, Docker setup, iptables rules). Verify it's not tracked by git: +```powershell +git ls-files "server full picture.txt" +``` + +--- + +## 👤 User Experience Bugs +*Things that create an unpleasant, confusing, or glitchy experience for real users.* + +--- + +### UX-1. 5-Second Cooldown Triggers Even on Instant Failure + +**File:** `lib/components/connect_button.dart` → `_handleTap()` + +```dart +setState(() => _inCooldown = true); +Future.delayed(const Duration(seconds: 5), () { + if (mounted) setState(() => _inCooldown = false); +}); +``` + +The cooldown is set **before** attempting the connection. If the connection fails instantly (no network, server unreachable, permission denied), the user sees: +- "PLEASE WAIT" with an hourglass icon +- A greyed-out, unresponsive button +- For **5 full seconds** + +This is infuriating. The user knows the connection failed, but the app locks them out anyway. + +**Fix:** Only apply the cooldown on **successful** connections (to prevent rapid disconnect/reconnect spam). On failure, reset immediately or use a much shorter cooldown (1 second). + +--- + +### UX-2. Countdown Timer Jumps Backward When Sync Arrives Late + +**File:** `lib/logic/session_timer.dart` + +The optimistic start sets `_remainingSeconds = 3600`. The `_tick()` decrements it every second. When the first server sync arrives (1–3 seconds later), the server returns the *real* `expires_in_seconds` — which could be anything from 0 to 3600. The timer visibly jumps: + +``` +01:00:00 → 00:59:59 → 00:59:58 → [sync] → 00:45:23 ← user sees a jump +``` + +Even worse if the sync fails for 10 seconds — the tick keeps decrementing local time while the server still shows 3600. When sync resumes, the timer jumps UP: + +``` +00:59:50 → [sync] → 01:00:00 ← "Wait, did I just gain time?" +``` + +**Fix:** Don't start the local countdown until the first successful sync. Show "Syncing…" or a blurred placeholder until real data arrives. + +--- + +### UX-3. "TAP TO CONNECT" Is Always Visible — Even When Nothing Will Happen + +**File:** `lib/components/connect_button.dart` + +When tapping has no possible effect (no network, ad not loaded, cooldown active), the button still says "TAP TO CONNECT". The only indication that tapping won't work is a subtle color change (grey vs muted blue). Users will tap repeatedly and wonder why nothing happens. + +**Fix:** Show contextual text: +- No network: "NO CONNECTION" +- Ad loading: "LOADING AD…" +- Cooldown: "PLEASE WAIT" (already done) +- Ready: "TAP TO CONNECT" + +--- + +### UX-4. No Feedback When Ad Fails to Load + +**File:** `lib/logic/ad_manager.dart` + +If `_preloadAd()` fails, `_isAdLoaded` stays `false`. The `ConnectButton` doesn't check `AdManager.isAdLoaded` at all — it proceeds to call `vpn.connect()` which calls `hivemind_service.fetchConfigWithPolling()`, which fires the fake SSV ping (see Critical #3), which polls. The user sees "Establishing tunnel…" for up to 30 seconds (15 polls × 2 seconds) then gets a cryptic "Session not activated" error. + +**Fix:** Check `adManager.isAdLoaded` before attempting connection. If no ad is ready, show "Ad not ready, please wait…" and trigger a reload. + +--- + +### UX-5. Snackbar Says "+30 minutes added!" Even When Bonus Fails + +**File:** `lib/components/watch_ad_button.dart` + +```dart +final success = await context.read().showAd('bonus_ad'); +if (success && context.mounted) { + context.read().addBonusTime(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: const Text('+30 minutes added!'), ...), + ); +} +``` + +The snackbar shows **regardless of whether `addBonusTime()` succeeded**. The `addBonusTime()` method silently catches all errors. So the user sees "+30 minutes added!" but the server never got the request (missing endpoint, see Critical #5). The timer stays unchanged. The user thinks the app is broken or lying. + +**Fix:** `addBonusTime()` should return a `bool`. Only show the success snackbar if the API call succeeded. Show an error snackbar otherwise. + +--- + +### UX-6. Intro Splash Screen — 2.5 Seconds of Wasted Time, Every Launch + +**File:** `lib/screens/intro.dart` + +```dart +Timer(const Duration(milliseconds: 2500), () { + if (mounted) { + Navigator.of(context).pushReplacement(...); + } +}); +``` + +Every time the user opens the app, they stare at a logo and spinner for 2.5 seconds. This is a placeholder delay, not a loading screen — nothing is being loaded. For an app that users open frequently to check their VPN status, this adds up to minutes of wasted time per week. + +**Fix:** Remove the artificial delay. If the app genuinely needs startup time (e.g., checking for a running tunnel), make the intro screen reflect actual loading progress and transition as soon as `_init()` completes. + +--- + +### UX-7. Notification Shows "0.00 KB/s" on Slow Connections + +**File:** `lib/logic/session_timer.dart` → `_syncWithHivemind()` + +```dart +if (_lastUsedBytes > 0 && deltaBytes > 0) { + _currentSpeedKbps = (deltaBytes / 3) / 1024; +} else { + _currentSpeedKbps = 0.0; +} +``` + +The speed calculation uses `_lastUsedBytes > 0` as a guard. On the first sync, `_lastUsedBytes` is 0, so speed shows `0.00 KB/s`. If the user isn't actively transferring data, the delta is 0 and speed shows `0.00 KB/s`. The persistent notification displays "0.00 KB/s" which looks like the VPN is broken. Users associate "0.00 KB/s" with "not working." + +**Fix:** Show "Idle" or "Connected" instead of "0.00 KB/s" when speed is effectively zero. + +--- + +### UX-8. Persistent Notification Is Re-Posted Every 3 Seconds + +**File:** `lib/components/notification.dart` → `showOrUpdateStatus()` + +The notification is updated every 3 seconds (every sync cycle). On some Android skins (Xiaomi, OnePlus, Samsung), frequent notification updates cause the notification to "flicker" in the shade — it briefly disappears and reappears. This is distracting and looks buggy. + +**Fix:** Only update the notification when the displayed values actually change (time string differs from last posted, speed changes by >10%). + +--- + +### UX-9. No Visual Indicator That Bonus Ad Loading Failed + +**File:** `lib/components/watch_ad_button.dart`, `lib/logic/ad_manager.dart` + +The `WatchAdButton` always renders — even if no ad is loaded. Tapping it calls `showAd('bonus_ad')` which internally calls `_preloadAd()` and waits. If loading fails, `showAd()` returns `false` and… nothing happens. No error message. No snackbar. The user taps "Support +30m", waits a moment, and… nothing. They'll tap again. And again. + +**Fix:** Show the ad button with a loading state ("Loading ad…") when no ad is ready. Show a brief error if loading fails after a tap. + +--- + +### UX-10. Disconnect Clears Timer Instantly — No "Tearing Down" Visual + +**File:** `lib/logic/session_timer.dart` → `stop()` + +```dart +void stop() { + _timer?.cancel(); + _timer = null; + _remainingSeconds = 0; + ... +} +``` + +`stop()` sets the timer to zero immediately, but the `VpnConnection` goes through a `disconnecting → disconnected` transition. The ConnectButton circle shows a spinner during disconnection, but the timer text inside it jumps to `00:00:00` prematurely. + +**Fix:** Don't zero the timer in `stop()`. Let `VpnConnection` control the disconnect flow and only zero the timer when the status reaches `disconnected`. + +--- + +### UX-11. Error Messages Are Generic and Unhelpful + +**File:** `lib/logic/vpn_connection.dart` → `connect()` + +```dart +_errorMessage = 'Tunnel failed to start.\nTry reconnecting.'; +``` + +Every failure produces the same error message. The user doesn't know if: +- The server is down +- Their network has no internet +- The ad wasn't watched +- The config was invalid +- Android denied VPN permission + +This makes debugging impossible for users and will generate 1-star reviews saying "doesn't work." + +**Fix:** Surface specific error messages from each failure branch. Map them to user-friendly strings. + +--- + +### UX-12. Connected Timer Text Is Small and Hard to Read at a Glance + +**File:** `lib/components/clock_display.dart` + +The countdown text is 40px inside a 260px circle. For a quick-glance status, many users will pick up their phone and squint at "01:00:00" in white on a dark background. The speed text below it (12px, grey) is nearly invisible in sunlight. + +**Fix:** Consider increasing font weight, adding a subtle background contrast behind the timer, or showing remaining time in the notification as a large-format text for at-a-glance viewing. + +--- + +### UX-13. No Haptic Feedback on Connect/Disconnect + +**File:** `lib/components/connect_button.dart` + +The connect/disconnect action has no haptic feedback. For a button this important, a brief vibration on successful connection gives tactile confirmation. Many premium VPN apps do this. + +--- + +### UX-14. Intro Screen "PALADIN VPN" Text Has No Loading Context + +**File:** `lib/screens/intro.dart` + +The intro shows a logo, "PALADIN VPN", and a spinning circle for 2.5 seconds. It doesn't say "Loading…" or "Preparing…" — it's just a spinner. Users may think the app froze. If the startup takes longer (slow device), 2.5 seconds feels like an eternity with no text indicating progress. + +--- + +### UX-15. Status Bar Icon Brightness Is Always Light + +**File:** `lib/main.dart` + +```dart +statusBarIconBrightness: Brightness.light, +``` + +This is correct for the dark theme — light icons on a transparent/dark status bar. No bug here. Confirming compliance. + +--- + +### UX-16. App Crashes If WireGuard Plugin Is Not Available + +**File:** `lib/logic/vpn_connection.dart` → `_init()` + +The `try/catch` around initialization catches the error, but `_wireguard` remains half-initialized. If the AmneziaWG native library fails to load (wrong architecture, missing .so files), subsequent calls to `startVpn()` or `stopVpn()` will throw unhandled exceptions that crash the app. + +**Fix:** Set a `_isAvailable` flag after successful init. Guard all VPN operations behind it. Show "VPN service unavailable" in the UI if init failed. + +--- + +## Summary + +| Category | Count | Top Priority Items | +|----------|-------|-------------------| +| 🔴 Critical Blockers | 6 | Conflicting `applicationId`, missing `foregroundServiceType`, SSV bypasses, secrets in git, broken bonus ads, invalid server key fallback | +| 🟡 Warnings | 9 | Optimistic state flash, disconnect race, no network-drop recovery, permission dialog UX, polling scale, tc rule leak, ProGuard missing | +| 🟢 Polish | 9 | Static class, no tests, hardcoded location, privacy policy URL, debug banner, dead gradle config | +| 👤 UX Bugs | 16 | Cooldown on failure, timer jumps, broken bonus feedback, 2.5s artificial delay, generic errors, notification flicker, 0.00 KB/s display | + +**Before Google Play submission, fix items 1–6 in Critical Blockers. Items UX-1 through UX-7 will directly prevent 1-star reviews.** diff --git a/technical_debt_1_answer.md b/technical_debt_1_answer.md new file mode 100644 index 0000000..1263c22 --- /dev/null +++ b/technical_debt_1_answer.md @@ -0,0 +1,102 @@ +# Thoughts and Answers to Technical Audit + +Here are my thoughts and proposed actions for every single point raised in the technical audit report. The agent did a fantastic job pointing out native Android issues and server-side edge cases that are crucial for a Google Play launch. + +--- + +## 🔴 Critical Blockers + +### 1. Conflicting `build.gradle.kts` +**Thoughts:** The agent is 100% correct. Applying `com.android.application` in the root `build.gradle` is an anti-pattern in Flutter and will cause massive headaches during the release build. +**Action:** Remove the `android {}` block and plugin from the root `build.gradle.kts`. + +### 2. Missing Foreground Service Type Declaration +**Thoughts:** Android 14+ is incredibly strict about Foreground Services. However, `VpnService` is a special case in Android that generally provides its own system-level notification. If we are using `FOREGROUND_SERVICE_SPECIAL_USE`, Google Play will definitely flag us for a manual review. +**Action:** We need to inspect `wireguard_flutter` and potentially remove our manual `FOREGROUND_SERVICE` permissions if the AmneziaWG AAR handles it, or correctly classify it to avoid Play Store rejection. + +### 3. AdMob SSV Completely Bypassed +**Thoughts:** We knowingly bypassed this for your rapid testing phase, so this isn't a surprise. But the agent is right: if we ship this to Google Play, anyone can bypass the ads, and Google might reject it for deceptive ad behavior. +**Action:** When you are ready to enable real ads, we must remove the client-side fake ping in `hivemind_service.dart` and uncomment the ECDSA signature verification in the Python server. + +### 4. `app_config.dart` Contains Production Secrets +**Thoughts:** AdMob App IDs are public anyway (they are embedded in the APK), but tracking IP addresses and configurations in Git is bad practice. +**Action:** We should ensure `app_config.dart` is untracked (`git rm --cached`) so future changes don't pollute the repository. + +### 5. `/api/session/start` Endpoint Does Not Exist +**Thoughts:** Excellent catch by the agent! Our `addBonusTime()` method in Flutter posts to a `/session/start` endpoint that the Python server doesn't even have. +**Action:** We need to refactor `addBonusTime()`. It shouldn't try to start a session. Instead, watching the ad should trigger the real SSV callback on the server (which already correctly handles `bonus_ad` to extend time), and the client should simply re-sync the timer. + +### 6. Server Public Key Fallback Is a Hardcoded Invalid String +**Thoughts:** Passing `"server key="` when `awg0` fails to read the pubkey will guarantee the Flutter client crashes with an invalid WireGuard config. +**Action:** We should make the server throw a proper HTTP 500 error instead of silently passing garbage to the client. + +--- + +## 🟡 Warnings — Architecture & Reliability + +### 7. Optimistic Client State Before Server Confirmation +**Thoughts:** Showing 01:00:00 before the server confirms the real time causes a glitchy UI jump. +**Action:** We will add a "Calculating..." or "Syncing..." state in the UI until the first `_syncWithHivemind` succeeds. + +### 8. Race Condition: In-Flight HTTP Callback During Disconnect +**Thoughts:** If the 3-second sync finishes right after the user hits disconnect, it triggers a double-disconnect. +**Action:** Easy fix. Add a `bool _isDisconnecting` guard. + +### 9. No Graceful Recovery From Temporary Network Drops +**Thoughts:** Very important for mobile. If the user drives through a tunnel and loses 4G, the local timer shouldn't blindly tick down to 0 while the server sync fails. +**Action:** Pause the local timer decrement if the HTTP sync throws an error, and resume when it reconnects. + +### 10. Permission Dialog + Immediate Failure = Broken UX +**Thoughts:** Throwing an exception in Kotlin while the Android permission dialog is still on screen is a bad bug in `wireguard_flutter`. +**Action:** We need to edit `WireguardFlutterPlugin.kt` to await the `onActivityResult` before returning to Flutter. + +### 11. 3-Second Polling Per Client — Server Scalability Issue +**Thoughts:** Polling every 3 seconds is ~33 req/sec for 100 users. Flask might struggle with this under heavy load. +**Action:** We can increase this to 10 seconds. The local Dart timer is accurate enough to cover the gap. + +### 12. `isVpnChecked` Flag — Single-Use +**Thoughts:** We actually solved most of the state restoration in our previous chat, but the native Kotlin plugin state tracking is still a bit messy. +**Action:** Minor cleanup in the Kotlin plugin. + +### 13. Server: `management_loop` Blocks on Subprocess Calls +**Thoughts:** `subprocess.run("awg show ...")` is synchronous and blocks the Python GIL. +**Action:** We can optimize the server to run this in a thread or use a timeout. + +### 14. Server: `tc filter` Cleanup Is Never Done on Expiry +**Thoughts:** This is a **massive server bug**. If a user's session expires naturally, we delete them from memory but leave their bandwidth throttling rules active in the Linux kernel forever. Over time, the server will choke on dead rules. +**Action:** We must call `remove_throttle()` and `remove_wg_peer()` in the expiry loop inside `server_hivemind_5_0.py`. + +### 15. No ProGuard/R8 Rules for AmneziaWG Native Library +**Thoughts:** Standard Android release builds strip unused code. If it strips AmneziaWG, the release APK will crash on launch. +**Action:** Add ProGuard rules for `org.amnezia.awg.**`. + +--- + +## 🟢 Polish & Best Practices + +**16-23.** The agent's points here are all solid minor improvements. We should host the Privacy Policy on GitHub Pages instead of a raw `.md` file, make `AppConfig` private, and untrack `server full picture.txt`. + +--- + +## 👤 User Experience Bugs + +The agent highlighted 16 UX bugs. Here are the most impactful ones that we should fix: + +* **UX-1 (5-Second Cooldown on Failure):** We shouldn't lock the connect button for 5 seconds if the connection fails instantly. +* **UX-3 ("TAP TO CONNECT" Always Visible):** We should change the text dynamically (e.g., "NO NETWORK"). +* **UX-4 & UX-9 (No Ad Load Feedback):** We need to prevent the user from connecting if the ad isn't loaded yet, and show them a "Loading ad..." state. +* **UX-5 (Fake "+30 mins" Snackbar):** We must only show this snackbar if the server actually extended the time. +* **UX-6 (2.5s Splash Delay):** The artificial `Future.delayed` in `intro.dart` should be removed so the app opens instantly. +* **UX-7 (Notification "0.00 KB/s"):** We should show "Idle" instead of "0.00 KB/s" to look more professional. +* **UX-10 (Timer clears instantly on Disconnect):** We should let the timer stay visible until the disconnection finishes. +* **UX-13 (No Haptic Feedback):** Adding `HapticFeedback.lightImpact()` on the connect button will make the app feel much more premium. + +--- + +### Conclusion + +The agent provided an incredibly valuable roadmap. +I recommend we tackle these in batches: +1. **Server Fixes:** Fix the `tc filter` memory leak (Critical) and the AdMob SSV / bonus time endpoints. +2. **Android Native Fixes:** Fix the `build.gradle.kts` conflict, ProGuard rules, and the Kotlin permission dialog bug. +3. **Flutter UX Polish:** Fix the fake snackbars, loading states, and haptic feedback.