diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index df090023..2f668026 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -5,8 +5,8 @@ + - @@ -45,10 +45,13 @@ + diff --git a/android/app/src/main/kotlin/com/defyx/defyx/MainActivity.kt b/android/app/src/main/kotlin/com/defyx/defyx/MainActivity.kt index 002b461d..e594cc37 100644 --- a/android/app/src/main/kotlin/com/defyx/defyx/MainActivity.kt +++ b/android/app/src/main/kotlin/com/defyx/defyx/MainActivity.kt @@ -18,10 +18,13 @@ import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel import java.io.File import java.net.* +import java.util.concurrent.atomic.AtomicBoolean import kotlinx.coroutines.* +import kotlin.coroutines.resume private const val VPN_REQUEST_CODE = 1000 private const val TAG = "MainActivity" +private const val VPN_OPERATION_TIMEOUT_MS = 30_000L class MainActivity : FlutterActivity() { private val CHANNEL = "com.defyx.vpn" @@ -83,10 +86,20 @@ class MainActivity : FlutterActivity() { super.onCreate(savedInstanceState) val intent = Intent(this, DefyxVpnService::class.java) + DefyxVpnService.setVpnStatusListener { status -> + runOnUiThread { sendVpnStatusToFlutter(status) } + } grantNotificationPermission() startService(intent) } + override fun onDestroy() { + DefyxVpnService.setVpnStatusListener(null) + eventSink = null + clearPendingVpnResult() + super.onDestroy() + } + private suspend fun handleMethodCall(call: MethodCall, result: MethodChannel.Result) { try { when (call.method) { @@ -130,20 +143,33 @@ class MainActivity : FlutterActivity() { } } private fun connectVpn(result: MethodChannel.Result) { - pendingVpnResult = result - - // DefyxVpnService.setVpnStatusListener { status -> sendVpnStatusToFlutter(status) } - + if (pendingVpnResult != null) { + result.error("VPN_OPERATION_IN_PROGRESS", "Another VPN permission request is pending", null) + return + } val vpnIntent = VpnService.prepare(this) if (vpnIntent != null) { + pendingVpnResult = result try { startActivityForResult(vpnIntent, VPN_REQUEST_CODE) } catch (e: Exception) { + pendingVpnResult = null result.error("VPN_PERMISSION_ERROR", "Failed to request VPN permission", e.message) } } else { - DefyxVpnService.getInstance().startVpn(this) - result.success(true) + DefyxVpnService.getInstance().startVpn( + this, + onConnected = { runOnUiThread { result.success(true) } }, + onFailure = { error -> + runOnUiThread { + result.error( + "VPN_FOREGROUND_ERROR", + "Failed to start VPN foreground service", + error.message + ) + } + } + ) } } @@ -180,14 +206,59 @@ class MainActivity : FlutterActivity() { } } - private fun disconnectVpn(result: MethodChannel.Result) = - try { - DefyxVpnService.getInstance().stopVpn() - sendVpnStatusToFlutter("disconnected") - result.success(true) - } catch (e: Exception) { - result.error("VPN_STOP_ERROR", "Failed to stop VPN", e.message) + private suspend fun disconnectVpn(result: MethodChannel.Result) { + val completed = AtomicBoolean(false) + try { + withTimeout(VPN_OPERATION_TIMEOUT_MS) { + suspendCancellableCoroutine { continuation -> + fun complete(block: () -> Unit) { + if (completed.compareAndSet(false, true)) { + block() + continuation.resume(Unit) + } + } + + try { + DefyxVpnService.getInstance().stopVpn( + onComplete = { + runOnUiThread { complete { result.success(true) } } + }, + onFailure = { error -> + runOnUiThread { + complete { + result.error( + "VPN_STOP_ERROR", + "Failed to stop VPN", + error.message + ) + } + } + } + ) + } catch (e: Exception) { + complete { result.error("VPN_STOP_ERROR", "Failed to stop VPN", e.message) } + } + } } + } catch (e: TimeoutCancellationException) { + completed.set(true) + result.error("VPN_STOP_TIMEOUT", "VPN teardown timed out", e.message) + } catch (e: Exception) { + completed.set(true) + result.error("VPN_STOP_ERROR", "Failed to stop VPN", e.message) + } + } + + private fun clearPendingVpnResult() { + pendingVpnResult = null + } + + override fun onBackPressed() { + if (pendingVpnResult != null) { + clearPendingVpnResult() + } + super.onBackPressed() + } private fun getVpnStatus(result: MethodChannel.Result) = try { @@ -273,17 +344,7 @@ class MainActivity : FlutterActivity() { } private fun stopVPN(result: MethodChannel.Result) { - CoroutineScope(Dispatchers.IO).launch { - try { - DefyxVpnService.getInstance().disconnectVPN() - result.success(true) - } catch (e: Exception) { - Log.e("Stop VPN", "Stop VPN failed: ${e.message}", e) - withContext(Dispatchers.Main) { - result.error("PING_ERROR", "Failed to Stop VPN", e.localizedMessage) - } - } - } + lifecycleScope.launch { disconnectVpn(result) } } private fun getFlag(result: MethodChannel.Result) { diff --git a/android/app/src/main/kotlin/com/defyx/defyx/VpnService.kt b/android/app/src/main/kotlin/com/defyx/defyx/VpnService.kt index 323ce062..383eb290 100644 --- a/android/app/src/main/kotlin/com/defyx/defyx/VpnService.kt +++ b/android/app/src/main/kotlin/com/defyx/defyx/VpnService.kt @@ -13,8 +13,14 @@ import androidx.core.app.NotificationCompat import androidx.core.content.edit import java.io.File import kotlinx.coroutines.* +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +private const val VPN_OPERATION_TIMEOUT_MS = 30_000L class DefyxVpnService : VpnService() { + private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + companion object { private const val TAG = "DefyxVpnService" private const val NOTIFICATION_ID = 1 @@ -23,12 +29,23 @@ class DefyxVpnService : VpnService() { fun getInstance(): DefyxVpnService = instance private var vpnInterface: ParcelFileDescriptor? = null private var listener: ((String) -> Unit)? = null - private var tunnelFd = -1 - private var isServiceRunning = false - private var isVpnConnected = false + private val operationMutex = Mutex() + @Volatile private var tunnelFd = -1 + @Volatile private var tunnelFdPassedToCore = false + @Volatile private var isServiceRunning = false + @Volatile private var isVpnConnected = false private var connectionMethod: String? = "" - fun setVpnStatusListener(l: (String) -> Unit) { + private enum class VpnState { + DISCONNECTED, + CONNECTING, + CONNECTED, + DISCONNECTING + } + + private var vpnState = VpnState.DISCONNECTED + + fun setVpnStatusListener(l: ((String) -> Unit)?) { listener = l } fun notifyVpnStatus(status: String) { @@ -40,11 +57,22 @@ class DefyxVpnService : VpnService() { super.onCreate() instance = this createNotificationChannel() + if (getSharedPreferences("defyx_vpn_prefs", Context.MODE_PRIVATE) + .getBoolean("vpn_running", false)) { + log("Clearing stale VPN running state after service restart") + saveVpnState(false) + } } override fun onDestroy() { - super.onDestroy() log("VPN Service Destroyed") + try { + runBlocking(Dispatchers.IO) { disconnectVpnAndWait() } + } catch (e: Throwable) { + log("Service destroy cleanup timed out: ${e.message}") + } + serviceScope.cancel() + super.onDestroy() } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { @@ -56,6 +84,25 @@ class DefyxVpnService : VpnService() { return START_STICKY } + private suspend fun disconnectVpnAndWait() { + operationMutex.withLock { + if (vpnState == VpnState.DISCONNECTED) { + return + } + + vpnState = VpnState.DISCONNECTING + notifyVpnStatus("disconnecting") + try { + withTimeout(VPN_OPERATION_TIMEOUT_MS) { + cleanupVpn("disconnected") + } + } catch (e: Throwable) { + log("Error stopping VPN: ${e.message}") + throw e + } + } + } + private fun createNotificationChannel() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val channel = @@ -75,32 +122,26 @@ class DefyxVpnService : VpnService() { private fun startAsForeground(title: String, contentText: String) { val notification = buildNotification(title, contentText, isVpnConnected) - try { - if (Build.VERSION.SDK_INT >= 34) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + try { startForeground( NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE ) - } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - startForeground( - NOTIFICATION_ID, - notification, - ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE - ) - } else { - startForeground(NOTIFICATION_ID, notification) - } - } catch (e: Exception) { - Log.e(TAG, "Failed to start foreground: ${e.message}", e) - try { - startForeground(NOTIFICATION_ID, notification) - } catch (e2: Exception) { - Log.e(TAG, "Fallback failed: ${e2.message}", e2) + } catch (e: Exception) { + Log.e(TAG, "Failed to promote VPN service to foreground", e) + throw ForegroundPromotionException(e) } + } else { + startForeground(NOTIFICATION_ID, notification) } + isServiceRunning = true } + private class ForegroundPromotionException(cause: Throwable) : + IllegalStateException("Failed to promote VPN service to foreground", cause) + private fun updateNotification(title: String, contentText: String) { val notification = buildNotification(title, contentText, isVpnConnected) val notificationManager = @@ -158,112 +199,151 @@ class DefyxVpnService : VpnService() { return builder.build() } - fun startVpn(context: Context) { - CoroutineScope(Dispatchers.IO).launch { - Log.d(TAG, "startVpn called") - try { - notifyVpnStatus("connecting") - startAsForeground("DefyxVPN", "Connecting...") - - val builder = - Builder() - .setSession("DefyxVPN") - .addAddress("10.0.0.2", 32) - .addRoute("0.0.0.0", 0) - .addDnsServer("1.1.1.1") - .allowFamily(android.system.OsConstants.AF_INET) - .setMtu(1500) - .setBlocking(true) - .allowBypass() + fun startVpn( + context: Context, + onConnected: () -> Unit = {}, + onFailure: (Throwable) -> Unit = {} + ) { + serviceScope.launch { + operationMutex.withLock { + if (vpnState == VpnState.CONNECTED) { + onConnected() + return@withLock + } + if (vpnState == VpnState.CONNECTING || vpnState == VpnState.DISCONNECTING) { + onFailure(IllegalStateException("VPN operation already in progress")) + return@withLock + } + vpnState = VpnState.CONNECTING + notifyVpnStatus("connecting") try { - builder.addDisallowedApplication(context.packageName) - } catch (_: Exception) {} + withTimeout(VPN_OPERATION_TIMEOUT_MS) { + startAsForeground("DefyxVPN", "Connecting...") + + val builder = + Builder() + .setSession("DefyxVPN") + .addAddress("10.0.0.2", 32) + .addRoute("0.0.0.0", 0) + .addDnsServer("1.1.1.1") + .allowFamily(android.system.OsConstants.AF_INET) + .setMtu(1500) + .setBlocking(true) + .allowBypass() + + try { + builder.addDisallowedApplication(context.packageName) + } catch (_: Exception) {} - vpnInterface?.close() - vpnInterface = builder.establish() - Log.d(TAG, "vpnInterface: $vpnInterface") + vpnInterface?.close() + vpnInterface = builder.establish() + Log.d(TAG, "vpnInterface: $vpnInterface") - isVpnConnected = vpnInterface != null - withContext(Dispatchers.Main) { saveVpnState(isVpnConnected) } + if (vpnInterface == null) { + throw IllegalStateException("VPN interface could not be established") + } - if (vpnInterface != null) { - try { val fd = vpnInterface?.detachFd() ?: -1 Log.d(TAG, "Tunnel fd: $fd") - if (fd > 0) { - tunnelFd = fd - vpnInterface = null - try { - Android.startT2S(tunnelFd.toLong(), "127.0.0.1:5000") - updateNotification("DefyxVPN", "Connected by " + connectionMethod) - notifyVpnStatus("connected") - } catch (e: Exception) { - Log.e(TAG, "T2S failed: ${e.message}", e) - updateNotification("DefyxVPN", "Connection failed") - notifyVpnStatus("disconnected") - } - } else { - tunnelFd = -1 - updateNotification("DefyxVPN", "Connection failed") - notifyVpnStatus("disconnected") + if (fd <= 0) { + throw IllegalStateException("VPN tunnel descriptor is invalid") } - } catch (e: Exception) { - Log.e(TAG, "detachFd failed: ${e.message}", e) - updateNotification("DefyxVPN", "Connection failed") - notifyVpnStatus("disconnected") + + tunnelFd = fd + vpnInterface = null + Android.startT2S(tunnelFd.toLong(), "127.0.0.1:5000") + tunnelFdPassedToCore = true + isVpnConnected = true + vpnState = VpnState.CONNECTED + saveVpnState(true) + updateNotification("DefyxVPN", "Connected by " + connectionMethod) + notifyVpnStatus("connected") } - } else { - Log.e(TAG, "vpnInterface is null") - updateNotification("DefyxVPN", "Connection failed") - notifyVpnStatus("disconnected") + onConnected() + } catch (e: Throwable) { + Log.e(TAG, "startVpn failed: ${e.message}", e) + withContext(NonCancellable) { cleanupVpn("disconnected") } + onFailure(e) } - } catch (e: Exception) { - Log.e(TAG, "startVpn failed: ${e.message}", e) - updateNotification("DefyxVPN", "Connection failed") - notifyVpnStatus("disconnected") - withContext(Dispatchers.Main) { saveVpnState(false) } } } } - private fun disconnectVpn() { - CoroutineScope(Dispatchers.IO).launch { + private fun disconnectVpn( + onComplete: () -> Unit = {}, + onFailure: (Throwable) -> Unit = {} + ) { + serviceScope.launch { try { - withContext(Dispatchers.Main) { - notifyVpnStatus("disconnecting") - updateNotification("DefyxVPN", "Disconnecting...") - } + disconnectVpnAndWait() + onComplete() + } catch (e: Throwable) { + log("Error stopping VPN: ${e.message}") + onFailure(e) + } + } + } + private suspend fun cleanupVpn(status: String) { + val shouldNotify = vpnState != VpnState.DISCONNECTED + try { + try { Android.stopVPN() + } catch (e: Exception) { + log("Stop VPN failed during cleanup: ${e.message}") + } - try { - vpnInterface?.close() - } catch (_: Exception) {} - vpnInterface = null - + try { stopTun2Socks() - tunnelFd = -1 - isVpnConnected = false + } catch (e: Exception) { + log("Stop T2S failed during cleanup: ${e.message}") + } + + try { + vpnInterface?.close() + } catch (e: Exception) { + log("Close VPN interface failed during cleanup: ${e.message}") + } + } finally { + vpnInterface = null + if (tunnelFd > 0 && !tunnelFdPassedToCore) { + try { + ParcelFileDescriptor.adoptFd(tunnelFd).close() + } catch (e: Exception) { + log("Close detached VPN descriptor failed during cleanup: ${e.message}") + } + } + tunnelFd = -1 + tunnelFdPassedToCore = false + isVpnConnected = false + isServiceRunning = false + connectionMethod = "" + vpnState = VpnState.DISCONNECTED + try { saveVpnState(false) + } catch (e: Exception) { + log("Persist disconnected state failed: ${e.message}") + } - withContext(Dispatchers.Main) { - notifyVpnStatus("disconnected") + if (shouldNotify) { + notifyVpnStatus(status) + } + try { stopForeground(STOP_FOREGROUND_REMOVE) val notificationManager = - getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager notificationManager.cancel(NOTIFICATION_ID) - } } catch (e: Exception) { - log("Error stopping VPN: ${e.message}") + log("Remove VPN notification failed: ${e.message}") } } } - fun stopVpn() { - disconnectVpn() + fun stopVpn(onComplete: () -> Unit = {}, onFailure: (Throwable) -> Unit = {}) { + disconnectVpn(onComplete, onFailure) } fun stopTun2Socks() { @@ -371,13 +451,23 @@ class DefyxVpnService : VpnService() { fun isTunnelRunning(): Boolean = tunnelFd > 0 override fun onTaskRemoved(rootIntent: Intent?) { - super.onTaskRemoved(rootIntent) Log.d(TAG, "Task removed") + try { + runBlocking(Dispatchers.IO) { disconnectVpnAndWait() } + } catch (e: Throwable) { + log("Task removal cleanup timed out: ${e.message}") + } + super.onTaskRemoved(rootIntent) } override fun onRevoke() { - super.onRevoke() Log.d("VPN_SERVICE", "Revoked") + try { + runBlocking(Dispatchers.IO) { disconnectVpnAndWait() } + } catch (e: Throwable) { + log("Revoke cleanup timed out: ${e.message}") + } + super.onRevoke() } private fun saveVpnState(isRunning: Boolean) { diff --git a/lib/main.dart b/lib/main.dart index 6a33e9e2..79ba367e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,15 +1,13 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; -import 'package:defyx_vpn/firebase_options.dart'; import 'package:defyx_vpn/modules/core/vpn_bridge.dart'; import 'package:defyx_vpn/shared/providers/language_provider.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter/services.dart'; -import 'package:firebase_core/firebase_core.dart'; -import 'package:firebase_crashlytics/firebase_crashlytics.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'shared/services/telemetry_consent_service.dart'; import 'app/app.dart'; void main() async { @@ -33,29 +31,8 @@ void main() async { debugPrint('Failed to set cache directory: $e'); } - // Initialize Firebase only on supported platforms (not Windows) - if (!Platform.isWindows && !Platform.isLinux) { - await Firebase.initializeApp( - name: "defyx-vpn", - options: DefaultFirebaseOptions.currentPlatform, - ); - - // Initialize Firebase Crashlytics - await FirebaseCrashlytics.instance.setCrashlyticsCollectionEnabled(true); - - // Pass all uncaught Flutter errors to Crashlytics - FlutterError.onError = (errorDetails) { - FirebaseCrashlytics.instance.recordFlutterFatalError(errorDetails); - // Also print to console in debug mode - FlutterError.presentError(errorDetails); - }; - - // Pass all uncaught asynchronous errors to Crashlytics - PlatformDispatcher.instance.onError = (error, stack) { - FirebaseCrashlytics.instance.recordError(error, stack, fatal: true); - return true; - }; - } + // Firebase and Crashlytics stay uninitialized until telemetry is granted. + await TelemetryConsentService().initialize(); // Only lock orientation on mobile devices, not on Android TV if (Platform.isAndroid || Platform.isIOS) { @@ -76,15 +53,6 @@ void main() async { final prefs = await SharedPreferences.getInstance(); final languageNotifier = LanguageNotifier(prefs); - // Set up error handler for zone errors (if not on Windows/Linux) - if (!Platform.isWindows && !Platform.isLinux) { - // Additional async error handling via runZonedGuarded - FlutterError.onError = (errorDetails) { - FirebaseCrashlytics.instance.recordFlutterFatalError(errorDetails); - FlutterError.presentError(errorDetails); - }; - } - // Run app in same zone as ensureInitialized runApp( ProviderScope( diff --git a/lib/modules/core/vpn.dart b/lib/modules/core/vpn.dart index 83900a29..50ab22a5 100644 --- a/lib/modules/core/vpn.dart +++ b/lib/modules/core/vpn.dart @@ -55,6 +55,7 @@ class VPN { StreamSubscription? _vpnSub; StreamSubscription>? _crashSub; DateTime? _connectionStartTime; + Future? _connectOperation; void _init(ProviderContainer container) { if (_initialized) return; @@ -218,7 +219,22 @@ class VPN { } } - Future _connect() async { + Future _connect() { + final operation = _connectOperation; + if (operation != null) { + return operation; + } + + final nextOperation = _connectInternal(); + _connectOperation = nextOperation; + return nextOperation.whenComplete(() { + if (identical(_connectOperation, nextOperation)) { + _connectOperation = null; + } + }); + } + + Future _connectInternal() async { final connectionNotifier = _container?.read( connectionStateProvider.notifier, ); @@ -342,12 +358,27 @@ class VPN { return; } - if (!_isReconnectMode) { - await _createTunnel(); - _isReconnectMode = true; + try { + if (!_isReconnectMode) { + await _createTunnel(); + _isReconnectMode = true; + } + } catch (e, stack) { + connectionNotifier?.setError(); + await vpnData?.disableVPN(); + try { + await _vpnBridge.disconnectVpn(); + } catch (_) {} + crashReportingService.recordVpnError( + e, + stack, + vpnState: 'tunnel_start_failed', + ); + alertService.error(); + return; } connectionNotifier?.setConnected(); - vpnData?.enableVPN(); + await vpnData?.enableVPN(); await refreshPing(); alertService.success(); @@ -398,20 +429,28 @@ class VPN { Future _stopVPN(WidgetRef ref) async { final connectionNotifier = ref.read(connectionStateProvider.notifier); connectionNotifier.setDisconnecting(); - await _vpnBridge.stopVPN(); - _clearData(ref); - connectionNotifier.setDisconnected(); + try { + await _vpnBridge.stopVPN(); + } catch (e, stack) { + crashReportingService.recordVpnError(e, stack, vpnState: 'stopping'); + } finally { + _clearData(ref); + connectionNotifier.setDisconnected(); + } } Future _disconnect(WidgetRef ref) async { final connectionNotifier = ref.read(connectionStateProvider.notifier); final vpnData = await _container?.read(vpnDataProvider.future); connectionNotifier.setDisconnecting(); - await _vpnBridge.disconnectVpn(); - _clearData(ref); - await vpnData?.disableVPN(); - connectionNotifier.setDisconnected(); - analyticsService.logVpnDisconnected(); + try { + await _vpnBridge.disconnectVpn(); + } finally { + _clearData(ref); + await vpnData?.disableVPN(); + connectionNotifier.setDisconnected(); + analyticsService.logVpnDisconnected(); + } } Future _closeTunnel({bool keepConnectionStatus = false}) async { @@ -423,15 +462,18 @@ class VPN { if (!keepConnectionStatus) { connectionNotifier?.setDisconnecting(); } - if (Platform.isIOS) { - await _vpnBridge.disconnectVpn(); - } - await vpnData?.disableVPN(); - if (!keepConnectionStatus) { - connectionNotifier?.setDisconnected(); + try { + if (Platform.isIOS || Platform.isAndroid) { + await _vpnBridge.disconnectVpn(); + } + } finally { + await vpnData?.disableVPN(); + if (!keepConnectionStatus) { + connectionNotifier?.setDisconnected(); + } + analyticsService.logVpnDisconnected(); + _isReconnectMode = false; } - analyticsService.logVpnDisconnected(); - _isReconnectMode = false; } Future _onTunnelClosed() async { @@ -440,9 +482,12 @@ class VPN { ); connectionNotifier?.setDisconnecting(); final vpnData = await _container?.read(vpnDataProvider.future); - await _vpnBridge.stopVPN(); - await vpnData?.disableVPN(); - connectionNotifier?.setDisconnected(); + try { + await _vpnBridge.stopVPN(); + } finally { + await vpnData?.disableVPN(); + connectionNotifier?.setDisconnected(); + } } Future _grantVpnPermission() async { @@ -462,7 +507,10 @@ class VPN { Future _createTunnel() async { switch (Platform.operatingSystem) { case 'android': - await _vpnBridge.connectVpn(); + final isConnected = await _vpnBridge.connectVpn(); + if (isConnected != true) { + throw StateError('Android VPN tunnel did not become ready'); + } break; case "ios": await _vpnBridge.startTun2socks(); @@ -547,10 +595,24 @@ class VPN { void _sendCoreFirebaseMessage(String message) { Map jsonData = jsonDecode(message); final title = jsonData["title"] ?? "Unknown"; + const allowedEvents = { + 'config_updated', + 'core_started', + 'core_stopped', + 'vpn_state_changed', + }; + if (title is! String || !allowedEvents.contains(title)) { + return; + } + jsonData.remove("title"); - final Map stringMap = jsonData.map( - (key, value) => MapEntry(key, value.toString()), - ); + const allowedParameters = {'status', 'version'}; + final stringMap = {}; + for (final entry in jsonData.entries) { + if (allowedParameters.contains(entry.key) && entry.value is String) { + stringMap[entry.key] = entry.value as String; + } + } analyticsService.logCoreData(title, stringMap); } } diff --git a/lib/modules/main/presentation/screens/main_screen.dart b/lib/modules/main/presentation/screens/main_screen.dart index 2b7d4a27..841e6ead 100644 --- a/lib/modules/main/presentation/screens/main_screen.dart +++ b/lib/modules/main/presentation/screens/main_screen.dart @@ -20,6 +20,7 @@ import 'package:defyx_vpn/modules/main/presentation/widgets/tips_slider_section. import 'package:defyx_vpn/shared/providers/connection_state_provider.dart'; import 'package:defyx_vpn/shared/providers/ad_readiness_coordinator.dart'; import 'package:defyx_vpn/shared/services/animation_service.dart'; +import 'package:defyx_vpn/shared/services/telemetry_consent_service.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flame/game.dart'; @@ -74,8 +75,12 @@ class _MainScreenState extends ConsumerState { // Check if privacy notice should be shown using coordinator final adReadiness = ref.read(adReadinessCoordinatorProvider); - if (adReadiness.canShowPrivacyDialog) { - _showPrivacyNoticeDialog(); + final telemetryConsent = TelemetryConsentService(); + if (adReadiness.canShowPrivacyDialog || + telemetryConsent.consent == TelemetryConsent.undecided) { + _showPrivacyNoticeDialog( + telemetryOnly: !adReadiness.canShowPrivacyDialog, + ); } _checkInitialConnectionState(); @@ -153,14 +158,16 @@ class _MainScreenState extends ConsumerState { _secretTapHandler.handleSecretTap(context); } - void _showPrivacyNoticeDialog() { - PrivacyNoticeDialog.show(context, () async { + void _showPrivacyNoticeDialog({bool telemetryOnly = false}) { + PrivacyNoticeDialog.show(context, (telemetryOptIn) async { if (ref.context.mounted) { - // 1. Prepare VPN profile - final vpnBridge = VpnBridge(); - final result = await vpnBridge.prepareVpn(); + if (!telemetryOnly) { + // 1. Prepare VPN profile + final vpnBridge = VpnBridge(); + final result = await vpnBridge.prepareVpn(); + + if (!result || !ref.context.mounted) return false; - if (result && ref.context.mounted) { // 2. Initialize VPN final vpn = VPN(ProviderScope.containerOf(ref.context)); await vpn.initVPN(); @@ -168,7 +175,7 @@ class _MainScreenState extends ConsumerState { // 3. Save settings await ref.read(settingsProvider.notifier).saveState(); - // 4. Mark privacy accepted in coordinator (replaces old scattered state) + // 4. Mark VPN/privacy acceptance in coordinator await ref .read(adReadinessCoordinatorProvider.notifier) .markPrivacyAccepted(); @@ -176,11 +183,18 @@ class _MainScreenState extends ConsumerState { if (!(Platform.isAndroid || Platform.isIOS)) { await _logic.triggerAutoConnectIfEnabled(); } - return true; } + + final telemetry = TelemetryConsentService(); + if (telemetryOptIn) { + await telemetry.grant(); + } else { + await telemetry.deny(); + } + return true; } return false; - }); + }, telemetryOnly: telemetryOnly); } @override diff --git a/lib/modules/main/presentation/widgets/ads/strategy/google_ad_strategy.dart b/lib/modules/main/presentation/widgets/ads/strategy/google_ad_strategy.dart index e0beac59..04934d54 100644 --- a/lib/modules/main/presentation/widgets/ads/strategy/google_ad_strategy.dart +++ b/lib/modules/main/presentation/widgets/ads/strategy/google_ad_strategy.dart @@ -52,6 +52,21 @@ class GoogleAdStrategy implements AdLoadingStrategy { GoogleAdStrategy({required this.backgroundColor, required this.cornerRadius}); + Future _loadAdAndStartCountdown( + Ref ref, { + required String source, + }) async { + try { + final result = await loadAd(ref: ref); + if (result.success && _nativeAd != null) { + debugPrint('⏰ $source loaded - starting countdown'); + ref.read(adsProvider.notifier).startCountdownTimer(); + } + } catch (e) { + debugPrint('❌ Error during $source load flow: $e'); + } + } + @override String get strategyName => 'Google AdMob'; @@ -126,7 +141,7 @@ class GoogleAdStrategy implements AdLoadingStrategy { } _nativeAd = null; } - + // Also dispose pre-loaded ad if exists if (_nextAd != null) { try { @@ -145,7 +160,7 @@ class GoogleAdStrategy implements AdLoadingStrategy { /// Rotate to the next pre-loaded ad (carousel pattern) void _rotateToNextAd(Ref ref) { debugPrint('πŸ”„ Rotating to next pre-loaded ad'); - + if (_nextAd == null) { debugPrint('⚠️ No pre-loaded ad available for rotation'); return; @@ -197,7 +212,7 @@ class GoogleAdStrategy implements AdLoadingStrategy { try { debugPrint('πŸ“¦ Starting pre-load of next ad'); final result = await _loadAdInstance(ref, isPreload: true); - + if (result.success && _nextAd != null) { ref.read(adsProvider.notifier).setNextAdReady(true); debugPrint('βœ… Next ad pre-loaded successfully'); @@ -215,10 +230,10 @@ class GoogleAdStrategy implements AdLoadingStrategy { Future loadAd({required Ref ref}) async { // Reset rotation count at start of new connection cycle ref.read(adsProvider.notifier).resetRotationCount(); - + // Load the first ad final result = await _loadAdInstance(ref, isPreload: false); - + // If first ad loaded successfully, start pre-loading the next one if (result.success && _nativeAd != null) { final currentRotation = ref.read(adsProvider).rotationCount; @@ -230,14 +245,17 @@ class GoogleAdStrategy implements AdLoadingStrategy { }); } } - + return result; } /// Internal method to load a NativeAd instance (for current or pre-load) - Future _loadAdInstance(Ref ref, {required bool isPreload}) async { + Future _loadAdInstance( + Ref ref, { + required bool isPreload, + }) async { final logPrefix = isPreload ? 'πŸ“¦ [PRELOAD]' : 'πŸ“± [LOAD]'; - + // CRITICAL: Wait for AdMob SDK to be initialized first try { final versionString = await MobileAds.instance.getVersionString(); @@ -310,12 +328,14 @@ class GoogleAdStrategy implements AdLoadingStrategy { if (adUnitId.isEmpty) { debugPrint('$logPrefix No ad unit ID configured for this platform'); if (!isPreload) _isLoading = false; - + if (!isPreload) { - ref.read(adsProvider.notifier).setAdLoadFailed( - errorCode: 'NO_AD_UNIT_ID', - errorMessage: 'No ad unit ID configured', - ); + ref + .read(adsProvider.notifier) + .setAdLoadFailed( + errorCode: 'NO_AD_UNIT_ID', + errorMessage: 'No ad unit ID configured', + ); } return AdLoadResult.failure( @@ -331,10 +351,12 @@ class GoogleAdStrategy implements AdLoadingStrategy { debugPrint('$logPrefix No network connectivity'); if (!isPreload) { _isLoading = false; - ref.read(adsProvider.notifier).setAdLoadFailed( - errorCode: '2', - errorMessage: 'Network unavailable', - ); + ref + .read(adsProvider.notifier) + .setAdLoadFailed( + errorCode: '2', + errorMessage: 'Network unavailable', + ); } return AdLoadResult.failure( errorCode: '2', @@ -346,7 +368,9 @@ class GoogleAdStrategy implements AdLoadingStrategy { final analytics = FirebaseAnalyticsService(); await analytics.logEvent( name: isPreload ? 'ad_preload_attempt' : 'ad_load_attempt', - parameters: {'rotation_position': ref.read(adsProvider).rotationCount.toString()}, + parameters: { + 'rotation_position': ref.read(adsProvider).rotationCount.toString(), + }, ); // Create template style @@ -388,7 +412,7 @@ class GoogleAdStrategy implements AdLoadingStrategy { listener: NativeAdListener( onAdLoaded: (ad) { debugPrint('$logPrefix Ad loaded successfully'); - + if (isPreload) { // Store in pre-load slot _nextAd = ad as NativeAd; @@ -403,7 +427,12 @@ class GoogleAdStrategy implements AdLoadingStrategy { // Log success analytics.logEvent( name: isPreload ? 'ad_preload_success' : 'ad_load_success', - parameters: {'rotation_position': ref.read(adsProvider).rotationCount.toString()}, + parameters: { + 'rotation_position': ref + .read(adsProvider) + .rotationCount + .toString(), + }, ); if (!completer.isCompleted) { @@ -411,15 +440,19 @@ class GoogleAdStrategy implements AdLoadingStrategy { } }, onAdFailedToLoad: (ad, error) { - debugPrint('$logPrefix Ad failed to load: ${error.code} - ${error.message}'); + debugPrint( + '$logPrefix Ad failed to load: ${error.code} - ${error.message}', + ); ad.dispose(); - + if (!isPreload) { _isLoading = false; - ref.read(adsProvider.notifier).setAdLoadFailed( - errorCode: error.code.toString(), - errorMessage: error.message, - ); + ref + .read(adsProvider.notifier) + .setAdLoadFailed( + errorCode: error.code.toString(), + errorMessage: error.message, + ); } // Log failure @@ -428,7 +461,10 @@ class GoogleAdStrategy implements AdLoadingStrategy { parameters: { 'error_code': error.code.toString(), 'error_message': error.message, - 'rotation_position': ref.read(adsProvider).rotationCount.toString(), + 'rotation_position': ref + .read(adsProvider) + .rotationCount + .toString(), }, ); @@ -468,9 +504,11 @@ class GoogleAdStrategy implements AdLoadingStrategy { final rotationPosition = ref.read(adsProvider).rotationCount; final revenueUsd = valueMicros / 1000000.0; final eCPM = revenueUsd * 1000; - - debugPrint('πŸ’° Ad revenue earned: \$$revenueUsd USD (eCPM: \$$eCPM, rotation: $rotationPosition)'); - + + debugPrint( + 'πŸ’° Ad revenue earned: \$$revenueUsd USD (eCPM: \$$eCPM, rotation: $rotationPosition)', + ); + analytics.logEvent( name: 'ad_revenue', parameters: { @@ -599,12 +637,7 @@ class GoogleAdStrategy implements AdLoadingStrategy { // Load fresh AdMob ad with real IP debugPrint('πŸ“± Loading fresh AdMob ad with real IP'); - loadAd(ref: ref).then((result) { - if (result.success && _nativeAd != null) { - debugPrint('⏰ Fresh AdMob ad loaded - starting countdown'); - ref.read(adsProvider.notifier).startCountdownTimer(); - } - }); + unawaited(_loadAdAndStartCountdown(ref, source: 'Fresh AdMob ad')); } // Initial disconnected state or coming from other states else { @@ -622,12 +655,7 @@ class GoogleAdStrategy implements AdLoadingStrategy { } debugPrint('πŸ“± Loading AdMob ad with real IP'); - loadAd(ref: ref).then((result) { - if (result.success && _nativeAd != null) { - debugPrint('⏰ AdMob ad loaded - starting countdown'); - ref.read(adsProvider.notifier).startCountdownTimer(); - } - }); + unawaited(_loadAdAndStartCountdown(ref, source: 'AdMob ad')); } else { debugPrint('βœ… Already have valid ad - starting countdown'); ref.read(adsProvider.notifier).startCountdownTimer(); diff --git a/lib/modules/main/presentation/widgets/ads/strategy/internal_ad_strategy.dart b/lib/modules/main/presentation/widgets/ads/strategy/internal_ad_strategy.dart index a36f3600..fbb541fb 100644 --- a/lib/modules/main/presentation/widgets/ads/strategy/internal_ad_strategy.dart +++ b/lib/modules/main/presentation/widgets/ads/strategy/internal_ad_strategy.dart @@ -10,120 +10,137 @@ import '../models/ad_load_result.dart'; import 'ad_loading_strategy.dart'; /// Strategy for loading and displaying internal/custom ads -/// +/// /// This strategy handles ads served from the app's own backend. /// Shows ads ONLY when VPN is connected (all users, including Iranian users). -/// +/// /// Behavior: /// - Connected state: Load and display internal ad with 60s countdown /// - Disconnected state: Clear ad data (GoogleAdStrategy handles disconnected for non-Iranian users) /// - Iranian users: Will see internal ads only when connected, nothing when disconnected -/// +/// /// Handles internal ads ONLY - does not manage AdMob ads. class InternalAdStrategy implements AdLoadingStrategy { bool _internalAdImageFailed = false; final FirebaseAnalyticsService _analytics = FirebaseAnalyticsService(); - + // Visual properties final Color backgroundColor; final double cornerRadius; - + InternalAdStrategy({ this.backgroundColor = const Color(0xFF19312F), this.cornerRadius = 10.0, }); - + + Future _loadConnectedInternalAd(Ref ref) async { + await Future.delayed(const Duration(milliseconds: 2500)); + debugPrint( + '⏱️ Network routing delay complete (2.5s) - loading fresh internal ad', + ); + + try { + final result = await loadAd(ref: ref); + if (result.success) { + debugPrint('⏰ Fresh internal ad loaded - starting countdown'); + ref.read(adsProvider.notifier).startCountdownTimer(); + } + } catch (e) { + debugPrint('❌ Error in delayed internal ad load flow: $e'); + } + } + @override String get strategyName => 'Internal Ads'; - + @override Future initialize(Ref ref, {OnFallbackNeeded? onFallbackNeeded}) async { // Internal ads don't need fallback callback (they are the fallback) - + // DON'T load ad on initialization - let connection state changes handle it // This prevents race conditions } - + @override - Future loadAd({ - required Ref ref, - }) async { + Future loadAd({required Ref ref}) async { try { - // Track load attempt await _analytics.logEvent( name: 'ads_internal_ad_load_attempt', parameters: {}, ); - + // Load ad data from backend final adData = await AdvertiseDirector.getRandomCustomAd(ref); final imageUrl = adData['imageUrl'] ?? ''; final clickUrl = adData['clickUrl'] ?? ''; - + if (imageUrl.isEmpty) { await _analytics.logEvent( name: 'ads_internal_ad_load_failure', parameters: {'error_code': 'NO_AD'}, ); - - ref.read(adsProvider.notifier).setAdLoadFailed( - errorCode: 'NO_AD', - errorMessage: 'No internal ads available', - ); + + ref + .read(adsProvider.notifier) + .setAdLoadFailed( + errorCode: 'NO_AD', + errorMessage: 'No internal ads available', + ); return AdLoadResult.failure( errorCode: 'NO_AD', errorMessage: 'No internal ads available', ); } - + // Validate URL format before using it (safety check for iOS network issue) if (!imageUrl.startsWith('http://') && !imageUrl.startsWith('https://')) { await _analytics.logEvent( name: 'ads_internal_ad_load_failure', parameters: {'error_code': 'INVALID_URL', 'url': imageUrl}, ); - - ref.read(adsProvider.notifier).setAdLoadFailed( - errorCode: 'INVALID_URL', - errorMessage: 'Invalid ad URL format', - ); + + ref + .read(adsProvider.notifier) + .setAdLoadFailed( + errorCode: 'INVALID_URL', + errorMessage: 'Invalid ad URL format', + ); return AdLoadResult.failure( errorCode: 'INVALID_URL', errorMessage: 'Invalid ad URL format: $imageUrl', ); } - + // Track success await _analytics.logEvent( name: 'ads_internal_ad_load_success', parameters: {}, ); - + // Reset failure flag for new ad attempt _internalAdImageFailed = false; ref.read(adsProvider.notifier).setCustomAdData(imageUrl, clickUrl); - + return const AdLoadResult.success(); } catch (e) { debugPrint('❌ Failed to load internal ad: $e'); - + await _analytics.logEvent( name: 'ads_internal_ad_load_failure', parameters: {'error': e.toString()}, ); - - ref.read(adsProvider.notifier).setAdLoadFailed( - errorCode: 'LOAD_ERROR', - errorMessage: e.toString(), - ); + + ref + .read(adsProvider.notifier) + .setAdLoadFailed(errorCode: 'LOAD_ERROR', errorMessage: e.toString()); return AdLoadResult.failure( errorCode: 'LOAD_ERROR', errorMessage: e.toString(), ); } } - + @override Widget buildAdWidget({ required BuildContext context, @@ -132,17 +149,20 @@ class InternalAdStrategy implements AdLoadingStrategy { }) { final imageUrl = state.customImageUrl ?? ''; final clickUrl = state.customClickUrl ?? ''; - + // Validate URL format before rendering (defensive check to prevent iOS file:/// error) // CRITICAL: Never pass empty or malformed URLs to Image.network on iOS // iOS incorrectly resolves empty strings as file:/// URIs causing crashes - if (imageUrl.isEmpty || (!imageUrl.startsWith('http://') && !imageUrl.startsWith('https://'))) { + if (imageUrl.isEmpty || + (!imageUrl.startsWith('http://') && !imageUrl.startsWith('https://'))) { if (imageUrl.isNotEmpty) { - debugPrint('❌ Invalid URL in buildAdWidget, refusing to render: $imageUrl'); + debugPrint( + '❌ Invalid URL in buildAdWidget, refusing to render: $imageUrl', + ); } return const SizedBox.shrink(); } - + return GestureDetector( onTap: () async { if (clickUrl.isEmpty) { @@ -153,20 +173,17 @@ class InternalAdStrategy implements AdLoadingStrategy { try { final uri = Uri.parse(clickUrl); debugPrint('πŸ”— Opening internal ad URL: $clickUrl'); - + // Track click final analytics = FirebaseAnalyticsService(); await analytics.logEvent( name: 'ads_internal_ad_clicked', parameters: {'click_url': clickUrl}, ); - + final canLaunch = await canLaunchUrl(uri); if (canLaunch) { - await launchUrl( - uri, - mode: LaunchMode.externalApplication, - ); + await launchUrl(uri, mode: LaunchMode.externalApplication); debugPrint('βœ… Internal ad URL opened successfully'); } else { debugPrint('❌ Cannot launch URL: $clickUrl'); @@ -179,10 +196,10 @@ class InternalAdStrategy implements AdLoadingStrategy { imageUrl, width: double.infinity, height: double.infinity, - fit: BoxFit.cover, // Fill entire space like AdMob ads do + fit: BoxFit.cover, // Fill entire space like AdMob ads do loadingBuilder: (context, child, loadingProgress) { if (loadingProgress == null) return child; - + // Show loading spinner on dark background (same as ad container) to prevent blink return Container( color: backgroundColor, @@ -190,7 +207,7 @@ class InternalAdStrategy implements AdLoadingStrategy { child: CircularProgressIndicator( value: loadingProgress.expectedTotalBytes != null ? loadingProgress.cumulativeBytesLoaded / - loadingProgress.expectedTotalBytes! + loadingProgress.expectedTotalBytes! : null, color: Colors.green, strokeWidth: 2.0, @@ -201,36 +218,42 @@ class InternalAdStrategy implements AdLoadingStrategy { errorBuilder: (context, error, stackTrace) { debugPrint('❌ Failed to load internal ad image from: $imageUrl'); debugPrint(' Error: $error'); - + // Set local flag and trigger state update to hide widget if (!_internalAdImageFailed) { _internalAdImageFailed = true; - + final container = ProviderScope.containerOf(context, listen: false); + // Track error - Future(() { - final container = ProviderScope.containerOf(context, listen: false); - final analytics = FirebaseAnalyticsService(); - analytics.logEvent( - name: 'ads_internal_ad_image_failure', - parameters: { - 'image_url': imageUrl, - 'error': error.toString(), - }, - ); - - // Clear all ad data so the container disappears completely - debugPrint('πŸ—‘οΈ Clearing ad data due to image load failure'); - container.read(adsProvider.notifier).clearCustomAdData(); - }); + unawaited( + Future(() async { + try { + final analytics = FirebaseAnalyticsService(); + await analytics.logEvent( + name: 'ads_internal_ad_image_failure', + parameters: { + 'image_url': imageUrl, + 'error': error.toString(), + }, + ); + + // Clear all ad data so the container disappears completely + debugPrint('πŸ—‘οΈ Clearing ad data due to image load failure'); + container.read(adsProvider.notifier).clearCustomAdData(); + } catch (e) { + debugPrint('❌ Failed to process internal ad image error: $e'); + } + }), + ); } - + // Return empty widget - parent will be hidden on next rebuild return const SizedBox.shrink(); }, ), ); } - + @override void onConnectionStateChanged({ required Ref ref, @@ -239,48 +262,43 @@ class InternalAdStrategy implements AdLoadingStrategy { required bool hasInitialized, required Function() onRefreshNeeded, }) { - debugPrint('πŸ“ InternalAdStrategy - Connection: ${previous.name} β†’ ${current.name}'); - + debugPrint( + 'πŸ“ InternalAdStrategy - Connection: ${previous.name} β†’ ${current.name}', + ); + // INTERNAL ADS: Show when connected (all users including Iranian) // When connected, load fresh internal ad and show it - if (current == ConnectionStatus.connected && previous != ConnectionStatus.connected) { - debugPrint('▢️ Connected - will load internal ad after network routing stabilizes'); - + if (current == ConnectionStatus.connected && + previous != ConnectionStatus.connected) { + debugPrint( + '▢️ Connected - will load internal ad after network routing stabilizes', + ); + // iOS FIX: Add delay to allow VPN network routing to fully establish // Without this delay, Image.network may incorrectly resolve HTTPS URLs as file:// URIs // causing "No host specified in URI file:///..." errors on first connection // Also allows time for VPN tunnel to stabilize (tun2socks, ping refresh, SSL/TLS) - Future.delayed(const Duration(milliseconds: 2500), () { - debugPrint('⏱️ Network routing delay complete (2.5s) - loading fresh internal ad'); - - // Load fresh internal ad - loadAd(ref: ref).then((result) { - if (result.success) { - debugPrint('⏰ Fresh internal ad loaded - starting countdown'); - ref.read(adsProvider.notifier).startCountdownTimer(); - } - }); - }); + unawaited(_loadConnectedInternalAd(ref)); return; } - + // When disconnecting, stop countdown and clear data (all users) // For non-Iranian users: GoogleAdStrategy will show AdMob ad // For Iranian users: Nothing will show (they don't have GoogleAdStrategy) - if (current == ConnectionStatus.disconnected && + if (current == ConnectionStatus.disconnected && previous == ConnectionStatus.connected) { debugPrint('⏸️ Disconnected - clearing internal ad'); ref.read(adsProvider.notifier).stopCountdownTimer(); ref.read(adsProvider.notifier).clearCustomAdData(); } } - + @override bool shouldLoadNewAd(AdsState state) { // Internal ads don't auto-refresh return false; } - + @override void dispose() { // Nothing to dispose for internal ads diff --git a/lib/modules/main/presentation/widgets/privacy_notice_dialog.dart b/lib/modules/main/presentation/widgets/privacy_notice_dialog.dart index ccdb3c77..83eca50b 100644 --- a/lib/modules/main/presentation/widgets/privacy_notice_dialog.dart +++ b/lib/modules/main/presentation/widgets/privacy_notice_dialog.dart @@ -5,24 +5,33 @@ import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; class PrivacyNoticeDialog extends StatefulWidget { - final Future Function() onAccept; + final Future Function(bool telemetryOptIn) onAccept; + final bool telemetryOnly; - const PrivacyNoticeDialog({super.key, required this.onAccept}); + const PrivacyNoticeDialog({ + super.key, + required this.onAccept, + this.telemetryOnly = false, + }); @override State createState() => _PrivacyNoticeDialogState(); static Future show( BuildContext context, - Future Function() onAccept, - ) { + Future Function(bool telemetryOptIn) onAccept, { + bool telemetryOnly = false, + }) { return showDialog( context: context, barrierDismissible: false, builder: (BuildContext context) { return PopScope( canPop: false, - child: PrivacyNoticeDialog(onAccept: onAccept), + child: PrivacyNoticeDialog( + onAccept: onAccept, + telemetryOnly: telemetryOnly, + ), ); }, ); @@ -31,13 +40,14 @@ class PrivacyNoticeDialog extends StatefulWidget { class _PrivacyNoticeDialogState extends State { bool _isLoading = false; + bool _telemetryOptIn = false; Future _handleGotIt() async { try { if (_isLoading) return; setState(() => _isLoading = true); - final accepted = await widget.onAccept(); + final accepted = await widget.onAccept(_telemetryOptIn); setState(() => _isLoading = false); if (accepted && mounted) { Navigator.of(context).pop(); @@ -58,15 +68,13 @@ class _PrivacyNoticeDialogState extends State { final ratio = screenWidth / baseScreenWidth; final fontSize = (16.0 * ratio).clamp(14.0, 18.0).toDouble(); - String message = - 'This app does not collect, store, or transmit any personal information to its servers.\n\n' - 'Only a small amount of non-personal data (such as your internet provider\'s name) may be stored locally on your device to improve connection performance for future sessions.\n'; + String message = widget.telemetryOnly + ? 'You previously accepted the VPN setup notice. Please choose separately whether Defyx may send diagnostic and usage telemetry.' + : 'Defyx needs your permission to install and use the VPN profile. VPN operation may process account, IP address, server, and connection information.'; if (Platform.isIOS || Platform.isAndroid) { - message += '\nBy continuing, you agree to install the VPN profile'; message += - ' and may be asked for consent to personalize ads based on your preferences'; - message += '.'; + '\nAdMob and its consent form are handled separately after this notice.'; } return Dialog( @@ -102,6 +110,35 @@ class _PrivacyNoticeDialogState extends State { height: 1.4, ), ), + SizedBox(height: 12.h), + CheckboxListTile( + contentPadding: EdgeInsets.zero, + value: _telemetryOptIn, + onChanged: _isLoading + ? null + : (value) { + setState(() => _telemetryOptIn = value ?? false); + }, + title: Text( + 'Allow telemetry', + style: TextStyle( + fontSize: fontSize, + fontFamily: 'Lato', + color: Colors.black, + fontWeight: FontWeight.w600, + ), + ), + subtitle: Text( + 'Optional: Firebase Analytics, Crashlytics, Sessions, VPN diagnostics, and Cloudflare speed-test measurements. You can revoke this choice later in Settings.', + style: TextStyle( + fontSize: fontSize * 0.82, + fontFamily: 'Lato', + color: Colors.black.withValues(alpha: 0.5), + height: 1.3, + ), + ), + controlAffinity: ListTileControlAffinity.leading, + ), SizedBox(height: 20.h), ElevatedButton( onPressed: _handleGotIt, diff --git a/lib/modules/settings/presentation/screens/settings_screen.dart b/lib/modules/settings/presentation/screens/settings_screen.dart index d48bcd77..d1f3ceeb 100644 --- a/lib/modules/settings/presentation/screens/settings_screen.dart +++ b/lib/modules/settings/presentation/screens/settings_screen.dart @@ -2,6 +2,7 @@ import 'package:defyx_vpn/modules/settings/presentation/widgets/settings_donate_ import 'package:defyx_vpn/modules/settings/presentation/widgets/settings_premium_widget.dart'; import 'package:defyx_vpn/shared/providers/connection_state_provider.dart'; import 'package:defyx_vpn/shared/layout/main_screen_background.dart'; +import 'package:defyx_vpn/shared/services/telemetry_consent_service.dart'; import 'package:defyx_vpn/l10n/app_localizations.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; @@ -26,6 +27,7 @@ class _SettingsScreenState extends ConsumerState { bool _isMiddleMouseScrolling = false; Offset _middleMouseStartPosition = Offset.zero; bool _hasAppliedLocalization = false; + bool _isUpdatingTelemetry = false; @override void initState() { @@ -361,39 +363,62 @@ class _SettingsScreenState extends ConsumerState { final settingsState = ref.watch(settingsProvider); final settingsNotifier = ref.read(settingsProvider.notifier); final groups = settingsState.groupList; + final telemetry = TelemetryConsentService(); return Column( - children: groups - .map( - (group) => SettingsGroupWidget( - key: ValueKey(group.id), - group: group, - showSeparators: true, - onToggle: (groupId, itemId) { - settingsNotifier.toggleSetting(groupId, itemId, context); - }, - onReorder: group.isDraggable - ? (oldIndex, newIndex) { - settingsNotifier.reorderItems( - group.id, - oldIndex, - newIndex, - ); - } - : null, - onReset: group.id == SettingsGroupId.connectionMethod - ? () { - settingsNotifier.resetGroupToDefault( - group.id, - context: context, - ); - } - : null, - onNavigate: (route) { - Navigator.pushNamed(context, route); - }, - ), - ) - .toList(), + children: [ + ...groups.map( + (group) => SettingsGroupWidget( + key: ValueKey(group.id), + group: group, + showSeparators: true, + onToggle: (groupId, itemId) { + settingsNotifier.toggleSetting(groupId, itemId, context); + }, + onReorder: group.isDraggable + ? (oldIndex, newIndex) { + settingsNotifier.reorderItems(group.id, oldIndex, newIndex); + } + : null, + onReset: group.id == SettingsGroupId.connectionMethod + ? () { + settingsNotifier.resetGroupToDefault( + group.id, + context: context, + ); + } + : null, + onNavigate: (route) { + Navigator.pushNamed(context, route); + }, + ), + ), + SizedBox(height: 24.h), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text( + 'Allow telemetry', + style: TextStyle(color: Colors.white, fontFamily: 'Lato'), + ), + subtitle: const Text( + 'Firebase diagnostics, VPN operational events, and Cloudflare speed-test measurements', + style: TextStyle(color: Colors.white70, fontFamily: 'Lato'), + ), + value: telemetry.isGranted, + onChanged: _isUpdatingTelemetry + ? null + : (enabled) async { + setState(() => _isUpdatingTelemetry = true); + if (enabled) { + await telemetry.grant(); + } else { + await telemetry.deny(); + } + if (mounted) { + setState(() => _isUpdatingTelemetry = false); + } + }, + ), + ], ); } } diff --git a/lib/modules/speed_test/application/services/cloudflare_logger_service.dart b/lib/modules/speed_test/application/services/cloudflare_logger_service.dart index d48c47e5..31f09a1d 100644 --- a/lib/modules/speed_test/application/services/cloudflare_logger_service.dart +++ b/lib/modules/speed_test/application/services/cloudflare_logger_service.dart @@ -1,16 +1,23 @@ import 'package:flutter/foundation.dart'; import '../../data/api/speed_test_api.dart'; import '../../models/speed_test_result.dart'; +import '../../../../shared/services/telemetry_consent_service.dart'; class CloudflareLoggerService { final SpeedTestApi api; CloudflareLoggerService(this.api); + final _telemetry = TelemetryConsentService(); Future logResults({ required String measurementId, required SpeedTestResult result, }) async { + if (!_telemetry.isCollectionEnabled) { + debugPrint('Telemetry disabled; speed-test results stay local'); + return; + } + try { final logData = { 'measId': measurementId, diff --git a/lib/shared/providers/connection_state_provider.dart b/lib/shared/providers/connection_state_provider.dart index baf39b90..c21bf3d6 100644 --- a/lib/shared/providers/connection_state_provider.dart +++ b/lib/shared/providers/connection_state_provider.dart @@ -73,21 +73,20 @@ class ConnectionStateNotifier extends StateNotifier { // Always update the UI based on the actual VPN status debugPrint('VPN status : $vpnStatus'); - // Update the state based on the VPN status from iOS - switch (state.status) { - case ConnectionStatus.analyzing: - break; - case ConnectionStatus.error: + switch (vpnStatus) { + case 'connecting': + if (state.status != ConnectionStatus.connected) { + setLoading(); + } break; - case ConnectionStatus.noInternet: + case 'connected': + setConnected(); break; - case ConnectionStatus.connected: - if (vpnStatus == "disconnected") { - debugPrint('VPN status is disconnected from case'); - setDisconnected(); - } + case 'disconnecting': + setDisconnecting(); break; - default: + case 'disconnected': + setDisconnected(); break; } } @@ -103,8 +102,12 @@ class ConnectionStateNotifier extends StateNotifier { // Save the current connection state to SharedPreferences Future _saveState() async { - // coreDown is transient β€” never persist it across launches. - if (state.status == ConnectionStatus.coreDown) return; + if (state.status == ConnectionStatus.loading || + state.status == ConnectionStatus.analyzing || + state.status == ConnectionStatus.disconnecting || + state.status == ConnectionStatus.coreDown) { + return; + } try { final prefs = await SharedPreferences.getInstance(); await prefs.setInt(_connectionStatusKey, state.status.toInt()); diff --git a/lib/shared/services/animation_service.dart b/lib/shared/services/animation_service.dart index c3185298..fef63a5e 100644 --- a/lib/shared/services/animation_service.dart +++ b/lib/shared/services/animation_service.dart @@ -30,9 +30,16 @@ class AnimationService { return shouldAnimate() ? originalDuration : Duration.zero; } - void conditionalRepeat(AnimationController controller, - {bool reverse = false}) { - if (shouldAnimate()) { + bool _canStartController(AnimationController controller) { + final duration = controller.duration; + return shouldAnimate() && duration != null && duration > Duration.zero; + } + + void conditionalRepeat( + AnimationController controller, { + bool reverse = false, + }) { + if (_canStartController(controller)) { controller.repeat(reverse: reverse); } else { controller.stop(); @@ -40,7 +47,7 @@ class AnimationService { } void conditionalForward(AnimationController controller, {double? from}) { - if (shouldAnimate()) { + if (_canStartController(controller)) { controller.forward(from: from); } else { controller.stop(); diff --git a/lib/shared/services/crash_reporting_service.dart b/lib/shared/services/crash_reporting_service.dart index a298b6c2..b38fdda1 100644 --- a/lib/shared/services/crash_reporting_service.dart +++ b/lib/shared/services/crash_reporting_service.dart @@ -3,6 +3,8 @@ import 'dart:io'; import 'package:firebase_crashlytics/firebase_crashlytics.dart'; import 'package:flutter/foundation.dart'; +import 'telemetry_consent_service.dart'; + class CrashReportingService { CrashReportingService._internal(); static final CrashReportingService _instance = @@ -10,13 +12,14 @@ class CrashReportingService { factory CrashReportingService() => _instance; FirebaseCrashlytics? _crashlytics; + final _telemetry = TelemetryConsentService(); bool get _isDesktopPlatform { return Platform.isWindows || Platform.isLinux || Platform.isMacOS; } FirebaseCrashlytics? get _crashlyticsInstance { - if (_isDesktopPlatform) return null; + if (_isDesktopPlatform || !_telemetry.isCollectionEnabled) return null; _crashlytics ??= FirebaseCrashlytics.instance; return _crashlytics; } @@ -29,7 +32,7 @@ class CrashReportingService { bool fatal = false, Iterable information = const [], }) async { - if (_isDesktopPlatform) { + if (_isDesktopPlatform || !_telemetry.isCollectionEnabled) { debugPrint('Crashlytics error (desktop): $exception'); return; } @@ -53,18 +56,15 @@ class CrashReportingService { StackTrace? stack, { String? reason, }) async { - return recordError( - exception, - stack, - reason: reason, - fatal: true, - ); + return recordError(exception, stack, reason: reason, fatal: true); } /// Record a Flutter error (from FlutterErrorDetails) Future recordFlutterFatalError(FlutterErrorDetails errorDetails) async { - if (_isDesktopPlatform) { - debugPrint('Crashlytics Flutter error (desktop): ${errorDetails.exception}'); + if (_isDesktopPlatform || !_telemetry.isCollectionEnabled) { + debugPrint( + 'Crashlytics Flutter error (desktop): ${errorDetails.exception}', + ); return; } @@ -77,7 +77,7 @@ class CrashReportingService { /// Set a custom key-value pair for debugging context Future setCustomKey(String key, Object value) async { - if (_isDesktopPlatform) return; + if (_isDesktopPlatform || !_telemetry.isCollectionEnabled) return; try { await _crashlyticsInstance?.setCustomKey(key, value); @@ -88,7 +88,7 @@ class CrashReportingService { /// Set user identifier for crash reports Future setUserId(String userId) async { - if (_isDesktopPlatform) return; + if (_isDesktopPlatform || !_telemetry.isCollectionEnabled) return; try { await _crashlyticsInstance?.setUserIdentifier(userId); @@ -99,7 +99,7 @@ class CrashReportingService { /// Log a message to Crashlytics (appears in crash reports as breadcrumb) Future log(String message) async { - if (_isDesktopPlatform) { + if (_isDesktopPlatform || !_telemetry.isCollectionEnabled) { debugPrint('Crashlytics log (desktop): $message'); return; } @@ -120,7 +120,7 @@ class CrashReportingService { String? protocol, String? connectionMethod, }) async { - if (_isDesktopPlatform) return; + if (_isDesktopPlatform || !_telemetry.isCollectionEnabled) return; try { // Set VPN context as custom keys @@ -149,7 +149,7 @@ class CrashReportingService { String errorMessage, String stackTrace, ) async { - if (_isDesktopPlatform) { + if (_isDesktopPlatform || !_telemetry.isCollectionEnabled) { debugPrint('Go panic (desktop): $functionName - $errorMessage'); return; } @@ -157,7 +157,7 @@ class CrashReportingService { try { await setCustomKey('go_panic_function', functionName); await log('Go panic in $functionName: $errorMessage'); - + // Record as non-fatal since we recovered from it await recordError( 'Go panic in $functionName: $errorMessage', diff --git a/lib/shared/services/firebase_analytics_service.dart b/lib/shared/services/firebase_analytics_service.dart index c8608936..2195bf26 100644 --- a/lib/shared/services/firebase_analytics_service.dart +++ b/lib/shared/services/firebase_analytics_service.dart @@ -3,6 +3,8 @@ import 'dart:io'; import 'package:firebase_analytics/firebase_analytics.dart'; import 'package:flutter/foundation.dart'; +import 'telemetry_consent_service.dart'; + class FirebaseAnalyticsService { FirebaseAnalyticsService._internal(); static final FirebaseAnalyticsService _instance = @@ -10,27 +12,29 @@ class FirebaseAnalyticsService { factory FirebaseAnalyticsService() => _instance; FirebaseAnalytics? _analytics; + final _telemetry = TelemetryConsentService(); bool get _isDesktopPlatform { return !(Platform.isAndroid || Platform.isIOS); } FirebaseAnalytics? get _analyticsInstance { - if (_isDesktopPlatform) return null; + if (_isDesktopPlatform || !_telemetry.isCollectionEnabled) return null; _analytics ??= FirebaseAnalytics.instance; return _analytics; } FirebaseAnalyticsObserver getAnalyticsObserver() { - if (_isDesktopPlatform) { + if (_isDesktopPlatform || !_telemetry.isCollectionEnabled) { throw UnsupportedError( - 'Firebase Analytics is not supported on desktop platforms'); + 'Firebase Analytics requires telemetry consent on a mobile platform', + ); } return FirebaseAnalyticsObserver(analytics: _analyticsInstance!); } Future logVpnConnectAttempt(String connectionMethod) async { - if (_isDesktopPlatform) return; + if (_isDesktopPlatform || !_telemetry.isCollectionEnabled) return; try { await _analyticsInstance?.logEvent( name: 'vpn_connect_attempt', @@ -42,8 +46,11 @@ class FirebaseAnalyticsService { } Future logVpnConnected( - String connectionMethod, String? server, int durationSeconds) async { - if (_isDesktopPlatform) return; + String connectionMethod, + String? server, + int durationSeconds, + ) async { + if (_isDesktopPlatform || !_telemetry.isCollectionEnabled) return; try { await _analyticsInstance?.logEvent( name: 'vpn_connected', @@ -59,8 +66,11 @@ class FirebaseAnalyticsService { } Future logVpnConnectionFailed( - String connectionMethod, String? server, int durationSeconds) async { - if (_isDesktopPlatform) return; + String connectionMethod, + String? server, + int durationSeconds, + ) async { + if (_isDesktopPlatform || !_telemetry.isCollectionEnabled) return; try { await _analyticsInstance?.logEvent( name: 'vpn_connection_failed', @@ -76,7 +86,7 @@ class FirebaseAnalyticsService { } Future logVpnDisconnected() async { - if (_isDesktopPlatform) return; + if (_isDesktopPlatform || !_telemetry.isCollectionEnabled) return; try { await _analyticsInstance?.logEvent(name: 'vpn_disconnected'); } catch (e) { @@ -85,7 +95,7 @@ class FirebaseAnalyticsService { } Future logConnectionMethodChanged(String newMethod) async { - if (_isDesktopPlatform) return; + if (_isDesktopPlatform || !_telemetry.isCollectionEnabled) return; try { await _analyticsInstance?.logEvent( name: 'connection_method_changed', @@ -97,7 +107,7 @@ class FirebaseAnalyticsService { } Future logServerSelected(String serverName) async { - if (_isDesktopPlatform) return; + if (_isDesktopPlatform || !_telemetry.isCollectionEnabled) return; try { await _analyticsInstance?.logEvent( name: 'server_selected', @@ -109,7 +119,7 @@ class FirebaseAnalyticsService { } Future setUserId(String? userId) async { - if (_isDesktopPlatform) return; + if (_isDesktopPlatform || !_telemetry.isCollectionEnabled) return; try { await _analyticsInstance?.setUserId(id: userId); } catch (e) { @@ -118,7 +128,7 @@ class FirebaseAnalyticsService { } Future logCoreData(String event, Map parameters) async { - if (_isDesktopPlatform) return; + if (_isDesktopPlatform || !_telemetry.isCollectionEnabled) return; try { await _analyticsInstance?.logEvent(name: event, parameters: parameters); } catch (e) { @@ -127,7 +137,7 @@ class FirebaseAnalyticsService { } Future setUserProperty(String name, String? value) async { - if (_isDesktopPlatform) return; + if (_isDesktopPlatform || !_telemetry.isCollectionEnabled) return; try { await _analyticsInstance?.setUserProperty(name: name, value: value); } catch (e) { @@ -140,7 +150,7 @@ class FirebaseAnalyticsService { required String name, Map? parameters, }) async { - if (_isDesktopPlatform) return; + if (_isDesktopPlatform || !_telemetry.isCollectionEnabled) return; try { await _analyticsInstance?.logEvent(name: name, parameters: parameters); } catch (e) { diff --git a/lib/shared/services/telemetry_consent_service.dart b/lib/shared/services/telemetry_consent_service.dart new file mode 100644 index 00000000..a1e18292 --- /dev/null +++ b/lib/shared/services/telemetry_consent_service.dart @@ -0,0 +1,98 @@ +import 'dart:io'; + +import 'package:firebase_analytics/firebase_analytics.dart'; +import 'package:firebase_core/firebase_core.dart'; +import 'package:firebase_crashlytics/firebase_crashlytics.dart'; +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:defyx_vpn/firebase_options.dart'; + +enum TelemetryConsent { undecided, denied, granted } + +class TelemetryConsentService { + TelemetryConsentService._internal(); + + static final TelemetryConsentService _instance = + TelemetryConsentService._internal(); + static const _storageKey = 'telemetry_consent_v1'; + static const _firebaseAppName = 'defyx-vpn'; + + factory TelemetryConsentService() => _instance; + + TelemetryConsent _consent = TelemetryConsent.undecided; + bool _firebaseInitialized = false; + Future? _initialization; + + TelemetryConsent get consent => _consent; + bool get isGranted => _consent == TelemetryConsent.granted; + bool get isCollectionEnabled => isGranted && _firebaseInitialized; + + Future initialize() { + return _initialization ??= _loadAndInitialize(); + } + + Future _loadAndInitialize() async { + final prefs = await SharedPreferences.getInstance(); + final storedConsent = prefs.getString(_storageKey); + _consent = switch (storedConsent) { + 'granted' => TelemetryConsent.granted, + 'denied' => TelemetryConsent.denied, + _ => TelemetryConsent.undecided, + }; + + if (isGranted) { + await _initializeFirebase(); + } + } + + Future grant() async { + _consent = TelemetryConsent.granted; + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_storageKey, 'granted'); + await _initializeFirebase(); + } + + Future deny() async { + _consent = TelemetryConsent.denied; + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_storageKey, 'denied'); + + if (_firebaseInitialized) { + await FirebaseAnalytics.instance.setAnalyticsCollectionEnabled(false); + await FirebaseCrashlytics.instance.setCrashlyticsCollectionEnabled(false); + } + } + + Future _initializeFirebase() async { + if (_firebaseInitialized || !(Platform.isAndroid || Platform.isIOS)) { + return; + } + + try { + await Firebase.initializeApp( + name: _firebaseAppName, + options: DefaultFirebaseOptions.currentPlatform, + ); + await FirebaseAnalytics.instance.setAnalyticsCollectionEnabled(true); + await FirebaseCrashlytics.instance.setCrashlyticsCollectionEnabled(true); + _installErrorHandlers(); + _firebaseInitialized = true; + } catch (error, stack) { + debugPrint('Failed to initialize telemetry: $error'); + debugPrint(stack.toString()); + } + } + + void _installErrorHandlers() { + FlutterError.onError = (errorDetails) { + FirebaseCrashlytics.instance.recordFlutterFatalError(errorDetails); + FlutterError.presentError(errorDetails); + }; + + PlatformDispatcher.instance.onError = (error, stack) { + FirebaseCrashlytics.instance.recordError(error, stack, fatal: true); + return true; + }; + } +} diff --git a/test/widget_test.dart b/test/widget_test.dart index 1d8126a1..070c4148 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -5,24 +5,59 @@ // gestures. You can also use WidgetTester to find child widgets in the widget // tree, read text, and verify that the values of widget properties are correct. +import 'package:defyx_vpn/modules/main/presentation/widgets/privacy_notice_dialog.dart'; +import 'package:defyx_vpn/shared/services/telemetry_consent_service.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; - -import 'package:defyx_vpn/app/app.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:shared_preferences/shared_preferences.dart'; void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { - await tester.pumpWidget(const App()); + testWidgets('telemetry is opt-in by default in the privacy notice', ( + tester, + ) async { + bool? selectedTelemetry; + + await tester.pumpWidget( + ScreenUtilInit( + designSize: const Size(375, 812), + builder: (_, __) => MaterialApp( + home: PrivacyNoticeDialog( + onAccept: (telemetryOptIn) async { + selectedTelemetry = telemetryOptIn; + return true; + }, + ), + ), + ), + ); + + expect(find.byType(CheckboxListTile), findsOneWidget); + expect( + tester.widget(find.byType(CheckboxListTile)).value, + isFalse, + ); + + await tester.tap(find.text('Got it')); + await tester.pumpAndSettle(); + + expect(selectedTelemetry, isFalse); + }); + + test('telemetry consent persists explicit choices', () async { + SharedPreferences.setMockInitialValues({}); + final telemetry = TelemetryConsentService(); + + await telemetry.initialize(); + expect(telemetry.consent, TelemetryConsent.undecided); - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); + await telemetry.grant(); + expect(telemetry.consent, TelemetryConsent.granted); - // Tap the '+' icon and trigger a frame. - await tester.tap(find.byIcon(Icons.add)); - await tester.pump(); + await telemetry.deny(); + expect(telemetry.consent, TelemetryConsent.denied); - // Verify that our counter has incremented. - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getString('telemetry_consent_v1'), 'denied'); }); }