feat: migrate Tor implementation from tor-android to Arti (Rust)

Replace Guardian Project's tor-android (C Tor) and jtorctl with
arti-mobile-ex, a Rust-based Tor implementation. This eliminates
the Android Service binding complexity in favor of an in-process
ArtiProxy object.

Key changes:
- TorService: Replace ServiceConnection to org.torproject.jni.TorService
  with direct ArtiProxy.Builder/start/stop API. Bootstrap state detected
  via log parsing (following BitChat's pattern).
- TorServiceStatus: Remove TorControlConnection field (Arti doesn't
  support jtorctl control protocol).
- RelayProxyClientConnector: Remove DORMANT/ACTIVE/NEWNYM control
  signals. Arti manages its own circuit lifecycle internally.
- Dependencies: Replace tor-android + jtorctl with arti-mobile-ex 1.2.3.

TorManager's external API (status/activePortOrNull StateFlows) and all
downstream consumers (DualHttpClientManager, TorSettings, UI) are
unchanged.

https://claude.ai/code/session_01BApgDd5udqBzMqysSRMpZu
This commit is contained in:
Claude
2026-03-31 22:00:48 +00:00
parent 1b39bf1d52
commit 11af50e0ad
5 changed files with 55 additions and 79 deletions
+1 -2
View File
@@ -372,8 +372,7 @@ dependencies {
// Kotlin serialization for the times where we need the Json tree and performance is not that important. // Kotlin serialization for the times where we need the Json tree and performance is not that important.
implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.serialization.json)
implementation libs.tor.android implementation libs.arti.mobile.ex
implementation libs.jtorctl
testImplementation libs.junit testImplementation libs.junit
testImplementation libs.mockk testImplementation libs.mockk
@@ -40,7 +40,6 @@ import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import net.freehaven.tor.control.TorControlCommands
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
class RelayProxyClientConnector( class RelayProxyClientConnector(
@@ -79,24 +78,13 @@ class RelayProxyClientConnector(
client.disconnect() client.disconnect()
} }
if (it.torStatus is TorServiceStatus.Active) { if (it.torStatus is TorServiceStatus.Active) {
try { Log.d("ManageRelayServices", "Connectivity off, Tor idle")
it.torStatus.torControlConnection?.signal(TorControlCommands.SIGNAL_DORMANT)
Log.d("ManageRelayServices", "Pausing Tor Activity")
} catch (e: Exception) {
Log.e("ManageRelayServices") { "Failed to signal Tor dormant: ${e.message}" }
}
} }
} else if (it.connectivity is ConnectivityStatus.Active && !client.isActive()) { } else if (it.connectivity is ConnectivityStatus.Active && !client.isActive()) {
Log.d("ManageRelayServices", "Connectivity On: Resuming Relay Services") Log.d("ManageRelayServices", "Connectivity On: Resuming Relay Services")
if (it.torStatus is TorServiceStatus.Active) { if (it.torStatus is TorServiceStatus.Active) {
try { Log.d("ManageRelayServices", "Connectivity resumed, Tor active")
it.torStatus.torControlConnection?.signal(TorControlCommands.SIGNAL_ACTIVE)
it.torStatus.torControlConnection?.signal(TorControlCommands.SIGNAL_NEWNYM)
Log.d("ManageRelayServices", "Resuming Tor Activity with new nym")
} catch (e: Exception) {
Log.e("ManageRelayServices") { "Failed to signal Tor active: ${e.message}" }
}
} }
// only calls this if the client is not active. Otherwise goes to the else below // only calls this if the client is not active. Otherwise goes to the else below
@@ -20,88 +20,84 @@
*/ */
package com.vitorpamplona.amethyst.ui.tor package com.vitorpamplona.amethyst.ui.tor
import android.content.ComponentName
import android.content.Context import android.content.Context
import android.content.Context.BIND_AUTO_CREATE
import android.content.Intent
import android.content.ServiceConnection
import android.os.IBinder
import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.Log
import info.guardianproject.arti.ArtiLogListener
import info.guardianproject.arti.ArtiProxy
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.launch
import org.torproject.jni.TorService
import org.torproject.jni.TorService.LocalBinder
private const val SOCKS_PORT_POLL_INTERVAL_MS = 100L private const val DEFAULT_SOCKS_PORT = 19050
private const val MAX_PORT_RETRIES = 3
class TorService( class TorService(
val context: Context, val context: Context,
) { ) {
val status = val status =
callbackFlow { callbackFlow {
Log.d("TorService", "Binding Tor Service") Log.d("TorService", "Starting Arti Tor Service")
trySend(TorServiceStatus.Connecting) trySend(TorServiceStatus.Connecting)
val currentIntent = Intent(context, TorService::class.java) var socksPort = DEFAULT_SOCKS_PORT
val serviceConnection: ServiceConnection = var artiProxy: ArtiProxy? = null
object : ServiceConnection { var started = false
override fun onServiceConnected(
name: ComponentName,
service: IBinder,
) {
launch(Dispatchers.IO) {
try {
// moved torService to a local variable, since we only need it once
val torService = (service as LocalBinder).service
while (torService.socksPort < 0) { val logListener =
delay(SOCKS_PORT_POLL_INTERVAL_MS) ArtiLogListener { logLine ->
} val text = logLine ?: return@ArtiLogListener
Log.d("TorService") { "Arti: $text" }
val active = TorServiceStatus.Active(torService.socksPort) when {
active.torControlConnection = torService.torControlConnection text.contains("Sufficiently bootstrapped", ignoreCase = true) ||
text.contains("is usable", ignoreCase = true) -> {
trySend(active) if (!started) {
Log.d("TorService") { "Tor Service Connected ${torService.socksPort}" } started = true
} catch (e: Exception) { trySend(TorServiceStatus.Active(socksPort))
Log.e("TorService") { "Tor service connection failed: ${e.message}" } Log.d("TorService") { "Arti bootstrapped on port $socksPort" }
trySend(TorServiceStatus.Off)
} }
} }
}
override fun onServiceDisconnected(name: ComponentName) { text.contains("state changed to Stopped", ignoreCase = true) -> {
Log.d("TorService", "Tor Service Disconnected") started = false
trySend(TorServiceStatus.Off) trySend(TorServiceStatus.Off)
}
} }
} }
try { var lastError: Exception? = null
context.bindService( for (attempt in 0 until MAX_PORT_RETRIES) {
currentIntent, try {
serviceConnection, artiProxy =
BIND_AUTO_CREATE, ArtiProxy
) .Builder(context.applicationContext)
} catch (e: Exception) { .setSocksPort(socksPort)
Log.e("TorService") { "Failed to bind Tor Service: ${e.message}" } .setDnsPort(socksPort + 1)
.setLogListener(logListener)
.build()
artiProxy!!.start()
lastError = null
break
} catch (e: Exception) {
lastError = e
Log.e("TorService") { "Failed to start Arti on port $socksPort (attempt ${attempt + 1}): ${e.message}" }
socksPort++
}
}
if (lastError != null) {
Log.e("TorService") { "Failed to start Arti after $MAX_PORT_RETRIES attempts" }
trySend(TorServiceStatus.Off) trySend(TorServiceStatus.Off)
} }
awaitClose { awaitClose {
Log.d("TorService", "Stopping Tor Service") Log.d("TorService", "Stopping Arti Tor Service")
try { try {
context.unbindService(serviceConnection) artiProxy?.stop()
} catch (e: Exception) { } catch (e: Exception) {
Log.d("TorService") { "Failed to unbind Tor Service: ${e.message}" } Log.d("TorService") { "Failed to stop Arti: ${e.message}" }
}
try {
context.stopService(currentIntent)
} catch (e: Exception) {
Log.d("TorService") { "Failed to stop Tor Service: ${e.message}" }
} }
trySend(TorServiceStatus.Off) trySend(TorServiceStatus.Off)
} }
@@ -20,15 +20,10 @@
*/ */
package com.vitorpamplona.amethyst.ui.tor package com.vitorpamplona.amethyst.ui.tor
import net.freehaven.tor.control.TorControlConnection
sealed class TorServiceStatus { sealed class TorServiceStatus {
data class Active( data class Active(
val port: Int, val port: Int,
) : TorServiceStatus() { ) : TorServiceStatus()
// If internal, it has control.
var torControlConnection: TorControlConnection? = null
}
object Off : TorServiceStatus() object Off : TorServiceStatus()
+2 -4
View File
@@ -25,7 +25,7 @@ fragmentKtx = "1.8.9"
gms = "4.4.4" gms = "4.4.4"
jacksonModuleKotlin = "2.21.2" jacksonModuleKotlin = "2.21.2"
javaKeyring = "1.0.4" javaKeyring = "1.0.4"
jtorctl = "0.4.5.7" artiMobileEx = "1.2.3"
junit = "4.13.2" junit = "4.13.2"
kchesslib = "1.0.5" kchesslib = "1.0.5"
kotlin = "2.3.20" kotlin = "2.3.20"
@@ -51,7 +51,6 @@ securityCryptoKtx = "1.1.0"
slf4j = "2.0.17" slf4j = "2.0.17"
spotless = "8.4.0" spotless = "8.4.0"
tarsosdsp = "2.5" tarsosdsp = "2.5"
torAndroid = "0.4.9.5.1"
translate = "17.0.3" translate = "17.0.3"
jetbrainsCompose = "1.10.3" jetbrainsCompose = "1.10.3"
unifiedpush = "3.0.10" unifiedpush = "3.0.10"
@@ -141,7 +140,7 @@ google-mlkit-language-id = { group = "com.google.mlkit", name = "language-id", v
google-mlkit-translate = { group = "com.google.mlkit", name = "translate", version.ref = "translate" } google-mlkit-translate = { group = "com.google.mlkit", name = "translate", version.ref = "translate" }
jackson-module-kotlin = { group = "com.fasterxml.jackson.module", name = "jackson-module-kotlin", version.ref = "jacksonModuleKotlin" } jackson-module-kotlin = { group = "com.fasterxml.jackson.module", name = "jackson-module-kotlin", version.ref = "jacksonModuleKotlin" }
java-keyring = { group = "com.github.javakeyring", name = "java-keyring", version.ref = "javaKeyring" } java-keyring = { group = "com.github.javakeyring", name = "java-keyring", version.ref = "javaKeyring" }
jtorctl = { module = "info.guardianproject:jtorctl", version.ref = "jtorctl" } arti-mobile-ex = { module = "info.guardianproject:arti-mobile-ex", version.ref = "artiMobileEx" }
junit = { group = "junit", name = "junit", version.ref = "junit" } junit = { group = "junit", name = "junit", version.ref = "junit" }
kchesslib = { module = "io.github.cvb941:kchesslib", version.ref = "kchesslib" } kchesslib = { module = "io.github.cvb941:kchesslib", version.ref = "kchesslib" }
kotlinx-collections-immutable = { group = "org.jetbrains.kotlinx", name = "kotlinx-collections-immutable", version.ref = "kotlinxCollectionsImmutable" } kotlinx-collections-immutable = { group = "org.jetbrains.kotlinx", name = "kotlinx-collections-immutable", version.ref = "kotlinxCollectionsImmutable" }
@@ -163,7 +162,6 @@ secp256k1-kmp-common = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp", v
secp256k1-kmp-jni-android = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-android", version.ref = "secp256k1KmpJniAndroid" } secp256k1-kmp-jni-android = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-android", version.ref = "secp256k1KmpJniAndroid" }
secp256k1-kmp-jni-jvm = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-jvm", version.ref = "secp256k1KmpJniAndroid" } secp256k1-kmp-jni-jvm = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-jvm", version.ref = "secp256k1KmpJniAndroid" }
tarsosdsp = { group = "be.tarsos.dsp", name = "core", version.ref = "tarsosdsp" } tarsosdsp = { group = "be.tarsos.dsp", name = "core", version.ref = "tarsosdsp" }
tor-android = { module = "info.guardianproject:tor-android", version.ref = "torAndroid" }
unifiedpush = { group = "com.github.UnifiedPush", name = "android-connector", version.ref = "unifiedpush" } unifiedpush = { group = "com.github.UnifiedPush", name = "android-connector", version.ref = "unifiedpush" }
vico-charts-compose = { group = "com.patrykandpatrick.vico", name = "compose", version.ref = "vico-charts-compose" } vico-charts-compose = { group = "com.patrykandpatrick.vico", name = "compose", version.ref = "vico-charts-compose" }
vico-charts-m3 = { group = "com.patrykandpatrick.vico", name = "compose-m3", version.ref = "vico-charts-compose" } vico-charts-m3 = { group = "com.patrykandpatrick.vico", name = "compose-m3", version.ref = "vico-charts-compose" }