@@ -16,6 +16,8 @@ import kotlinx.coroutines.sync.withLock
1616import kotlinx.coroutines.launch
1717import kotlinx.coroutines.delay
1818import kotlinx.coroutines.Job
19+ import kotlinx.coroutines.withTimeoutOrNull
20+ import kotlinx.coroutines.CompletableDeferred
1921
2022import java.net.InetSocketAddress
2123import java.util.concurrent.atomic.AtomicReference
@@ -31,6 +33,7 @@ object TorManager {
3133 private const val RESTART_DELAY_MS = 2000L // 2 seconds between stop/start
3234 private const val INACTIVITY_TIMEOUT_MS = 5000L // 5 seconds of no activity before restart
3335 private const val MAX_RETRY_ATTEMPTS = 5
36+ private const val STOP_TIMEOUT_MS = 7000L
3437
3538 private val appScope = CoroutineScope (Dispatchers .IO + SupervisorJob ())
3639
@@ -51,19 +54,24 @@ object TorManager {
5154 private enum class LifecycleState { STOPPED , STARTING , RUNNING , STOPPING }
5255 @Volatile private var lifecycleState: LifecycleState = LifecycleState .STOPPED
5356
57+ enum class TorState { OFF , STARTING , BOOTSTRAPPING , RUNNING , STOPPING , ERROR }
58+
5459 data class TorStatus (
5560 val mode : TorMode = TorMode .OFF ,
5661 val running : Boolean = false ,
57- val bootstrapPercent : Int = 0 ,
58- val lastLogLine : String = " "
62+ val bootstrapPercent : Int = 0 , // kept for backwards compatibility with UI; 0 or 100 only
63+ val lastLogLine : String = " " ,
64+ val state : TorState = TorState .OFF
5965 )
6066
6167 private val _status = MutableStateFlow (TorStatus ())
6268 val statusFlow: StateFlow <TorStatus > = _status .asStateFlow()
6369
70+ private val stateChangeDeferred = AtomicReference <CompletableDeferred <TorState >? > (null )
71+
6472 fun isProxyEnabled (): Boolean {
6573 val s = _status .value
66- return s.mode != TorMode .OFF && s.running && s.bootstrapPercent >= 100 && socksAddr != null
74+ return s.mode != TorMode .OFF && s.running && s.bootstrapPercent >= 100 && socksAddr != null && s.state == TorState . RUNNING
6775 }
6876
6977 fun init (application : Application ) {
@@ -104,9 +112,12 @@ object TorManager {
104112 TorMode .OFF -> {
105113 Log .i(TAG , " applyMode: OFF -> stopping tor" )
106114 lifecycleState = LifecycleState .STOPPING
107- stopArti()
115+ _status .value = _status .value.copy(mode = TorMode .OFF , running = false , bootstrapPercent = 0 , state = TorState .STOPPING )
116+ stopArti() // non-suspending immediate request
117+ // Best-effort wait for STOPPED before we declare OFF
118+ waitForStateTransition(target = TorState .OFF , timeoutMs = STOP_TIMEOUT_MS )
108119 socksAddr = null
109- _status .value = _status .value.copy(mode = TorMode .OFF , running = false , bootstrapPercent = 0 )
120+ _status .value = _status .value.copy(mode = TorMode .OFF , running = false , bootstrapPercent = 0 , state = TorState . OFF )
110121 currentSocksPort = DEFAULT_SOCKS_PORT
111122 bindRetryAttempts = 0
112123 lifecycleState = LifecycleState .STOPPED
@@ -124,8 +135,8 @@ object TorManager {
124135 }
125136 bindRetryAttempts = 0
126137 lifecycleState = LifecycleState .STARTING
138+ _status .value = _status .value.copy(mode = TorMode .ON , running = false , bootstrapPercent = 0 , state = TorState .STARTING )
127139 startArti(application, useDelay = false )
128- _status .value = _status .value.copy(mode = TorMode .ON )
129140 // Defer enabling proxy until bootstrap completes
130141 appScope.launch {
131142 waitUntilBootstrapped()
@@ -146,7 +157,8 @@ object TorManager {
146157
147158 private suspend fun startArti (application : Application , useDelay : Boolean = false) {
148159 try {
149- stopArtiInternal()
160+ // Ensure any previous instance is fully stopped before starting a new one
161+ stopArtiAndWait()
150162
151163 Log .i(TAG , " Starting Arti on port $currentSocksPort …" )
152164 if (useDelay) {
@@ -159,15 +171,7 @@ object TorManager {
159171 Log .i(TAG , " arti: $s " )
160172 lastLogTime.set(System .currentTimeMillis())
161173 _status .value = _status .value.copy(lastLogLine = s)
162- if (
163- s.contains(" Sufficiently bootstrapped" , ignoreCase = true ) ||
164- s.contains(" AMEx: state changed to Running" , ignoreCase = true )
165- ) {
166- _status .value = _status .value.copy(bootstrapPercent = 100 )
167- retryAttempts = 0 // Reset retry attempts on successful bootstrap
168- bindRetryAttempts = 0 // Reset bind retry attempts on successful bootstrap
169- startInactivityMonitoring()
170- }
174+ handleArtiLogLine(s)
171175 }
172176
173177 val proxy = ArtiProxy .Builder (application)
@@ -180,12 +184,13 @@ object TorManager {
180184 proxy.start()
181185 lastLogTime.set(System .currentTimeMillis())
182186
183- _status .value = _status .value.copy(running = true , bootstrapPercent = 0 )
187+ _status .value = _status .value.copy(running = true , bootstrapPercent = 0 , state = TorState . STARTING )
184188 lifecycleState = LifecycleState .RUNNING
185189 startInactivityMonitoring()
186190
187191 } catch (e: Exception ) {
188192 Log .e(TAG , " Error starting Arti on port $currentSocksPort : ${e.message} " )
193+ _status .value = _status .value.copy(state = TorState .ERROR )
189194
190195 // Check if this is a bind error
191196 val isBindError = isBindError(e)
@@ -198,7 +203,7 @@ object TorManager {
198203 } else if (isBindError) {
199204 Log .e(TAG , " Max bind retry attempts reached ($MAX_RETRY_ATTEMPTS ), giving up" )
200205 lifecycleState = LifecycleState .STOPPED
201- _status .value = _status .value.copy(running = false , bootstrapPercent = 0 )
206+ _status .value = _status .value.copy(running = false , bootstrapPercent = 0 , state = TorState . ERROR )
202207 } else {
203208 // For non-bind errors, use the existing retry mechanism
204209 scheduleRetry(application)
@@ -235,12 +240,21 @@ object TorManager {
235240 private fun stopArti () {
236241 stopArtiInternal()
237242 socksAddr = null
238- _status .value = _status .value.copy(running = false , bootstrapPercent = 0 )
243+ _status .value = _status .value.copy(running = false , bootstrapPercent = 0 , state = TorState .STOPPING )
244+ }
245+
246+ private suspend fun stopArtiAndWait (timeoutMs : Long = STOP_TIMEOUT_MS ) {
247+ // Request stop
248+ stopArtiInternal()
249+ // Wait for confirmation via logs (Stopped) or timeout
250+ waitForStateTransition(target = TorState .OFF , timeoutMs = timeoutMs)
251+ // Small grace period before relaunch to let file locks clear
252+ delay(200 )
239253 }
240254
241255 private suspend fun restartArti (application : Application ) {
242256 Log .i(TAG , " Restarting Arti (keeping SOCKS proxy enabled)..." )
243- stopArtiInternal ()
257+ stopArtiAndWait ()
244258 delay(RESTART_DELAY_MS )
245259 startArti(application, useDelay = false ) // Already delayed above
246260 }
@@ -302,23 +316,68 @@ object TorManager {
302316 retryJob = null
303317 }
304318
305- // Removed Tor resource installation: not needed for Arti
306-
307- /* *
308- * Build an execution command that works on Android 10+ where app data dirs are mounted noexec.
309- * We invoke the platform dynamic linker and pass the PIE binary path as its first arg.
310- */
311- // Removed exec command builder: not needed for Arti
312-
313319 private suspend fun waitUntilBootstrapped () {
314320 val current = _status .value
315321 if (! current.running) return
316- if (current.bootstrapPercent >= 100 ) return
317- // Suspend until we observe 100% at least once
322+ if (current.bootstrapPercent >= 100 && current.state == TorState . RUNNING ) return
323+ // Suspend until we observe RUNNING at least once
318324 while (true ) {
319- val s = statusFlow.first { it.bootstrapPercent >= 100 || ! it.running }
320- if (! s.running) return
321- if (s.bootstrapPercent >= 100 ) return
325+ val s = statusFlow.first { (it.bootstrapPercent >= 100 && it.state == TorState .RUNNING ) || ! it.running || it.state == TorState .ERROR }
326+ if (! s.running || s.state == TorState .ERROR ) return
327+ if (s.bootstrapPercent >= 100 && s.state == TorState .RUNNING ) return
328+ }
329+ }
330+
331+ private fun handleArtiLogLine (s : String ) {
332+ when {
333+ s.contains(" AMEx: state changed to Initialized" , ignoreCase = true ) -> {
334+ _status .value = _status .value.copy(state = TorState .STARTING )
335+ completeWaitersIf(TorState .STARTING )
336+ }
337+ s.contains(" AMEx: state changed to Starting" , ignoreCase = true ) -> {
338+ _status .value = _status .value.copy(state = TorState .STARTING )
339+ completeWaitersIf(TorState .STARTING )
340+ }
341+ s.contains(" Sufficiently bootstrapped; system SOCKS now functional" , ignoreCase = true ) -> {
342+ _status .value = _status .value.copy(bootstrapPercent = 100 , state = TorState .BOOTSTRAPPING )
343+ retryAttempts = 0
344+ bindRetryAttempts = 0
345+ startInactivityMonitoring()
346+ }
347+ s.contains(" AMEx: state changed to Running" , ignoreCase = true ) -> {
348+ // If we already saw Sufficiently bootstrapped, mark as RUNNING and ready.
349+ val bp = if (_status .value.bootstrapPercent >= 100 ) 100 else 100 // treat Running as ready
350+ _status .value = _status .value.copy(state = TorState .RUNNING , bootstrapPercent = bp, running = true )
351+ completeWaitersIf(TorState .RUNNING )
352+ }
353+ s.contains(" AMEx: state changed to Stopping" , ignoreCase = true ) -> {
354+ _status .value = _status .value.copy(state = TorState .STOPPING , running = false )
355+ }
356+ s.contains(" AMEx: state changed to Stopped" , ignoreCase = true ) -> {
357+ _status .value = _status .value.copy(state = TorState .OFF , running = false , bootstrapPercent = 0 )
358+ completeWaitersIf(TorState .OFF )
359+ }
360+ s.contains(" Another process has the lock on our state files" , ignoreCase = true ) -> {
361+ // Signal error; we'll likely need to wait longer before restart
362+ _status .value = _status .value.copy(state = TorState .ERROR )
363+ }
364+ }
365+ }
366+
367+ private fun completeWaitersIf (state : TorState ) {
368+ stateChangeDeferred.getAndSet(null )?.let { def ->
369+ def.complete(state)
370+ }
371+ }
372+
373+ private suspend fun waitForStateTransition (target : TorState , timeoutMs : Long ): TorState ? {
374+ val def = CompletableDeferred <TorState >()
375+ stateChangeDeferred.getAndSet(def)?.cancel()
376+ return withTimeoutOrNull(timeoutMs) {
377+ // Fast-path: if we're already there
378+ val cur = _status .value.state
379+ if (cur == target) return @withTimeoutOrNull cur
380+ def.await()
322381 }
323382 }
324383
0 commit comments