Add Supa account and search changes

This commit is contained in:
Fabian Freund
2026-05-22 18:10:22 +02:00
parent 3a19865b2e
commit 51289f1266
374 changed files with 54061 additions and 5013 deletions
@@ -7,6 +7,7 @@ import android.content.Intent
import android.content.ServiceConnection
import android.os.IBinder
import android.util.Log
import androidx.core.content.ContextCompat
import eu.weblibre.flutter_tor.generated.IPtProxyController
import eu.weblibre.flutter_tor.generated.TorApi
import eu.weblibre.flutter_tor.generated.TorConfiguration
@@ -29,22 +30,25 @@ class FlutterTorPlugin : FlutterPlugin, TorApi {
}
private var context: Context? = null
private var binaryMessenger: io.flutter.plugin.common.BinaryMessenger? = null
private var torService: TorService? = null
private var serviceConnection: ServiceConnection? = null
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
// Service connection state
@Volatile
private var serviceConnected = CompletableDeferred<Unit>()
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
Log.d(TAG, "onAttachedToEngine")
context = flutterPluginBinding.applicationContext
binaryMessenger = flutterPluginBinding.binaryMessenger
// Setup Pigeon API
TorApi.setUp(flutterPluginBinding.binaryMessenger, this)
// Bind to TorService
bindTorService(flutterPluginBinding)
// Reconnect to an already-running TorService without creating a new idle instance.
bindTorService(createIfNeeded = false)
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
@@ -60,40 +64,62 @@ class FlutterTorPlugin : FlutterPlugin, TorApi {
scope.cancel()
context = null
binaryMessenger = null
}
/**
* Bind to TorService
* Bind to TorService. Idempotent — used both for initial bind and rebind on disconnect.
*/
private fun bindTorService(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
private fun bindTorService(createIfNeeded: Boolean) {
val ctx = context ?: return
val messenger = binaryMessenger ?: return
if (serviceConnection != null) return
val connection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
Log.d(TAG, "TorService connected")
val binder = service as? TorService.LocalBinder
torService = binder?.getService()
torService?.initialize(flutterPluginBinding.binaryMessenger)
torService?.initialize(messenger)
// Signal that service is connected
serviceConnected.complete(Unit)
}
override fun onServiceDisconnected(name: ComponentName?) {
Log.w(TAG, "TorService disconnected")
Log.w(TAG, "TorService disconnected — will rebind")
torService = null
// Reset connection deferred for potential reconnection
serviceConnected = CompletableDeferred()
// Drop the stale ServiceConnection reference so bindTorService() reattempts.
serviceConnection = null
scope.launch {
delay(500)
bindTorService(createIfNeeded = true)
}
}
}
serviceConnection = connection
val intent = Intent(ctx, TorService::class.java)
// Only bind to service, don't start it yet
// Service will be started when startTor() is called
ctx.bindService(intent, connection, Context.BIND_AUTO_CREATE)
val intent = Intent(ctx, TorService::class.java).apply {
if (createIfNeeded) {
action = TorService.ACTION_START
}
}
try {
if (createIfNeeded) {
ContextCompat.startForegroundService(ctx, intent)
}
val flags = if (createIfNeeded) Context.BIND_AUTO_CREATE else 0
if (!ctx.bindService(intent, connection, flags)) {
Log.w(TAG, "bindService returned false (createIfNeeded=$createIfNeeded)")
serviceConnection = null
}
} catch (e: Exception) {
Log.e(TAG, "bindService failed", e)
serviceConnection = null
}
}
/**
@@ -112,9 +138,12 @@ class FlutterTorPlugin : FlutterPlugin, TorApi {
}
/**
* Wait for service to be connected
* Wait for service to be connected. Triggers a rebind if needed.
*/
private suspend fun waitForService(): TorService {
if (serviceConnection == null) {
bindTorService(createIfNeeded = true)
}
return withTimeoutOrNull(SERVICE_CONNECTION_TIMEOUT_MS) {
serviceConnected.await()
torService
@@ -30,6 +30,9 @@ class PluggableTransportManager private constructor(private val context: Context
private val SNOWFLAKE_AMP_FRONTS = listOf("www.google.com")
private const val SNOWFLAKE_ICE_SERVERS = "stun:stun.l.google.com:19302,stun:stun.antisip.com:3478,stun:stun.bluesip.net:3478,stun:stun.dus.net:3478,stun:stun.epygi.com:3478,stun:stun.sonetel.com:3478,stun:stun.uls.co.za:3478,stun:stun.voipgate.com:3478,stun:stun.voys.nl:3478"
private const val PORT_READY_TIMEOUT_MS = 10_000L
private const val PORT_READY_POLL_MS = 100L
@Volatile
private var instance: PluggableTransportManager? = null
@@ -102,60 +105,25 @@ class PluggableTransportManager private constructor(private val context: Context
try {
when (type) {
TransportType.OBFS4 -> {
val transportName = IPtProxy.Obfs4
controller.start(transportName, null) // null = no proxy
activeTransports.add(transportName)
val port = controller.port(transportName)
if (port > 0) {
ports[transportName] = port.toInt()
Log.d(TAG, "$transportName started on port $port")
}
startAndAwait(IPtProxy.Obfs4)?.let { ports[IPtProxy.Obfs4] = it }
}
TransportType.SNOWFLAKE -> {
val transportName = IPtProxy.Snowflake
configureSnowflake(useAmp = false)
controller.start(transportName, null)
activeTransports.add(transportName)
val port = controller.port(transportName)
if (port > 0) {
ports[transportName] = port.toInt()
Log.d(TAG, "$transportName started on port $port")
}
startAndAwait(IPtProxy.Snowflake)?.let { ports[IPtProxy.Snowflake] = it }
}
TransportType.SNOWFLAKE_AMP -> {
val transportName = IPtProxy.Snowflake
configureSnowflake(useAmp = true)
controller.start(transportName, null)
activeTransports.add(transportName)
val port = controller.port(transportName)
if (port > 0) {
ports[transportName] = port.toInt()
Log.d(TAG, "$transportName (AMP) started on port $port")
}
startAndAwait(IPtProxy.Snowflake)?.let { ports[IPtProxy.Snowflake] = it }
}
TransportType.MEEK, TransportType.MEEK_AZURE -> {
val transportName = IPtProxy.MeekLite
controller.start(transportName, null)
activeTransports.add(transportName)
val port = controller.port(transportName)
if (port > 0) {
ports[transportName] = port.toInt()
Log.d(TAG, "$transportName started on port $port")
}
startAndAwait(IPtProxy.MeekLite)?.let { ports[IPtProxy.MeekLite] = it }
}
TransportType.WEBTUNNEL -> {
val transportName = IPtProxy.Webtunnel
controller.start(transportName, null)
activeTransports.add(transportName)
val port = controller.port(transportName)
if (port > 0) {
ports[transportName] = port.toInt()
Log.d(TAG, "$transportName started on port $port")
}
startAndAwait(IPtProxy.Webtunnel)?.let { ports[IPtProxy.Webtunnel] = it }
}
TransportType.NONE, TransportType.CUSTOM -> {
@@ -169,6 +137,30 @@ class PluggableTransportManager private constructor(private val context: Context
return ports
}
/**
* Start a transport and poll until controller.port(name) returns a usable value.
* IPtProxy assigns the port asynchronously after `start()`; without this poll, we
* may read 0 and silently skip the ClientTransportPlugin line in torrc, causing
* tor to bootstrap with `UseBridges 1` but no transport — bootstrap stalls.
*/
private fun startAndAwait(transportName: String): Int? {
controller.start(transportName, null) // null = no proxy
activeTransports.add(transportName)
val deadline = System.currentTimeMillis() + PORT_READY_TIMEOUT_MS
while (System.currentTimeMillis() < deadline) {
val port = controller.port(transportName)
if (port > 0) {
Log.d(TAG, "$transportName started on port $port")
return port.toInt()
}
Thread.sleep(PORT_READY_POLL_MS)
}
Log.e(TAG, "$transportName failed to bind a port within ${PORT_READY_TIMEOUT_MS}ms")
throw IllegalStateException("Pluggable transport $transportName did not become ready in time")
}
/**
* Configure Snowflake-specific settings
*/
@@ -1,32 +0,0 @@
package eu.weblibre.flutter_tor
import java.net.ServerSocket
/**
* Manages random port allocation for Tor and pluggable transports
*/
object PortManager {
/**
* Find an available random port by binding to port 0
* @return Available port number
*/
fun findAvailablePort(): Int {
return ServerSocket(0).use { socket ->
socket.localPort
}
}
/**
* Check if a specific port is available
* @param port Port to check
* @return true if port is available
*/
fun isPortAvailable(port: Int): Boolean {
return try {
ServerSocket(port).use { true }
} catch (e: Exception) {
false
}
}
}
@@ -5,39 +5,36 @@ import IPtProxy.IPtProxy
import java.io.File
/**
* Generates Tor configuration (torrc) based on user settings
* Generates Tor configuration (torrc) based on user settings.
*
* Notes on what we deliberately do NOT set here:
* - DataDirectory, ControlSocket, CookieAuthentication, RunAsDaemon, CacheDirectory,
* SyslogIdentityTag — upstream org.torproject.jni.TorService passes these on the
* `tor` command line. Setting them here causes duplicate-option warnings or contradicts
* the values upstream needs (e.g. RunAsDaemon must be 0 in-process).
* - SocksPort / HTTPTunnelPort — upstream rewrites defaults-torrc on every start with
* `SOCKSPort 9050|auto` and `HTTPTunnelPort 8118|auto`. tor REJECTS mixing
* `SocksPort 0` with any other SocksPort line ("Invalid SocksPort configuration"),
* so we cannot override these from torrc. Instead we just consume what upstream
* binds — read it from the static `TorService.socksPort` field (or via
* `getInfo net/listeners/socks`).
* - DNSPort / TransPort default to 0 in tor; no need to set them.
*/
class TorConfig(private val config: TorConfiguration) {
/**
* Generate torrc file content
* @param socksPort SOCKS proxy port
* @param dataDir Tor data directory
* @param geoipFile GeoIP file (optional, for country selection)
* @param geoip6File GeoIP6 file (optional, for IPv6 country selection)
* @param transportPorts Map of transport name to port (from PluggableTransportManager)
* @return torrc content as string
*
* Note: ControlPort is NOT set in torrc. The tor-android library automatically
* uses ControlSocket (Unix domain socket) which is more secure than TCP ControlPort.
* See SECURITY_CONTROL_PORT.md for details.
*/
fun generateTorrc(
socksPort: Int,
dataDir: File,
geoipFile: File?,
geoip6File: File?,
transportPorts: Map<String, Int>
): String = buildString {
// Core Tor settings
append("# Generated torrc for flutter_tor\n")
append("SocksPort 127.0.0.1:$socksPort\n")
// ControlPort is NOT set - tor-android uses ControlSocket (Unix domain socket)
// This is more secure as it uses file permissions instead of TCP authentication
append("DataDirectory ${dataDir.absolutePath}\n")
// Start with networking disabled. Re-enabled via control port
// (setConf DisableNetwork 0) AFTER our event listener is wired up so we
// don't miss bootstrap events.
append("DisableNetwork 1\n")
append("\n")
// GeoIP files for country-based node selection
if (geoipFile != null && geoipFile.exists()) {
append("GeoIPFile ${geoipFile.absolutePath}\n")
}
@@ -46,34 +43,27 @@ class TorConfig(private val config: TorConfiguration) {
}
append("\n")
// Entry node countries
config.entryNodeCountries?.let { countries ->
if (countries.isNotBlank()) {
val formatted = formatCountries(countries)
append("EntryNodes $formatted\n")
append("EntryNodes ${formatCountries(countries)}\n")
}
}
// Exit node countries
config.exitNodeCountries?.let { countries ->
if (countries.isNotBlank()) {
val formatted = formatCountries(countries)
append("ExitNodes $formatted\n")
append("ExitNodes ${formatCountries(countries)}\n")
}
}
// Strict nodes (only use specified countries)
if (config.strictNodes == true) {
append("StrictNodes 1\n")
}
append("\n")
// Pluggable transport configuration
val transport = TransportType.fromPigeon(config.transport)
when (transport) {
TransportType.OBFS4 -> {
transportPorts[IPtProxy.Obfs4]?.let { port ->
// Validate port is valid (like Orbot does)
if (port > 0) {
append("ClientTransportPlugin ${IPtProxy.Obfs4} socks5 127.0.0.1:$port\n")
}
@@ -101,17 +91,12 @@ class TorConfig(private val config: TorConfiguration) {
}
}
TransportType.CUSTOM -> {
// Custom bridges - transport plugin defined in bridge line
// We'll try to detect and configure based on bridge lines
configureCustomTransports(transportPorts)
}
TransportType.NONE -> {
// Direct connection, no pluggable transports
}
TransportType.NONE -> {}
}
append("\n")
// Bridge configuration
if (transport != TransportType.NONE) {
val normalizedBridges = BridgeParser.normalize(config.bridgeLines)
if (normalizedBridges.isNotEmpty()) {
@@ -123,9 +108,6 @@ class TorConfig(private val config: TorConfiguration) {
}
}
// Additional Tor settings (matching Orbot's configuration)
append("# Additional settings\n")
append("RunAsDaemon 1\n")
append("AvoidDiskWrites 1\n")
append("SafeSocks 0\n")
append("TestSocks 0\n")
@@ -133,14 +115,6 @@ class TorConfig(private val config: TorConfiguration) {
append("AutomapHostsOnResolve 1\n")
append("DormantClientTimeout 10 minutes\n")
append("DormantCanceledByStartup 1\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")
}
/**
@@ -155,15 +129,11 @@ class TorConfig(private val config: TorConfiguration) {
.split(",")
.map { it.trim().uppercase() }
.filter { it.isNotEmpty() }
.filter { it.length == 2 } // ISO 3166-1 alpha-2 codes
.filter { it.length == 2 }
return codes.joinToString(",") { "{$it}" }
}
/**
* Configure custom transports based on bridge lines
* Detects transport type from bridge lines and configures accordingly
*/
private fun StringBuilder.configureCustomTransports(transportPorts: Map<String, Int>) {
val bridgeTransports = config.bridgeLines
.mapNotNull { BridgeParser.extractTransportType(it) }
@@ -195,11 +165,6 @@ class TorConfig(private val config: TorConfiguration) {
}
}
/**
* Write torrc to file
* @param torrcFile File to write to
* @param content torrc content
*/
fun writeTorrc(torrcFile: File, content: String) {
torrcFile.parentFile?.mkdirs()
torrcFile.writeText(content)
@@ -1,11 +1,14 @@
package eu.weblibre.flutter_tor
import android.content.BroadcastReceiver
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.ServiceConnection
import android.os.IBinder
import android.util.Log
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import eu.weblibre.flutter_tor.generated.TorConfiguration
import eu.weblibre.flutter_tor.generated.TorStatus
import kotlinx.coroutines.*
@@ -27,12 +30,18 @@ class TorManager(
) {
companion object {
const val TAG = "TorManager"
private const val STATUS_OFF_TIMEOUT_MS = 10_000L
}
private val dataDir = File(context.filesDir, "tor_data")
// GeoIP files live alongside other install assets. tor-android owns its own
// DataDirectory/CacheDirectory under getDir("TorService"), so we don't define one.
private val installDir = File(context.filesDir, "tor_install")
private var torServiceConnection: ServiceConnection? = null
@Volatile
private var controlConnection: TorControlConnection? = null
@Volatile
private var torService: TorService? = null
val pluggableTransportManager = PluggableTransportManager.getInstance(context)
@@ -45,20 +54,67 @@ class TorManager(
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()
// Latch awaited by stop() — completed when upstream broadcasts STATUS_OFF.
@Volatile
private var stopSignal: CompletableDeferred<Unit>? = null
// Background bootstrap-progress poller — fallback for when NOTICE/STATUS_CLIENT
// events are silently dropped by jtorctl/tor (observed intermittently in the wild).
private var bootstrapPollJob: Job? = null
// Mirror of upstream TorService's status — its own static `currentStatus` is package-private.
@Volatile
private var lastUpstreamStatus: String = TorService.STATUS_OFF
private val statusReceiver = object : BroadcastReceiver() {
override fun onReceive(ctx: Context?, intent: Intent?) {
val status = intent?.getStringExtra(TorService.EXTRA_STATUS) ?: return
Log.d(TAG, "TorService broadcast: $status")
lastUpstreamStatus = status
if (status == TorService.STATUS_OFF) {
stopSignal?.complete(Unit)
}
}
}
private val errorReceiver = object : BroadcastReceiver() {
override fun onReceive(ctx: Context?, intent: Intent?) {
val msg = intent?.getStringExtra(Intent.EXTRA_TEXT) ?: "unknown"
Log.e(TAG, "TorService error broadcast: $msg")
logHandler.error("Tor service error: $msg")
}
}
private var receiversRegistered = false
private fun registerReceivers() {
if (receiversRegistered) return
val lbm = LocalBroadcastManager.getInstance(context)
lbm.registerReceiver(statusReceiver, IntentFilter(TorService.ACTION_STATUS))
lbm.registerReceiver(errorReceiver, IntentFilter(TorService.ACTION_ERROR))
receiversRegistered = true
}
private fun unregisterReceivers() {
if (!receiversRegistered) return
val lbm = LocalBroadcastManager.getInstance(context)
try { lbm.unregisterReceiver(statusReceiver) } catch (_: Exception) {}
try { lbm.unregisterReceiver(errorReceiver) } catch (_: Exception) {}
receiversRegistered = false
}
/**
* Start Tor with the given configuration
* @param config Tor configuration from Flutter
* @return SOCKS port
* @return SOCKS port (discovered from tor via `getInfo net/listeners/socks`)
*/
suspend fun start(config: TorConfiguration): Int = withContext(Dispatchers.IO) {
if (isRunning) {
@@ -69,66 +125,41 @@ class TorManager(
try {
logHandler.notice("Starting Tor...")
// Create directories
dataDir.mkdirs()
installDir.mkdirs()
lastUpstreamStatus = TorService.STATUS_STARTING
registerReceivers()
// Allocate random SOCKS port
// Note: We don't allocate a control port - tor-android uses ControlSocket instead
socksPort = PortManager.findAvailablePort()
Log.d(TAG, "Allocated SOCKS port: $socksPort")
Log.d(TAG, "Control connection will use ControlSocket (Unix domain socket)")
logHandler.notice("SOCKS port: $socksPort")
// Start pluggable transports if needed
val transport = TransportType.fromPigeon(config.transport)
val transportPorts = if (transport != TransportType.NONE && transport != TransportType.CUSTOM) {
logHandler.notice("Starting pluggable transport: $transport")
pluggableTransportManager.startTransport(transport)
} else if (transport == TransportType.CUSTOM) {
// For custom, we need to detect and start appropriate transports
startCustomTransports(config.bridgeLines)
} else {
emptyMap()
}
// Generate torrc
val geoipFile = geoIpManager.getGeoIpFile(installDir)
val geoip6File = geoIpManager.getGeoIp6File(installDir)
val torConfig = TorConfig(config)
val torrcContent = torConfig.generateTorrc(
socksPort = socksPort,
// controlPort removed - tor-android uses ControlSocket (Unix domain socket) for security
dataDir = dataDir,
geoipFile = geoipFile,
geoip6File = geoip6File,
transportPorts = transportPorts
)
// Write torrc to the correct location (like Orbot does)
// CRITICAL: Must use TorService.getTorrc() so TorService can find it!
// Tor reads torrc from the location upstream TorService passes via -f.
val torrcFile = TorService.getTorrc(context)
torConfig.writeTorrc(torrcFile, torrcContent)
Log.d(TAG, "Generated torrc at ${torrcFile.absolutePath}:\n$torrcContent")
// Write defaults torrc (required by tor-android)
// Set DisableNetwork 1 initially like Orbot does, will be enabled via control port
// Also disable DNSPort and TransPort (matching Orbot)
val defaultsTorrcFile = TorService.getDefaultsTorrc(context)
defaultsTorrcFile.writeText("""
DNSPort 0
TransPort 0
DisableNetwork 1
""".trimIndent())
// Note: do NOT write defaults-torrc here. Upstream's setDefaultProxyPorts()
// truncates and rewrites it on every start with `SOCKSPort/HTTPTunnelPort auto`.
// We neutralise those listeners with `SOCKSPort 0` / `HTTPTunnelPort 0` in
// the regular torrc instead (see TorConfig.kt).
// 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()
socksPort
@@ -136,13 +167,11 @@ class TorManager(
Log.e(TAG, "Failed to start Tor", e)
logHandler.error("Failed to start Tor: ${e.message}")
cleanup()
unregisterReceivers()
throw e
}
}
/**
* Start custom transports based on bridge lines
*/
private fun startCustomTransports(bridgeLines: List<String>): Map<String, Int> {
val transports = bridgeLines
.mapNotNull { BridgeParser.extractTransportType(it) }
@@ -168,8 +197,7 @@ class TorManager(
}
/**
* Start the native TorService and bind to it
* TorService will automatically use the torrc written to TorService.getTorrc(context)
* Start upstream tor-android TorService and bind to it.
*/
private suspend fun startTorService() = suspendCancellableCoroutine<Unit> { continuation ->
val connection = object : ServiceConnection {
@@ -178,28 +206,48 @@ class TorManager(
val binder = service as? TorService.LocalBinder
torService = binder?.service
// Wait for control connection to be available
// Wait for control connection to be available AND for TorService's
// own controlPortThread to have finished its setup (auth + addRawEventListener
// + setEvents). TorService.torControlConnection becomes non-null immediately
// after `new TorControlConnection(...)`, but TorService then calls
// setEvents([EVENT_STATUS_CLIENT]) — if we race and call our setEvents first,
// TorService overwrites it and we stop receiving NOTICE/BW/CIRC events.
// TorService.socksPort is set AFTER its setEvents call, so polling for it
// gives us a reliable "TorService is done initializing the control port" signal.
scope.launch {
var conn: TorControlConnection? = null
var attempts = 0
while (conn == null && attempts < 60) { // 30 seconds timeout
while ((conn == null || TorService.socksPort == -1) && attempts < 60) {
delay(500)
conn = torService?.torControlConnection
attempts++
// Bail early if upstream gave up (typically due to a torrc
// parse error caught by `tor --verify-config`).
if (lastUpstreamStatus == TorService.STATUS_OFF ||
lastUpstreamStatus == TorService.STATUS_STOPPING) {
break
}
}
if (conn != null) {
// Wait an additional second before setting up event listener
// This matches Orbot's behavior and ensures Tor is fully initialized
delay(1000)
if (conn != null && TorService.socksPort != -1) {
controlConnection = conn
setupControlConnection(conn)
if (continuation.isActive) {
continuation.resume(Unit)
try {
setupControlConnection(conn)
if (continuation.isActive) {
continuation.resume(Unit)
}
} catch (e: Exception) {
if (continuation.isActive) {
continuation.resumeWithException(e)
}
}
} else {
val error = Exception("Failed to get control connection after 30 seconds")
val error = Exception(
"Failed to get fully-initialized control connection " +
"(conn=${conn != null}, torServiceSocksPort=${TorService.socksPort}, " +
"upstreamStatus=$lastUpstreamStatus). " +
"If upstreamStatus is STOPPING/OFF, tor likely rejected the torrc — check logcat for TorService."
)
if (continuation.isActive) {
continuation.resumeWithException(error)
}
@@ -220,7 +268,6 @@ class TorManager(
val intent = Intent(context, org.torproject.jni.TorService::class.java)
try {
// Start the service first (like Orbot does) before binding
context.startService(intent)
context.bindService(intent, connection, Context.BIND_AUTO_CREATE)
} catch (e: Exception) {
@@ -231,26 +278,20 @@ class TorManager(
}
/**
* Setup control connection and event listeners
* This follows Orbot's approach: query ports first, then set up events, then enable network
* Setup control connection and event listeners.
* Order: discover real SOCKS port → register listener → subscribe events enable network.
*/
private fun setupControlConnection(conn: TorControlConnection) {
try {
// 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)
// Wire up event listener BEFORE enabling network so we don't miss bootstrap
// notices. Created as a field to keep a strong reference (avoids GC).
torEventListener = TorEventListener()
conn.addRawEventListener(torEventListener)
// Subscribe to events (matching Orbot's event subscriptions)
// EVENT_STATUS_CLIENT must be included — upstream TorService relies on it
// internally to detect circuit-established and broadcast STATUS_ON.
conn.setEvents(listOf(
TorControlCommands.EVENT_OR_CONN_STATUS,
TorControlCommands.EVENT_CIRCUIT_STATUS,
@@ -259,26 +300,53 @@ class TorManager(
TorControlCommands.EVENT_ERR_MSG,
TorControlCommands.EVENT_BANDWIDTH_USED,
TorControlCommands.EVENT_NEW_DESC,
TorControlCommands.EVENT_ADDRMAP
TorControlCommands.EVENT_ADDRMAP,
TorControlCommands.EVENT_STATUS_CLIENT
))
Log.d(TAG, "Control connection setup complete, enabling network")
// Replace upstream's default SOCKS/HTTP listeners with our own. Upstream's
// defaults-torrc binds `SOCKSPort 9050|auto` and `HTTPTunnelPort 8118|auto`;
// a well-known port lets other apps on the device probe / use our SOCKS proxy,
// so we want a random one. We can't disable these in torrc (tor rejects mixing
// `SocksPort 0` with other SocksPort lines), so SETCONF here:
// - SETCONF replaces the listener list (closes prev, opens new).
// - `auto` is a bare-port token; SocksPort defaults to binding 127.0.0.1.
// - SETCONF is recorded immediately, but listeners only physically bind
// once DisableNetwork=0 (see GoLog: "DisableNetwork is set. ... Shutting
// down all existing connections.") — so we read the port back AFTER
// enabling network below.
try {
conn.setConf("HTTPTunnelPort", "0")
} catch (e: Exception) {
Log.w(TAG, "Could not disable HTTPTunnelPort", e)
}
conn.setConf("SocksPort", "auto")
// Set isRunning=true BEFORE enabling network so status is consistent
// when bootstrap events start arriving
// 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
Log.d(TAG, "Control connection setup complete, enabling network")
conn.setConf("DisableNetwork", "0")
// Now that listeners are physically bound, discover the random port tor
// chose. Poll briefly — binding takes a few hundred ms after enabling net.
socksPort = awaitSocksListener(conn)
if (socksPort <= 0) {
throw IllegalStateException("Failed to discover SOCKS listener after enabling network")
}
Log.d(TAG, "Bound random SOCKS port: $socksPort")
logHandler.notice("SOCKS port: $socksPort")
sendStatusUpdate()
startBootstrapPoller(conn)
} 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
}
@@ -287,6 +355,74 @@ class TorManager(
}
}
/**
* Poll `getInfo("status/bootstrap-phase")` until progress reaches 100, the
* connection is gone, or tor stops. This is a fallback for the intermittent
* issue where NOTICE / STATUS_CLIENT events are silently not delivered to
* our raw event listener even though tor accepted SETEVENTS for them.
*
* Reply format: `NOTICE BOOTSTRAP PROGRESS=85 TAG=loading_descriptors SUMMARY="..."`
*/
private fun startBootstrapPoller(conn: TorControlConnection) {
bootstrapPollJob?.cancel()
bootstrapPollJob = scope.launch {
try {
while (isActive && isRunning && controlConnection === conn) {
val info = try {
conn.getInfo("status/bootstrap-phase")
} catch (e: Exception) {
Log.w(TAG, "bootstrap-phase poll failed", e)
null
}
if (info != null) {
val progress = Regex("""PROGRESS=(\d+)""").find(info)
?.groupValues?.get(1)?.toIntOrNull() ?: -1
if (progress >= 0) {
val changed = synchronized(statusLock) {
if (progress > bootstrapProgress) {
bootstrapProgress = progress
true
} else false
}
if (changed) {
sendStatusUpdate()
if (progress == 100) {
logHandler.notice("Tor is ready!")
break
}
} else if (progress == 100) {
break
}
}
}
delay(750)
}
} finally {
Log.d(TAG, "Bootstrap poller exiting")
}
}
}
private fun awaitSocksListener(conn: TorControlConnection): Int {
val deadline = System.currentTimeMillis() + 5_000L
while (System.currentTimeMillis() < deadline) {
val port = parseSocksPort(conn.getInfo("net/listeners/socks"))
if (port > 0) return port
Thread.sleep(100)
}
return -1
}
/**
* Parse `net/listeners/socks` reply, e.g. `"127.0.0.1:43251"` or
* `"127.0.0.1:43251" "127.0.0.1:9050"`. Returns the first numeric port, or -1.
*/
private fun parseSocksPort(reply: String?): Int {
if (reply.isNullOrBlank()) return -1
val match = Regex("""127\.0\.0\.1:(\d+)""").find(reply) ?: return -1
return match.groupValues[1].toIntOrNull() ?: -1
}
/**
* Stop Tor and cleanup
*/
@@ -294,25 +430,32 @@ class TorManager(
Log.d(TAG, "Stopping Tor")
logHandler.notice("Stopping Tor...")
val signal = CompletableDeferred<Unit>().also { stopSignal = it }
try {
// 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
// DON'T call shutdownTor() here - let upstream's onDestroy() handle it,
// otherwise we get a broken pipe error.
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)
// Wait for upstream to broadcast STATUS_OFF (which it does after releasing
// its static runLock). Without this, the next start() can hang on runLock.lock().
val arrived = withTimeoutOrNull(STATUS_OFF_TIMEOUT_MS) { signal.await(); true } ?: false
if (!arrived) {
Log.w(TAG, "Did not receive STATUS_OFF within ${STATUS_OFF_TIMEOUT_MS}ms — proceeding anyway")
}
logHandler.notice("Tor stopped")
} catch (e: Exception) {
Log.e(TAG, "Error stopping Tor", e)
cleanup()
delay(2000)
} finally {
stopSignal = null
unregisterReceivers()
}
}
/**
* Cleanup resources
* Cleanup resources. Safe to call from any thread.
*/
private fun cleanup() {
synchronized(statusLock) {
@@ -321,47 +464,34 @@ class TorManager(
socksPort = -1
}
try {
// Unsubscribe from all events before removing listener
if (controlConnection != null) {
try {
controlConnection?.setEvents(emptyList())
} catch (e: Exception) {
// Connection may already be closed
}
}
bootstrapPollJob?.cancel()
bootstrapPollJob = null
// Remove event listener
if (torEventListener != null && controlConnection != null) {
try {
controlConnection?.removeRawEventListener(torEventListener)
} catch (e: Exception) {
// Connection may already be closed
}
val conn = controlConnection
val listener = torEventListener
if (conn != null) {
try { conn.setEvents(emptyList()) } catch (_: Exception) {}
if (listener != null) {
try { conn.removeRawEventListener(listener) } catch (_: Exception) {}
}
torEventListener = null
controlConnection = null
} catch (e: Exception) {
Log.w(TAG, "Error closing control connection", e)
}
torEventListener = null
controlConnection = null
// Service cleanup
runBlocking(Dispatchers.Main) {
try {
torServiceConnection?.let {
context.unbindService(it)
}
torServiceConnection = null
// unbindService / stopService are safe from any thread; no need to hop to Main
// (the previous runBlocking(Dispatchers.Main) wrapper risked deadlocking with
// the IO-dispatcher caller chain in FlutterTorPlugin.stopTor).
try {
torServiceConnection?.let { context.unbindService(it) }
} catch (e: Exception) {
Log.w(TAG, "Error unbinding TorService", e)
}
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)
}
try {
context.stopService(Intent(context, org.torproject.jni.TorService::class.java))
} catch (e: Exception) {
Log.w(TAG, "Error stopping TorService", e)
}
torService = null
@@ -384,9 +514,6 @@ class TorManager(
}
}
/**
* Get current Tor status
*/
fun getStatus(): TorStatus {
synchronized(statusLock) {
val status = TorStatus(
@@ -403,9 +530,6 @@ class TorManager(
}
}
/**
* Send status update to Flutter
*/
private fun sendStatusUpdate() {
logHandler.sendStatusChange(getStatus())
}
@@ -415,41 +539,44 @@ class TorManager(
*/
private inner class TorEventListener : RawEventListener {
override fun onEvent(eventType: String, eventData: String) {
// Handle bootstrap progress (comes in NOTICE events)
if (eventData.contains("Bootstrapped")) {
val progress = extractBootstrapProgress(eventData)
if (progress >= 0) {
synchronized(statusLock) {
bootstrapProgress = progress
}
sendStatusUpdate()
Log.d(TAG, "ReceivedData: $eventType: $eventData")
// Bootstrap progress can arrive in two formats:
// - EVENT_NOTICE_MSG: "Bootstrapped 85% (loading_descriptors): ..."
// - EVENT_STATUS_CLIENT: "NOTICE BOOTSTRAP PROGRESS=85 TAG=... SUMMARY=..."
val progress = extractBootstrapProgress(eventData)
if (progress >= 0) {
val changed = synchronized(statusLock) {
if (progress > bootstrapProgress) {
bootstrapProgress = progress
true
} else false
}
if (changed) {
sendStatusUpdate()
if (progress == 100) {
logHandler.notice("Tor is ready!")
}
}
}
// Forward to log handler
logHandler.handleTorEvent(eventType, eventData)
}
private fun extractBootstrapProgress(eventData: String): Int {
// Extract from format like "Bootstrapped 85% (loading_descriptors): ..."
val regex = "Bootstrapped\\s+(\\d+)%".toRegex()
return regex.find(eventData)?.groupValues?.get(1)?.toIntOrNull() ?: -1
Regex("""Bootstrapped\s+(\d+)%""").find(eventData)?.let {
return it.groupValues[1].toIntOrNull() ?: -1
}
Regex("""BOOTSTRAP\s+PROGRESS=(\d+)""").find(eventData)?.let {
return it.groupValues[1].toIntOrNull() ?: -1
}
return -1
}
}
/**
* Cleanup when manager is destroyed
*/
fun destroy() {
cleanup()
unregisterReceivers()
scope.cancel()
runBlocking {
if (isRunning) {
stop()
}
}
}
}
@@ -55,6 +55,11 @@ class TorService : Service() {
Log.d(TAG, "onStartCommand: ${intent?.action}")
when (intent?.action) {
ACTION_START -> {
// startForegroundService() must promote the service promptly, before
// the later binder call reaches startTor().
startForeground(NOTIFICATION_ID, createNotification("Tor is connecting..."))
}
ACTION_STOP -> {
scope.launch {
stopTor()
@@ -66,7 +71,8 @@ class TorService : Service() {
}
}
return START_STICKY
// Tor needs explicit user start; don't auto-restart with a null intent.
return START_NOT_STICKY
}
/**
@@ -92,10 +98,7 @@ class TorService : Service() {
suspend fun startTor(config: TorConfiguration): Int {
Log.d(TAG, "Starting Tor...")
// Start foreground service with notification
// This keeps the service alive even when the app is backgrounded
startForeground(NOTIFICATION_ID, createNotification("Tor is connecting..."))
Log.d(TAG, "Started foreground service")
val manager = torManager ?: throw IllegalStateException("Service not initialized")
@@ -105,9 +108,8 @@ class TorService : Service() {
return socksPort
} catch (e: Exception) {
Log.e(TAG, "Failed to start Tor", e)
updateNotification("Failed to start Tor")
// Stop foreground on failure
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
throw e
}
}
@@ -120,9 +122,8 @@ class TorService : Service() {
torManager?.stop()
// Stop foreground service and remove notification
stopForeground(STOP_FOREGROUND_REMOVE)
Log.d(TAG, "Stopped foreground service")
stopSelf()
}
/**
@@ -199,17 +200,13 @@ class TorService : Service() {
super.onDestroy()
Log.d(TAG, "Service destroyed")
// Stop pluggable transports synchronously to release Go references.
// Previously this was scope.launch { torManager?.destroy() } followed by
// scope.cancel(), which meant the cleanup coroutine was immediately cancelled
// and never ran — causing "trackGoRef called with Java refnum" crashes when
// TorService was recreated and tried to create a new IPtProxy.Controller.
//
// Note: We only stop transports here. The PluggableTransportManager singleton
// and its Controller persist across service restarts by design.
// Full TorManager.destroy() is not called because it would deadlock
// (cleanup() uses runBlocking(Dispatchers.Main) while onDestroy runs on Main).
torManager?.pluggableTransportManager?.stopAll()
// Tear down TorManager synchronously so receivers, transports, and the
// upstream tor-android service do not survive wrapper service teardown.
try {
torManager?.destroy()
} catch (e: Exception) {
Log.w(TAG, "Error destroying TorManager", e)
}
torManager = null
logHandler = null