diff --git a/app/lib/features/tor/presentation/screens/tor_proxy.dart b/app/lib/features/tor/presentation/screens/tor_proxy.dart index 54b90714..90e9b6dd 100644 --- a/app/lib/features/tor/presentation/screens/tor_proxy.dart +++ b/app/lib/features/tor/presentation/screens/tor_proxy.dart @@ -47,7 +47,13 @@ class TorProxyScreen extends HookConsumerWidget { if (next.requireValue.isRunning != previous?.value?.isRunning || next.requireValue.bootstrapProgress != previous?.value?.bootstrapProgress) { - torPendingRequest.value = null; + if (torPendingRequest.value == true) { + if (next.requireValue.bootstrapProgress > 0) { + torPendingRequest.value = null; + } + } else { + torPendingRequest.value = null; + } } } }); @@ -58,9 +64,16 @@ class TorProxyScreen extends HookConsumerWidget { ), ); + final torIsBootstrapped = ref.watch( + torProxyServiceProvider.select( + (value) => value.value?.bootstrapProgress == 100, + ), + ); + final torIsBusy = torPendingRequest.value != null || bootstrapProgress > 0 && bootstrapProgress < 100; + final torSettings = ref.watch(torSettingsWithDefaultsProvider); useOnInitialization(() async { @@ -446,7 +459,7 @@ class TorProxyScreen extends HookConsumerWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - if (torIsBusy) + if (torPendingRequest.value != false && torIsBusy) Column( mainAxisSize: MainAxisSize.min, children: [ @@ -463,7 +476,9 @@ class TorProxyScreen extends HookConsumerWidget { ), ], ) - else if (torIsRunning) + else if (torPendingRequest.value != false && + torIsRunning && + torIsBootstrapped) Padding( padding: const EdgeInsets.only(top: 24.0), child: Row( diff --git a/packages/flutter_tor/android/src/main/kotlin/eu/weblibre/flutter_tor/FlutterTorPlugin.kt b/packages/flutter_tor/android/src/main/kotlin/eu/weblibre/flutter_tor/FlutterTorPlugin.kt index 4124eef5..f5cfe780 100644 --- a/packages/flutter_tor/android/src/main/kotlin/eu/weblibre/flutter_tor/FlutterTorPlugin.kt +++ b/packages/flutter_tor/android/src/main/kotlin/eu/weblibre/flutter_tor/FlutterTorPlugin.kt @@ -23,7 +23,9 @@ class FlutterTorPlugin : FlutterPlugin, TorApi { companion object { private const val TAG = "FlutterTorPlugin" - private const val SERVICE_CONNECTION_TIMEOUT_MS = 10000L + // Increased timeout to 60 seconds to account for native TorService binding + // which can take 30+ seconds during initial setup + private const val SERVICE_CONNECTION_TIMEOUT_MS = 60000L } private var context: Context? = null diff --git a/packages/flutter_tor/android/src/main/kotlin/eu/weblibre/flutter_tor/LogStreamHandler.kt b/packages/flutter_tor/android/src/main/kotlin/eu/weblibre/flutter_tor/LogStreamHandler.kt index f51bc8a9..77afde2d 100644 --- a/packages/flutter_tor/android/src/main/kotlin/eu/weblibre/flutter_tor/LogStreamHandler.kt +++ b/packages/flutter_tor/android/src/main/kotlin/eu/weblibre/flutter_tor/LogStreamHandler.kt @@ -47,8 +47,6 @@ class LogStreamHandler(messenger: BinaryMessenger) { * @param status Current Tor status */ fun sendStatusChange(status: TorStatus) { - Log.d(TorManager.Companion.TAG, "sendStatusChange() returning: isRunning=${status.isRunning}, socksPort=${status.socksPort}, bootstrap=${status.bootstrapProgress}") - mainHandler.post { try { torLogApi.onStatusChanged(status) { } diff --git a/packages/flutter_tor/android/src/main/kotlin/eu/weblibre/flutter_tor/TorConfig.kt b/packages/flutter_tor/android/src/main/kotlin/eu/weblibre/flutter_tor/TorConfig.kt index 66174bf1..c7304ad8 100644 --- a/packages/flutter_tor/android/src/main/kotlin/eu/weblibre/flutter_tor/TorConfig.kt +++ b/packages/flutter_tor/android/src/main/kotlin/eu/weblibre/flutter_tor/TorConfig.kt @@ -134,10 +134,10 @@ class TorConfig(private val config: TorConfiguration) { append("DormantClientTimeout 10 minutes\n") append("DormantCanceledByStartup 1\n") - // Note: DisableNetwork is set to 1 in defaults.torrc - // It will be enabled via control port after setup completes (matching Orbot) - // We DON'T set it here to avoid overriding the defaults.torrc setting - append("DisableNetwork 0\n") + // CRITICAL: Set DisableNetwork 1 to prevent bootstrap before event listener is ready + // This will be changed to 0 via control port AFTER we set up event listeners + // This matches Orbot's approach and ensures we receive all bootstrap events + append("DisableNetwork 1\n") append("Log notice stdout\n") // Log to stdout for capture append("\n") diff --git a/packages/flutter_tor/android/src/main/kotlin/eu/weblibre/flutter_tor/TorManager.kt b/packages/flutter_tor/android/src/main/kotlin/eu/weblibre/flutter_tor/TorManager.kt index 3e418b36..02bd3816 100644 --- a/packages/flutter_tor/android/src/main/kotlin/eu/weblibre/flutter_tor/TorManager.kt +++ b/packages/flutter_tor/android/src/main/kotlin/eu/weblibre/flutter_tor/TorManager.kt @@ -40,13 +40,21 @@ class TorManager( private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + // Store event listener as a field to prevent garbage collection + private var torEventListener: TorEventListener? = null + var socksPort: Int = -1 private set // Note: No controlPort - tor-android uses ControlSocket (Unix domain socket) instead // This is more secure than TCP ControlPort as it uses file permissions for access control + @Volatile private var isRunning = false + @Volatile private var bootstrapProgress = 0 + // Lock for synchronizing status updates + private val statusLock = Any() + /** * Start Tor with the given configuration * @param config Tor configuration from Flutter @@ -119,12 +127,10 @@ class TorManager( // Start TorService // Note: torrcFile is now written to the correct location via TorService.getTorrc() // so TorService will automatically find and use it + // Note: isRunning will be set to true in setupControlConnection() before network is enabled + // This ensures status is consistent when bootstrap events start arriving startTorService() - isRunning = true - logHandler.notice("Tor started successfully") - sendStatusUpdate() - socksPort } catch (e: Exception) { Log.e(TAG, "Failed to start Tor", e) @@ -190,7 +196,7 @@ class TorManager( controlConnection = conn setupControlConnection(conn) if (continuation.isActive) { - continuation.resume(Unit) {} + continuation.resume(Unit) } } else { val error = Exception("Failed to get control connection after 30 seconds") @@ -205,6 +211,7 @@ class TorManager( Log.w(TAG, "TorService disconnected") torService = null controlConnection = null + torEventListener = null } } @@ -225,11 +232,23 @@ class TorManager( /** * Setup control connection and event listeners + * This follows Orbot's approach: query ports first, then set up events, then enable network */ private fun setupControlConnection(conn: TorControlConnection) { try { - // Add event listener - conn.addRawEventListener(TorEventListener()) + // Query control connection to verify SOCKS port (like Orbot's initControlConnection) + // This also properly initializes the control connection for event delivery + try { + conn.getInfo("net/listeners/socks") + } catch (e: Exception) { + Log.w(TAG, "Could not query SOCKS port from control connection", e) + } + + logHandler.notice("Connected to Tor control port") + + // Create and store event listener instance (prevents garbage collection) + torEventListener = TorEventListener() + conn.addRawEventListener(torEventListener) // Subscribe to events (matching Orbot's event subscriptions) conn.setEvents(listOf( @@ -243,14 +262,28 @@ class TorManager( TorControlCommands.EVENT_ADDRMAP )) - // Enable network now that configuration is complete (like Orbot does) - conn.setConf("DisableNetwork", "0") + Log.d(TAG, "Control connection setup complete, enabling network") - Log.d(TAG, "Control connection setup complete") - logHandler.notice("Connected to Tor control port") + // Set isRunning=true BEFORE enabling network so status is consistent + // when bootstrap events start arriving + synchronized(statusLock) { + isRunning = true + } + logHandler.notice("Tor started successfully") + sendStatusUpdate() + + // Enable network now that configuration is complete (like Orbot does) + // This will trigger bootstrap events to start + conn.setConf("DisableNetwork", "0") } catch (e: Exception) { Log.e(TAG, "Failed to setup control connection", e) logHandler.error("Control connection error: ${e.message}") + // Reset isRunning on failure + synchronized(statusLock) { + isRunning = false + } + torEventListener = null + throw e } } @@ -262,15 +295,19 @@ class TorManager( logHandler.notice("Stopping Tor...") try { - // Shutdown Tor gracefully - controlConnection?.shutdownTor("SHUTDOWN") - delay(1000) // Give Tor time to shutdown - + // DON'T call shutdownTor() here - let the service's onDestroy() handle it + // Otherwise we get a broken pipe error when service tries to shutdown again cleanup() + + // Give the native service time to fully stop before potential restart + // This is CRITICAL to prevent binding to a stale service instance + delay(2000) + logHandler.notice("Tor stopped") } catch (e: Exception) { Log.e(TAG, "Error stopping Tor", e) cleanup() + delay(2000) } } @@ -278,32 +315,57 @@ class TorManager( * Cleanup resources */ private fun cleanup() { - isRunning = false - bootstrapProgress = 0 - socksPort = -1 + synchronized(statusLock) { + isRunning = false + bootstrapProgress = 0 + socksPort = -1 + } try { - controlConnection?.let { - // Don't shutdown again, just close + // Unsubscribe from all events before removing listener + if (controlConnection != null) { + try { + controlConnection?.setEvents(emptyList()) + } catch (e: Exception) { + // Connection may already be closed + } } + + // Remove event listener + if (torEventListener != null && controlConnection != null) { + try { + controlConnection?.removeRawEventListener(torEventListener) + } catch (e: Exception) { + // Connection may already be closed + } + } + torEventListener = null controlConnection = null } catch (e: Exception) { Log.w(TAG, "Error closing control connection", e) } - try { - torServiceConnection?.let { - context.unbindService(it) + // Service cleanup + runBlocking(Dispatchers.Main) { + try { + torServiceConnection?.let { + context.unbindService(it) + } + torServiceConnection = null + + // Small delay to ensure unbind completes before stopping service + delay(100) + + // Stop the native TorService to ensure clean restart + val intent = Intent(context, org.torproject.jni.TorService::class.java) + context.stopService(intent) + } catch (e: Exception) { + Log.w(TAG, "Error unbinding TorService", e) } - torServiceConnection = null - } catch (e: Exception) { - Log.w(TAG, "Error unbinding TorService", e) } torService = null - pluggableTransportManager.stopAll() - sendStatusUpdate() } @@ -326,18 +388,19 @@ class TorManager( * Get current Tor status */ fun getStatus(): TorStatus { - val status = TorStatus( - isRunning = isRunning, - socksPort = if (isRunning) socksPort.toLong() else null, - bootstrapProgress = bootstrapProgress.toLong(), - currentCircuit = null, // TODO: track current circuit - exitNodeCountry = null // TODO: track exit node country - ) + synchronized(statusLock) { + val status = TorStatus( + isRunning = isRunning, + socksPort = if (isRunning) socksPort.toLong() else null, + bootstrapProgress = bootstrapProgress.toLong(), + currentCircuit = null, // TODO: track current circuit + exitNodeCountry = null // TODO: track exit node country + ) - Log.d(TAG, "getStatus() returning: isRunning=$isRunning, socksPort=$socksPort, bootstrap=$bootstrapProgress") -// logHandler.sendStatusChange(status) + Log.d(TAG, "getStatus() returning: isRunning=$isRunning, socksPort=$socksPort, bootstrap=$bootstrapProgress") - return status + return status + } } /** @@ -352,13 +415,13 @@ class TorManager( */ private inner class TorEventListener : RawEventListener { override fun onEvent(eventType: String, eventData: String) { - Log.d(TAG, "Tor event: $eventType - $eventData") - // Handle bootstrap progress (comes in NOTICE events) if (eventData.contains("Bootstrapped")) { val progress = extractBootstrapProgress(eventData) if (progress >= 0) { - bootstrapProgress = progress + synchronized(statusLock) { + bootstrapProgress = progress + } sendStatusUpdate() if (progress == 100) {