Merge pull request #2063 from vitorpamplona/claude/migrate-to-arti-tor-voQeh

Replace Android TorService with ArtiProxy for Tor connectivity
This commit is contained in:
Vitor Pamplona
2026-04-01 14:01:05 -04:00
committed by GitHub
15 changed files with 1037 additions and 94 deletions
-3
View File
@@ -372,9 +372,6 @@ dependencies {
// Kotlin serialization for the times where we need the Json tree and performance is not that important.
implementation(libs.kotlinx.serialization.json)
implementation libs.tor.android
implementation libs.jtorctl
testImplementation libs.junit
testImplementation libs.mockk
testImplementation libs.kotlinx.coroutines.test
@@ -40,7 +40,6 @@ import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import net.freehaven.tor.control.TorControlCommands
import okhttp3.OkHttpClient
class RelayProxyClientConnector(
@@ -79,24 +78,13 @@ class RelayProxyClientConnector(
client.disconnect()
}
if (it.torStatus is TorServiceStatus.Active) {
try {
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}" }
}
Log.d("ManageRelayServices", "Connectivity off, Tor idle")
}
} else if (it.connectivity is ConnectivityStatus.Active && !client.isActive()) {
Log.d("ManageRelayServices", "Connectivity On: Resuming Relay Services")
if (it.torStatus is TorServiceStatus.Active) {
try {
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}" }
}
Log.d("ManageRelayServices", "Connectivity resumed, Tor active")
}
// only calls this if the client is not active. Otherwise goes to the else below
@@ -0,0 +1,68 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.tor
/**
* JNI bridge to the custom-built Arti native library (libarti_android.so).
*
* The native TorClient is created once via [initialize] and persists for the
* app's lifetime — its state file lock is never released until the process exits.
*
* The SOCKS proxy can be started and stopped independently via [startSocksProxy]
* and [stopSocksProxy] without affecting the TorClient.
*/
object ArtiNative {
init {
System.loadLibrary("arti_android")
}
external fun getVersion(): String
external fun setLogCallback(callback: ArtiLogCallback)
/**
* Initialize the Arti runtime and bootstrap the Tor client.
* @param dataDir Path to the app's private data directory for Arti state/cache.
* @return 0 on success, negative on error.
*/
external fun initialize(dataDir: String): Int
/**
* Start the SOCKS5 proxy on the given port.
* Can be called multiple times — stops any existing listener first.
* @return 0 on success, negative on error.
*/
external fun startSocksProxy(port: Int): Int
/**
* Stop the SOCKS5 proxy listener and release the port.
* The TorClient stays alive — no state file lock issues.
* @return 0 on success.
*/
external fun stopSocksProxy(): Int
}
/**
* Callback interface for Arti log messages from the native layer.
*/
fun interface ArtiLogCallback {
fun onLogLine(line: String)
}
@@ -58,18 +58,21 @@ class TorManager(
}.transformLatest { (torType, externalSocksPort) ->
when (torType) {
TorType.INTERNAL -> {
service.start()
emitAll(service.status)
}
TorType.OFF -> {
service.stop()
emit(TorServiceStatus.Off)
}
TorType.EXTERNAL -> {
service.stop()
if (externalSocksPort > 0) {
emit(TorServiceStatus.Active(externalSocksPort))
} else {
emitAll(service.status)
emit(TorServiceStatus.Off)
}
}
}
@@ -95,5 +98,5 @@ class TorManager(
fun isSocksReady() = status.value is TorServiceStatus.Active
fun socksPort(): Int = (status.value as? TorServiceStatus.Active)?.port ?: 9050
fun socksPort(): Int = (status.value as? TorServiceStatus.Active)?.port ?: 19050
}
@@ -20,90 +20,111 @@
*/
package com.vitorpamplona.amethyst.ui.tor
import android.content.ComponentName
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 kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.launch
import org.torproject.jni.TorService
import org.torproject.jni.TorService.LocalBinder
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.withContext
import java.io.File
import java.util.concurrent.atomic.AtomicBoolean
private const val SOCKS_PORT_POLL_INTERVAL_MS = 100L
private const val DEFAULT_SOCKS_PORT = 19050
/**
* Manages the Arti Tor client via custom JNI bindings.
*
* The native TorClient is initialized once and persists for the app's
* lifetime — its state file lock is never released until the process exits.
* The SOCKS proxy can be started/stopped independently without affecting
* the TorClient or its file locks.
*
* All JNI calls (including System.loadLibrary) run on [Dispatchers.IO]
* to avoid blocking the main thread.
*/
class TorService(
val context: Context,
) {
val status =
callbackFlow {
Log.d("TorService", "Binding Tor Service")
trySend(TorServiceStatus.Connecting)
private val socksPort = DEFAULT_SOCKS_PORT
private val initialized = AtomicBoolean(false)
private val proxyRunning = AtomicBoolean(false)
val currentIntent = Intent(context, TorService::class.java)
val serviceConnection: ServiceConnection =
object : ServiceConnection {
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
private val _status = MutableStateFlow<TorServiceStatus>(TorServiceStatus.Off)
val status: StateFlow<TorServiceStatus> = _status.asStateFlow()
while (torService.socksPort < 0) {
delay(SOCKS_PORT_POLL_INTERVAL_MS)
}
/**
* Initialize the TorClient (once) and start the SOCKS proxy.
* Must be called from a coroutine on [Dispatchers.IO].
*/
suspend fun start() {
if (proxyRunning.get()) {
if (_status.value is TorServiceStatus.Active) return
_status.value = TorServiceStatus.Connecting
return
}
val active = TorServiceStatus.Active(torService.socksPort)
active.torControlConnection = torService.torControlConnection
_status.value = TorServiceStatus.Connecting
trySend(active)
Log.d("TorService") { "Tor Service Connected ${torService.socksPort}" }
} catch (e: Exception) {
Log.e("TorService") { "Tor service connection failed: ${e.message}" }
trySend(TorServiceStatus.Off)
}
withContext(Dispatchers.IO) {
// Initialize TorClient once — this bootstraps the Tor network.
// setLogCallback and initialize are the first ArtiNative calls,
// which triggers System.loadLibrary on this IO thread.
if (initialized.compareAndSet(false, true)) {
ArtiNative.setLogCallback { text ->
Log.d("TorService") {
val newLine = text.indexOf('\n')
if (newLine > 1) {
"Arti: ${text.substring(0, newLine)}"
} else {
"Arti: $text"
}
}
override fun onServiceDisconnected(name: ComponentName) {
Log.d("TorService", "Tor Service Disconnected")
trySend(TorServiceStatus.Off)
when {
text.contains("Sufficiently bootstrapped", ignoreCase = true) -> {
_status.value = TorServiceStatus.Active(socksPort)
Log.d("TorService") { "Arti SOCKS proxy active on port $socksPort" }
}
}
}
try {
context.bindService(
currentIntent,
serviceConnection,
BIND_AUTO_CREATE,
)
} catch (e: Exception) {
Log.e("TorService") { "Failed to bind Tor Service: ${e.message}" }
trySend(TorServiceStatus.Off)
val dataDir = File(context.filesDir, "arti").absolutePath
Log.d("TorService") { "Initializing Arti with data dir: $dataDir" }
val initResult = ArtiNative.initialize(dataDir)
if (initResult != 0) {
Log.e("TorService") { "Failed to initialize Arti: error $initResult" }
initialized.set(false)
_status.value = TorServiceStatus.Off
return@withContext
}
}
awaitClose {
Log.d("TorService", "Stopping Tor Service")
try {
context.unbindService(serviceConnection)
} catch (e: Exception) {
Log.d("TorService") { "Failed to unbind Tor Service: ${e.message}" }
}
try {
context.stopService(currentIntent)
} catch (e: Exception) {
Log.d("TorService") { "Failed to stop Tor Service: ${e.message}" }
}
trySend(TorServiceStatus.Off)
// Start the SOCKS proxy (can be called multiple times safely)
val proxyResult = ArtiNative.startSocksProxy(socksPort)
if (proxyResult != 0) {
Log.e("TorService") { "Failed to start SOCKS proxy: error $proxyResult" }
_status.value = TorServiceStatus.Off
return@withContext
}
}.flowOn(Dispatchers.IO)
proxyRunning.set(true)
}
}
/**
* Stop the SOCKS proxy and release the port.
* The TorClient stays alive — no file lock issues on restart.
*/
suspend fun stop() {
if (!proxyRunning.compareAndSet(true, false)) return
withContext(Dispatchers.IO) {
ArtiNative.stopSocksProxy()
Log.d("TorService") { "SOCKS proxy stopped" }
}
_status.value = TorServiceStatus.Off
}
}
@@ -20,15 +20,10 @@
*/
package com.vitorpamplona.amethyst.ui.tor
import net.freehaven.tor.control.TorControlConnection
sealed class TorServiceStatus {
data class Active(
val port: Int,
) : TorServiceStatus() {
// If internal, it has control.
var torControlConnection: TorControlConnection? = null
}
) : TorServiceStatus()
object Off : TorServiceStatus()
Binary file not shown.
Binary file not shown.