feat: add product flavors and TorProvider abstraction (#508)

* feat: add product flavors and TorProvider abstraction

Introduces build flavors to separate Tor functionality from the standard build, reducing APK size for users who don't need Tor.

- Creates `standard` and `tor` product flavors.
- The `standard` flavor is the default, lightweight build.
- The `tor` flavor includes the Arti (Tor) dependency and is identified by the `.tor` application ID suffix.
- Adds a `TorProvider` interface and a `TorProviderFactory` to abstract Tor implementation details between flavors.

Prepares architecture for optional Tor support to reduce APK size
from 142MB to ~4-5MB for standard builds

Related to #454

* Refactor: implement StandardTorProvider and RealTorProvider

This commit refactors the Tor integration by introducing a `TorProvider` interface and creating separate implementations for 'tor' and 'standard' product flavors.

- The original `TorManager` singleton has been moved into `RealTorProvider` for the 'tor' flavor.
- A no-op `StandardTorProvider` is introduced for the 'standard' flavor, which reports Tor as unavailable.
- A `TorProviderFactory` is used to create the appropriate provider at runtime based on the build variant.

* refactor: migrate to TorProvider abstraction

Replaced direct calls to the static `TorManager` with an instance obtained from `TorProviderFactory`.

This change allows for different Tor implementations based on build flavors, improving modularity and abstracting the Tor provider logic.

Updated `BitchatApplication`, `ChatHeader`, `OkHttpProvider`, and `AboutSheet` to use the new factory pattern for accessing Tor functionalities.

* build: add flavor-specific ProGuard rules and CI/CD

 - Split ProGuard rules: base, standard-specific, tor-specific
  - Move Arti/Guardian Project rules to proguard-tor.pro
  - Update CI/CD workflow to build both flavors
  - Add separate artifact uploads for each flavor
  - Add descriptive release notes template

  CI now builds both standard (~4-5MB) and tor (~140MB) APKs."

  resolves #454

* refactor: centralize network reset logic

Extracts the repeated network connection reset logic into a new private function `resetNetworkConnections()`.

This change also replaces direct `_statusFlow.value = ...` assignments with the safer `_statusFlow.update { ... }` function to prevent race conditions.

* ci: run separate build steps for flavors

* feat: disable Tor toggle if not available in build

* ci: parallelize builds and add conditional tor lint

Optimizes CI workflow:
- Parallel matrix builds (4 runners instead of sequential)
- Conditional tor lint only when app/src/tor/ changes in PRs
- Merged test+lint jobs to reduce setup overhead

Reduces CI time by ~50% (8-11 min vs 18-26 min)

* refactor: Unify Tor implementation and remove build flavors

This commit refactors the Tor integration by removing the `standard` and `tor` product flavors in favor of a single, unified build that always includes a custom-built Arti (Tor) library.

Key changes include:
*   **Removed Build Flavors:** Deleted the `standard` and `tor` product flavors from `build.gradle.kts`, simplifying the build process and CI configuration.
*   **Unified Tor Manager:** Replaced the `TorProvider` interface and flavor-specific implementations (`StandardTorProvider`, `RealTorProvider`) with a new singleton, `ArtiTorManager`. This class now manages the Arti lifecycle for all builds.
*   **Custom Arti Wrapper:** Introduced a new `ArtiProxy` class to provide a compatible API wrapper around the custom-built native Arti library (`libarti_android.so`). This replaces the dependency on the external `arti-mobile-ex` library.
*   **Updated Proguard:** Consolidated and updated Proguard rules into the main `proguard-rules.pro` file to keep the necessary `ArtiTorManager` and native library classes.
*   **CI/CD Simplification:** Updated GitHub Actions workflows (`release.yml`, `android-build.yml`) to build and release a single APK instead of separate ones for each flavor.

* build: Configure ABI filters for debug and release builds

For debug builds, include `x86_64` to support emulators.

For release builds, only include `arm64-v8a` to minimize the final APK size.

* feat: Ignore jniLibs directory

This change adds the `app/src/main/jniLibs/` directory to the `.gitignore` file to prevent native libraries from being committed to the repository.

* feat: Refine .gitignore for Arti build artifacts

Improves the `.gitignore` file by:
- Ignoring all `build/` directories except for `tools/arti-build/`.
- Adding specific ignores for Arti build artifacts, including the cloned source repository and the Rust build cache directory.

* feat: Update arti android native library

* feat: add build script and JNI wrapper for Arti

Adds a comprehensive build system for creating custom Arti (Tor in Rust) shared libraries for Android. This replaces the dependency on external, outdated AARs with a fully transparent and reproducible build process.

Key changes:
- Introduces `build-arti.sh`, a script to clone the official Arti repository, apply a JNI wrapper, and build `.so` files for Android.
- Adds `ARTI_VERSION` to pin the build to a specific Arti release (v1.7.0).
- Implements a new Rust JNI wrapper (`src/lib.rs`) that exposes core functions like `initialize`, `startSocksProxy`, and `stop` to the Android app.
- Includes a `Cargo.toml` with release profile optimizations for size (`lto`, `strip`, `opt-level = "z"`).
- Provides detailed documentation in `README.md` explaining the build process, prerequisites, and architecture.

* build script for mac

* consolidate both scripts

* improve script

---------

Co-authored-by: callebtc <93376500+callebtc@users.noreply.github.com>
This commit is contained in:
Moe Hamade
2025-12-12 23:27:52 +07:00
committed by GitHub
co-authored by callebtc
parent a174ac5185
commit b2febcee88
22 changed files with 1981 additions and 226 deletions
@@ -3,18 +3,21 @@ package com.bitchat.android
import android.app.Application
import com.bitchat.android.nostr.RelayDirectory
import com.bitchat.android.ui.theme.ThemePreferenceManager
import com.bitchat.android.net.TorManager
import com.bitchat.android.net.ArtiTorManager
/**
* Main application class for bitchat Android
*/
class BitchatApplication : Application() {
override fun onCreate() {
super.onCreate()
// Initialize Tor first so any early network goes over Tor
try { TorManager.init(this) } catch (_: Exception) { }
try {
val torProvider = ArtiTorManager.getInstance()
torProvider.init(this)
} catch (_: Exception){}
// Initialize relay directory (loads assets/nostr_relays.csv)
RelayDirectory.initialize(this)
@@ -2,6 +2,7 @@ package com.bitchat.android.net
import android.app.Application
import android.util.Log
import com.bitchat.android.util.AppConstants
import info.guardianproject.arti.ArtiLogListener
import info.guardianproject.arti.ArtiProxy
import kotlinx.coroutines.CoroutineScope
@@ -11,6 +12,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.launch
@@ -24,54 +26,97 @@ import java.util.concurrent.atomic.AtomicReference
import java.util.concurrent.atomic.AtomicLong
/**
* Manages embedded Tor lifecycle & provides SOCKS proxy address.
* Uses Arti (Tor in Rust) for improved security and reliability.
* Tor provider implementation using custom-built Arti (Tor-in-Rust).
*
* This singleton provides Tor anonymity features using a custom Arti build
* compiled with 16KB page size support for Google Play compliance.
*
* Based on the original TorManager implementation.
*/
object TorManager {
private const val TAG = "TorManager"
private const val DEFAULT_SOCKS_PORT = com.bitchat.android.util.AppConstants.Tor.DEFAULT_SOCKS_PORT
private const val RESTART_DELAY_MS = com.bitchat.android.util.AppConstants.Tor.RESTART_DELAY_MS // 2 seconds between stop/start
private const val INACTIVITY_TIMEOUT_MS = com.bitchat.android.util.AppConstants.Tor.INACTIVITY_TIMEOUT_MS // 5 seconds of no activity before restart
private const val MAX_RETRY_ATTEMPTS = com.bitchat.android.util.AppConstants.Tor.MAX_RETRY_ATTEMPTS
private const val STOP_TIMEOUT_MS = com.bitchat.android.util.AppConstants.Tor.STOP_TIMEOUT_MS
class ArtiTorManager private constructor() {
enum class TorState {
OFF,
STARTING,
BOOTSTRAPPING,
RUNNING,
STOPPING,
ERROR
}
data class TorStatus(
val mode: TorMode = TorMode.OFF,
val running: Boolean = false,
val bootstrapPercent: Int = 0,
val lastLogLine: String = "",
val state: TorState = TorState.OFF
)
companion object {
private const val TAG = "ArtiTorManager"
private const val DEFAULT_SOCKS_PORT = AppConstants.Tor.DEFAULT_SOCKS_PORT
private const val RESTART_DELAY_MS = AppConstants.Tor.RESTART_DELAY_MS
private const val INACTIVITY_TIMEOUT_MS = AppConstants.Tor.INACTIVITY_TIMEOUT_MS
private const val MAX_RETRY_ATTEMPTS = AppConstants.Tor.MAX_RETRY_ATTEMPTS
private const val STOP_TIMEOUT_MS = AppConstants.Tor.STOP_TIMEOUT_MS
@Volatile
private var INSTANCE: ArtiTorManager? = null
fun getInstance(): ArtiTorManager {
return INSTANCE ?: synchronized(this) {
INSTANCE ?: ArtiTorManager().also { INSTANCE = it }
}
}
}
private val appScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
@Volatile private var initialized = false
@Volatile private var socksAddr: InetSocketAddress? = null
private val artiProxyRef = AtomicReference<ArtiProxy?>(null)
@Volatile private var lastMode: TorMode = TorMode.OFF
@Volatile
private var initialized = false
@Volatile
private var socksAddr: InetSocketAddress? = null
@Volatile
private var artiProxy: ArtiProxy? = null
@Volatile
private var lastMode: TorMode = TorMode.OFF
private val applyMutex = Mutex()
@Volatile private var desiredMode: TorMode = TorMode.OFF
@Volatile private var currentSocksPort: Int = DEFAULT_SOCKS_PORT
@Volatile private var lastLogTime = AtomicLong(0L)
@Volatile private var retryAttempts = 0
@Volatile private var bindRetryAttempts = 0
@Volatile
private var desiredMode: TorMode = TorMode.OFF
@Volatile
private var currentSocksPort: Int = DEFAULT_SOCKS_PORT
@Volatile
private var lastLogTime = AtomicLong(0L)
@Volatile
private var retryAttempts = 0
@Volatile
private var bindRetryAttempts = 0
private var inactivityJob: Job? = null
private var retryJob: Job? = null
private var currentApplication: Application? = null
private enum class LifecycleState { STOPPED, STARTING, RUNNING, STOPPING }
@Volatile private var lifecycleState: LifecycleState = LifecycleState.STOPPED
enum class TorState { OFF, STARTING, BOOTSTRAPPING, RUNNING, STOPPING, ERROR }
@Volatile
private var lifecycleState: LifecycleState = LifecycleState.STOPPED
data class TorStatus(
val mode: TorMode = TorMode.OFF,
val running: Boolean = false,
val bootstrapPercent: Int = 0, // kept for backwards compatibility with UI; 0 or 100 only
val lastLogLine: String = "",
val state: TorState = TorState.OFF
private val _statusFlow = MutableStateFlow(
TorStatus(
mode = TorMode.OFF,
running = false,
bootstrapPercent = 0,
lastLogLine = "",
state = TorState.OFF
)
)
private val _status = MutableStateFlow(TorStatus())
val statusFlow: StateFlow<TorStatus> = _status.asStateFlow()
val statusFlow: StateFlow<TorStatus> = _statusFlow.asStateFlow()
private val stateChangeDeferred = AtomicReference<CompletableDeferred<TorState>?>(null)
fun isProxyEnabled(): Boolean {
val s = _status.value
return s.mode != TorMode.OFF && s.running && s.bootstrapPercent >= 100 && socksAddr != null && s.state == TorState.RUNNING
val s = _statusFlow.value
return s.mode != TorMode.OFF && s.running && s.bootstrapPercent >= 100 &&
socksAddr != null && s.state == TorState.RUNNING
}
fun init(application: Application) {
@@ -82,7 +127,21 @@ object TorManager {
currentApplication = application
TorPreferenceManager.init(application)
// Apply saved mode at startup. If ON, set planned SOCKS immediately to avoid any leak.
val logListener = ArtiLogListener { logLine ->
val text = logLine ?: return@ArtiLogListener
val s = text
Log.i(TAG, "arti: $s")
lastLogTime.set(System.currentTimeMillis())
_statusFlow.update { it.copy(lastLogLine = s) }
handleArtiLogLine(s)
}
artiProxy = ArtiProxy.Builder(application)
.setSocksPort(currentSocksPort)
.setDnsPort(currentSocksPort + 1)
.setLogListener(logListener)
.build()
val savedMode = TorPreferenceManager.get(application)
if (savedMode == TorMode.ON) {
if (currentSocksPort < DEFAULT_SOCKS_PORT) {
@@ -90,13 +149,15 @@ object TorManager {
}
desiredMode = savedMode
socksAddr = InetSocketAddress("127.0.0.1", currentSocksPort)
try { OkHttpProvider.reset() } catch (_: Throwable) { }
try {
OkHttpProvider.reset()
} catch (_: Throwable) {
} // Only reset OkHttp during init
}
appScope.launch {
applyMode(application, savedMode)
}
// Observe changes
appScope.launch {
TorPreferenceManager.modeFlow.collect { mode ->
applyMode(application, mode)
@@ -112,53 +173,63 @@ object TorManager {
try {
desiredMode = mode
lastMode = mode
val s = _status.value
if (mode == s.mode && mode != TorMode.OFF && (lifecycleState == LifecycleState.STARTING || lifecycleState == LifecycleState.RUNNING)) {
Log.i(TAG, "applyMode: already in progress/running mode=$mode, state=$lifecycleState; skip")
val s = _statusFlow.value
if (mode == s.mode && mode != TorMode.OFF &&
(lifecycleState == LifecycleState.STARTING || lifecycleState == LifecycleState.RUNNING)
) {
Log.i(
TAG,
"applyMode: already in progress/running mode=$mode, state=$lifecycleState; skip"
)
return
}
when (mode) {
TorMode.OFF -> {
Log.i(TAG, "applyMode: OFF -> stopping tor")
lifecycleState = LifecycleState.STOPPING
_status.value = _status.value.copy(mode = TorMode.OFF, running = false, bootstrapPercent = 0, state = TorState.STOPPING)
stopArti() // non-suspending immediate request
// Best-effort wait for STOPPED before we declare OFF
_statusFlow.value = _statusFlow.value.copy(
mode = TorMode.OFF,
running = false,
bootstrapPercent = 0,
state = TorState.STOPPING
)
stopArti()
waitForStateTransition(target = TorState.OFF, timeoutMs = STOP_TIMEOUT_MS)
socksAddr = null
_status.value = _status.value.copy(mode = TorMode.OFF, running = false, bootstrapPercent = 0, state = TorState.OFF)
_statusFlow.value = _statusFlow.value.copy(
mode = TorMode.OFF,
running = false,
bootstrapPercent = 0,
state = TorState.OFF
)
currentSocksPort = DEFAULT_SOCKS_PORT
bindRetryAttempts = 0
lifecycleState = LifecycleState.STOPPED
// Rebuild clients WITHOUT proxy and reconnect relays
try {
OkHttpProvider.reset()
com.bitchat.android.nostr.NostrRelayManager.shared.resetAllConnections()
} catch (_: Throwable) { }
resetNetworkConnections()
}
TorMode.ON -> {
Log.i(TAG, "applyMode: ON -> starting arti")
// Reset port to default unless we're already using a higher port
if (currentSocksPort < DEFAULT_SOCKS_PORT) {
currentSocksPort = DEFAULT_SOCKS_PORT
}
bindRetryAttempts = 0
lifecycleState = LifecycleState.STARTING
_status.value = _status.value.copy(mode = TorMode.ON, running = false, bootstrapPercent = 0, state = TorState.STARTING)
// Immediately set the planned SOCKS address so all traffic is forced through it,
// even before Tor is fully bootstrapped. This prevents any direct connections.
_statusFlow.value = _statusFlow.value.copy(
mode = TorMode.ON,
running = false,
bootstrapPercent = 0,
state = TorState.STARTING
)
socksAddr = InetSocketAddress("127.0.0.1", currentSocksPort)
try { OkHttpProvider.reset() } catch (_: Throwable) { }
try { com.bitchat.android.nostr.NostrRelayManager.shared.resetAllConnections() } catch (_: Throwable) { }
resetNetworkConnections()
startArti(application, useDelay = false)
// Defer enabling proxy until bootstrap completes
appScope.launch {
waitUntilBootstrapped()
if (_status.value.running && desiredMode == TorMode.ON) {
if (_statusFlow.value.running && desiredMode == TorMode.ON) {
socksAddr = InetSocketAddress("127.0.0.1", currentSocksPort)
Log.i(TAG, "Tor ON: proxy set to ${socksAddr}")
OkHttpProvider.reset()
try { com.bitchat.android.nostr.NostrRelayManager.shared.resetAllConnections() } catch (_: Throwable) { }
resetNetworkConnections()
}
}
}
@@ -171,84 +242,95 @@ object TorManager {
private suspend fun startArti(application: Application, useDelay: Boolean = false) {
try {
// Ensure any previous instance is fully stopped before starting a new one
stopArtiAndWait()
Log.i(TAG, "Starting Arti on port $currentSocksPort")
if (useDelay) {
delay(RESTART_DELAY_MS)
}
val logListener = ArtiLogListener { logLine ->
val text = logLine ?: return@ArtiLogListener
val s = text.toString()
Log.i(TAG, "arti: $s")
lastLogTime.set(System.currentTimeMillis())
_status.value = _status.value.copy(lastLogLine = s)
handleArtiLogLine(s)
val proxy = artiProxy ?: run {
Log.e(TAG, "ArtiProxy not initialized! This should not happen.")
_statusFlow.update { it.copy(state = TorState.ERROR) }
return
}
val proxy = ArtiProxy.Builder(application)
.setSocksPort(currentSocksPort)
.setDnsPort(currentSocksPort + 1)
.setLogListener(logListener)
.build()
artiProxyRef.set(proxy)
proxy.start()
lastLogTime.set(System.currentTimeMillis())
_status.value = _status.value.copy(running = true, bootstrapPercent = 0, state = TorState.STARTING)
_statusFlow.update {
it.copy(
running = true,
bootstrapPercent = 0,
state = TorState.STARTING
)
}
lifecycleState = LifecycleState.RUNNING
startInactivityMonitoring()
// Removed onion service startup (BLE-only file transfer in this branch)
} catch (e: Exception) {
Log.e(TAG, "Error starting Arti on port $currentSocksPort: ${e.message}")
_status.value = _status.value.copy(state = TorState.ERROR)
// Check if this is a bind error
_statusFlow.update { it.copy(state = TorState.ERROR) }
val isBindError = isBindError(e)
if (isBindError && bindRetryAttempts < MAX_RETRY_ATTEMPTS) {
bindRetryAttempts++
currentSocksPort++
Log.w(TAG, "Port bind failed (attempt $bindRetryAttempts/$MAX_RETRY_ATTEMPTS), retrying with port $currentSocksPort")
// Update planned SOCKS address immediately so all new connections target the new port
Log.w(
TAG,
"Port bind failed (attempt $bindRetryAttempts/$MAX_RETRY_ATTEMPTS), retrying with port $currentSocksPort"
)
socksAddr = InetSocketAddress("127.0.0.1", currentSocksPort)
try { OkHttpProvider.reset() } catch (_: Throwable) { }
try { com.bitchat.android.nostr.NostrRelayManager.shared.resetAllConnections() } catch (_: Throwable) { }
// Immediate retry with incremented port, no exponential backoff for bind errors
resetNetworkConnections()
startArti(application, useDelay = false)
} else if (isBindError) {
Log.e(TAG, "Max bind retry attempts reached ($MAX_RETRY_ATTEMPTS), giving up")
lifecycleState = LifecycleState.STOPPED
_status.value = _status.value.copy(running = false, bootstrapPercent = 0, state = TorState.ERROR)
_statusFlow.update {
it.copy(
running = false,
bootstrapPercent = 0,
state = TorState.ERROR
)
}
} else {
// For non-bind errors, use the existing retry mechanism
scheduleRetry(application)
}
}
}
/**
* Checks if the exception indicates a port binding failure
*/
private fun isBindError(exception: Exception): Boolean {
val message = exception.message?.lowercase() ?: ""
return message.contains("bind") ||
message.contains("address already in use") ||
message.contains("port") && message.contains("use") ||
message.contains("permission denied") && message.contains("port") ||
message.contains("could not bind")
message.contains("address already in use") ||
message.contains("port") && message.contains("use") ||
message.contains("permission denied") && message.contains("port") ||
message.contains("could not bind")
}
/**
* Reset network connections after Tor state changes.
* Rebuilds OkHttp clients and reconnects Nostr relays.
*/
private fun resetNetworkConnections() {
try {
OkHttpProvider.reset()
} catch (_: Throwable) {
}
try {
com.bitchat.android.nostr.NostrRelayManager.shared.resetAllConnections()
} catch (_: Throwable) {
}
}
private fun stopArtiInternal() {
try {
val proxy = artiProxyRef.getAndSet(null)
val proxy = artiProxy
if (proxy != null) {
Log.i(TAG, "Stopping Arti…")
try { proxy.stop() } catch (_: Throwable) {}
try {
proxy.stop()
} catch (_: Throwable) {
}
}
stopInactivityMonitoring()
stopRetryMonitoring()
@@ -260,15 +342,16 @@ object TorManager {
private fun stopArti() {
stopArtiInternal()
socksAddr = null
_status.value = _status.value.copy(running = false, bootstrapPercent = 0, state = TorState.STOPPING)
_statusFlow.value = _statusFlow.value.copy(
running = false,
bootstrapPercent = 0,
state = TorState.STOPPING
)
}
private suspend fun stopArtiAndWait(timeoutMs: Long = STOP_TIMEOUT_MS) {
// Request stop
stopArtiInternal()
// Wait for confirmation via logs (Stopped) or timeout
waitForStateTransition(target = TorState.OFF, timeoutMs = timeoutMs)
// Small grace period before relaunch to let file locks clear
delay(200)
}
@@ -276,7 +359,7 @@ object TorManager {
Log.i(TAG, "Restarting Arti (keeping SOCKS proxy enabled)...")
stopArtiAndWait()
delay(RESTART_DELAY_MS)
startArti(application, useDelay = false) // Already delayed above
startArti(application, useDelay = false)
}
private fun startInactivityMonitoring() {
@@ -287,13 +370,16 @@ object TorManager {
val currentTime = System.currentTimeMillis()
val lastActivity = lastLogTime.get()
val timeSinceLastActivity = currentTime - lastActivity
if (timeSinceLastActivity > INACTIVITY_TIMEOUT_MS) {
val currentMode = _status.value.mode
val currentMode = _statusFlow.value.mode
if (currentMode == TorMode.ON) {
val bootstrapPercent = _status.value.bootstrapPercent
val bootstrapPercent = _statusFlow.value.bootstrapPercent
if (bootstrapPercent < 100) {
Log.w(TAG, "Inactivity detected (${timeSinceLastActivity}ms), restarting Arti")
Log.w(
TAG,
"Inactivity detected (${timeSinceLastActivity}ms), restarting Arti"
)
currentApplication?.let { app ->
appScope.launch {
restartArti(app)
@@ -316,11 +402,11 @@ object TorManager {
retryJob?.cancel()
if (retryAttempts < MAX_RETRY_ATTEMPTS) {
retryAttempts++
val delayMs = (1000L * (1 shl retryAttempts)).coerceAtMost(30000L) // Exponential backoff, max 30s
val delayMs = (1000L * (1 shl retryAttempts)).coerceAtMost(30000L)
Log.w(TAG, "Scheduling Arti retry attempt $retryAttempts in ${delayMs}ms")
retryJob = appScope.launch {
delay(delayMs)
val currentMode = _status.value.mode
val currentMode = _statusFlow.value.mode
if (currentMode == TorMode.ON) {
Log.i(TAG, "Retrying Arti start (attempt $retryAttempts)")
restartArti(application)
@@ -337,50 +423,110 @@ object TorManager {
}
private suspend fun waitUntilBootstrapped() {
val current = _status.value
val current = _statusFlow.value
if (!current.running) return
if (current.bootstrapPercent >= 100 && current.state == TorState.RUNNING) return
// Suspend until we observe RUNNING at least once
while (true) {
val s = statusFlow.first { (it.bootstrapPercent >= 100 && it.state == TorState.RUNNING) || !it.running || it.state == TorState.ERROR }
val s = statusFlow.first {
(it.bootstrapPercent >= 100 && it.state == TorState.RUNNING) ||
!it.running ||
it.state == TorState.ERROR
}
if (!s.running || s.state == TorState.ERROR) return
if (s.bootstrapPercent >= 100 && s.state == TorState.RUNNING) return
}
}
private fun handleArtiLogLine(s: String) {
val currentState = _statusFlow.value.state
val currentLifecycle = lifecycleState
when {
s.contains("AMEx: state changed to Initialized", ignoreCase = true) -> {
_status.value = _status.value.copy(state = TorState.STARTING)
if (currentLifecycle != LifecycleState.STARTING && currentLifecycle != LifecycleState.RUNNING) {
Log.w(TAG, "Ignoring stale 'Initialized' log (lifecycle: $currentLifecycle)")
return
}
_statusFlow.update { it.copy(state = TorState.STARTING) }
completeWaitersIf(TorState.STARTING)
}
s.contains("AMEx: state changed to Starting", ignoreCase = true) -> {
_status.value = _status.value.copy(state = TorState.STARTING)
if (currentLifecycle != LifecycleState.STARTING && currentLifecycle != LifecycleState.RUNNING) {
Log.w(TAG, "Ignoring stale 'Starting' log (lifecycle: $currentLifecycle)")
return
}
_statusFlow.update { it.copy(state = TorState.STARTING) }
completeWaitersIf(TorState.STARTING)
}
s.contains("Sufficiently bootstrapped; system SOCKS now functional", ignoreCase = true) -> {
_status.value = _status.value.copy(bootstrapPercent = 75, state = TorState.BOOTSTRAPPING)
s.contains(
"Sufficiently bootstrapped; system SOCKS now functional",
ignoreCase = true
) -> {
if (currentLifecycle != LifecycleState.RUNNING) {
Log.w(TAG, "Ignoring bootstrap log (lifecycle: $currentLifecycle)")
return
}
_statusFlow.update {
it.copy(
bootstrapPercent = 75,
state = TorState.BOOTSTRAPPING
)
}
retryAttempts = 0
bindRetryAttempts = 0
startInactivityMonitoring()
}
//s.contains("AMEx: state changed to Running", ignoreCase = true) -> {
s.contains("We have found that guard [scrubbed] is usable.", ignoreCase = true) -> {
// If we already saw Sufficiently bootstrapped, mark as RUNNING and ready.
val bp = if (_status.value.bootstrapPercent >= 100) 100 else 100 // treat Running as ready
_status.value = _status.value.copy(state = TorState.RUNNING, bootstrapPercent = bp, running = true)
if (currentLifecycle != LifecycleState.RUNNING) {
Log.w(TAG, "Ignoring guard discovery log (lifecycle: $currentLifecycle)")
return
}
_statusFlow.update {
it.copy(
state = TorState.RUNNING,
bootstrapPercent = 100,
running = true
)
}
completeWaitersIf(TorState.RUNNING)
}
s.contains("AMEx: state changed to Stopping", ignoreCase = true) -> {
_status.value = _status.value.copy(state = TorState.STOPPING, running = false)
if (currentLifecycle != LifecycleState.STOPPING) {
Log.w(TAG, "Ignoring stale 'Stopping' log (lifecycle: $currentLifecycle)")
return
}
_statusFlow.update {
it.copy(
state = TorState.STOPPING,
running = false
)
}
}
s.contains("AMEx: state changed to Stopped", ignoreCase = true) -> {
_status.value = _status.value.copy(state = TorState.OFF, running = false, bootstrapPercent = 0)
if (currentLifecycle != LifecycleState.STOPPING && currentLifecycle != LifecycleState.STOPPED) {
Log.w(
TAG,
"Ignoring stale 'Stopped' log (lifecycle: $currentLifecycle, preventing state corruption)"
)
return
}
_statusFlow.update {
it.copy(
state = TorState.OFF,
running = false,
bootstrapPercent = 0
)
}
completeWaitersIf(TorState.OFF)
}
s.contains("Another process has the lock on our state files", ignoreCase = true) -> {
// Signal error; we'll likely need to wait longer before restart
_status.value = _status.value.copy(state = TorState.ERROR)
_statusFlow.update { it.copy(state = TorState.ERROR) }
}
}
}
@@ -395,13 +541,11 @@ object TorManager {
val def = CompletableDeferred<TorState>()
stateChangeDeferred.getAndSet(def)?.cancel()
return withTimeoutOrNull(timeoutMs) {
// Fast-path: if we're already there
val cur = _status.value.state
val cur = _statusFlow.value.state
if (cur == target) return@withTimeoutOrNull cur
def.await()
}
}
// Visible for instrumentation tests to validate installation
fun installResourcesForTest(application: Application): Boolean { return true }
fun isTorAvailable(): Boolean = true
}
@@ -42,8 +42,9 @@ object OkHttpProvider {
private fun baseBuilderForCurrentProxy(): OkHttpClient.Builder {
val builder = OkHttpClient.Builder()
val socks: InetSocketAddress? = TorManager.currentSocksAddress()
// If a SOCKS address is defined, always use it. TorManager sets this as soon as Tor mode is ON,
val torProvider = ArtiTorManager.getInstance()
val socks: InetSocketAddress? = torProvider.currentSocksAddress()
// If a SOCKS address is defined, always use it. TorProvider sets this as soon as Tor mode is ON,
// even before bootstrap, to prevent any direct connections from occurring.
if (socks != null) {
val proxy = Proxy(Proxy.Type.SOCKS, socks)
@@ -11,14 +11,14 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bluetooth
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Public
import androidx.compose.material.icons.filled.Security
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material.icons.outlined.Info
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import kotlinx.coroutines.launch
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
@@ -28,9 +28,12 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.bitchat.android.nostr.NostrProofOfWork
import com.bitchat.android.nostr.PoWPreferenceManager
import com.bitchat.android.ui.debug.DebugSettingsSheet
import androidx.compose.ui.res.stringResource
import com.bitchat.android.R
import com.bitchat.android.net.TorMode
import com.bitchat.android.net.TorPreferenceManager
import com.bitchat.android.net.ArtiTorManager
/**
* About Sheet for bitchat app information
* Matches the design language of LocationChannelsSheet
@@ -383,7 +386,9 @@ fun AboutSheet(
// Network (Tor) section
item(key = "network_section") {
val torMode = remember { mutableStateOf(com.bitchat.android.net.TorPreferenceManager.get(context)) }
val torStatus by com.bitchat.android.net.TorManager.statusFlow.collectAsState()
val torProvider = remember { ArtiTorManager.getInstance() }
val torStatus by torProvider.statusFlow.collectAsState()
val torAvailable = remember { torProvider.isTorAvailable() }
Text(
text = stringResource(R.string.about_network),
style = MaterialTheme.typography.labelLarge,
@@ -398,19 +403,22 @@ fun AboutSheet(
verticalAlignment = Alignment.CenterVertically
) {
FilterChip(
selected = torMode.value == com.bitchat.android.net.TorMode.OFF,
selected = torMode.value == TorMode.OFF,
onClick = {
torMode.value = com.bitchat.android.net.TorMode.OFF
com.bitchat.android.net.TorPreferenceManager.set(context, torMode.value)
torMode.value = TorMode.OFF
TorPreferenceManager.set(context, torMode.value)
},
label = { Text("tor off", fontFamily = FontFamily.Monospace) }
)
FilterChip(
selected = torMode.value == com.bitchat.android.net.TorMode.ON,
selected = torMode.value == TorMode.ON,
onClick = {
torMode.value = com.bitchat.android.net.TorMode.ON
com.bitchat.android.net.TorPreferenceManager.set(context, torMode.value)
if (torAvailable) {
torMode.value = TorMode.ON
TorPreferenceManager.set(context, torMode.value)
}
},
enabled = torAvailable,
label = {
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
@@ -428,13 +436,49 @@ fun AboutSheet(
}
}
)
if (!torAvailable) {
val tooltipState = rememberTooltipState()
val scope = rememberCoroutineScope()
TooltipBox(
positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(),
tooltip = {
PlainTooltip {
Text(
text = stringResource(R.string.tor_not_available_in_this_build),
fontSize = 11.sp,
fontFamily = FontFamily.Monospace
)
}
},
state = tooltipState
) {
IconButton(
onClick = {
scope.launch {
tooltipState.show()
}
},
modifier = Modifier.size(32.dp)
) {
Icon(
imageVector = Icons.Outlined.Info,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface.copy(
alpha = 0.6f
),
modifier = Modifier.size(18.dp)
)
}
}
}
}
Text(
text = stringResource(R.string.about_tor_route),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
if (torMode.value == com.bitchat.android.net.TorMode.ON) {
if (torMode.value == TorMode.ON) {
val statusText = if (torStatus.running) "Running" else "Stopped"
// Debug status (temporary)
Surface(
@@ -28,7 +28,6 @@ import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.bitchat.android.core.ui.utils.singleOrTripleClickable
import com.bitchat.android.geohash.LocationChannelManager.PermissionState
import androidx.compose.foundation.Canvas
import androidx.compose.ui.geometry.Offset
@@ -59,7 +58,8 @@ fun isFavoriteReactive(
fun TorStatusDot(
modifier: Modifier = Modifier
) {
val torStatus by com.bitchat.android.net.TorManager.statusFlow.collectAsState()
val torProvider = remember { com.bitchat.android.net.ArtiTorManager.getInstance() }
val torStatus by torProvider.statusFlow.collectAsState()
if (torStatus.mode != com.bitchat.android.net.TorMode.OFF) {
val dotColor = when {
@@ -0,0 +1,9 @@
package info.guardianproject.arti
/**
* Exception thrown by Arti operations.
*
* This exception is thrown when the native Arti library encounters
* an error during initialization, startup, or other operations.
*/
class ArtiException(message: String, cause: Throwable? = null) : Exception(message, cause)
@@ -0,0 +1,17 @@
package info.guardianproject.arti
/**
* Listener interface for Arti log messages.
*
* This interface is called from the native Arti implementation whenever
* a log line is produced. It allows the application to monitor Tor's
* bootstrap progress and connection status.
*/
fun interface ArtiLogListener {
/**
* Called when Arti produces a log line.
*
* @param logLine The log message from Arti, or null if no message
*/
fun onLogLine(logLine: String?)
}
@@ -0,0 +1,183 @@
package info.guardianproject.arti
import android.app.Application
import android.util.Log
import org.torproject.arti.ArtiNative
import java.io.File
/**
* Arti Tor proxy implementation compatible with Guardian Project API.
*
* This class provides a wrapper around the custom-built Arti native library,
* maintaining API compatibility with Guardian Project's arti-mobile-ex library.
*
* Usage:
* ```
* val proxy = ArtiProxy.Builder(application)
* .setSocksPort(9050)
* .setDnsPort(9051)
* .setLogListener { logLine -> Log.i("Arti", logLine ?: "") }
* .build()
*
* proxy.start()
* // ... use proxy ...
* proxy.stop()
* ```
*/
class ArtiProxy private constructor(
private val application: Application,
private val socksPort: Int,
private val dnsPort: Int,
private val logListener: ArtiLogListener?
) {
companion object {
private const val TAG = "ArtiProxy"
}
@Volatile
private var isRunning = false
/**
* Start the Arti Tor proxy.
*
* This method:
* 1. Registers log callback
* 2. Initializes Arti runtime with data directory
* 3. Starts SOCKS proxy on configured port
*
* @throws ArtiException if initialization or startup fails
*/
fun start() {
if (isRunning) {
Log.w(TAG, "Arti already running")
return
}
try {
logListener?.let { listener ->
Log.d(TAG, "Registering log callback")
ArtiNative.setLogCallback(listener)
}
val dataDir = getDataDirectory()
Log.i(TAG, "Initializing Arti with data directory: $dataDir")
val initResult = ArtiNative.initialize(dataDir.absolutePath)
if (initResult != 0) {
throw ArtiException("Failed to initialize Arti: error code $initResult")
}
Log.i(TAG, "Starting SOCKS proxy on port $socksPort (DNS port: $dnsPort)")
val startResult = ArtiNative.startSocksProxy(socksPort)
when (startResult) {
0 -> {
isRunning = true
Log.i(TAG, "Arti started successfully")
}
-1 -> throw ArtiException("Arti client not initialized")
-2 -> throw ArtiException("Tokio runtime not initialized")
-3 -> throw ArtiException("Failed to bind SOCKS proxy to port $socksPort (port already in use)")
else -> throw ArtiException("Failed to start SOCKS proxy: error code $startResult")
}
} catch (e: Exception) {
Log.e(TAG, "Failed to start Arti", e)
if (e is ArtiException) {
throw e
} else {
throw ArtiException("Failed to start Arti: ${e.message}", e)
}
}
}
/**
* Stop the Arti Tor proxy.
*
* This method gracefully shuts down the Tor client and SOCKS proxy.
*/
fun stop() {
if (!isRunning) {
Log.w(TAG, "Arti not running")
return
}
try {
Log.i(TAG, "Stopping Arti...")
val stopResult = ArtiNative.stop()
if (stopResult != 0) {
Log.w(TAG, "Stop returned error code: $stopResult")
}
isRunning = false
Log.i(TAG, "Arti stopped successfully")
} catch (e: Exception) {
Log.e(TAG, "Error stopping Arti", e)
}
}
/**
* Get or create Arti data directory.
*
* Directory structure:
* - {app_data}/arti/cache - Tor directory cache
* - {app_data}/arti/state - Tor persistent state
*/
private fun getDataDirectory(): File {
val artiDir = File(application.filesDir, "arti")
if (!artiDir.exists()) {
artiDir.mkdirs()
}
File(artiDir, "cache").apply { if (!exists()) mkdirs() }
File(artiDir, "state").apply { if (!exists()) mkdirs() }
return artiDir
}
/**
* Builder for ArtiProxy instances.
*
* Provides fluent API for configuring proxy settings before creation.
*/
class Builder(private val application: Application) {
private var socksPort: Int = 9050
private var dnsPort: Int = 9051
private var logListener: ArtiLogListener? = null
/**
* Set SOCKS proxy port.
* @param port Port number (default: 9050)
* @return this Builder for chaining
*/
fun setSocksPort(port: Int) = apply {
this.socksPort = port
}
/**
* Set DNS port.
* @param port Port number (default: 9051)
* @return this Builder for chaining
*/
fun setDnsPort(port: Int) = apply {
this.dnsPort = port
}
/**
* Set log listener for Arti log messages.
* @param listener Callback for log lines
* @return this Builder for chaining
*/
fun setLogListener(listener: ArtiLogListener) = apply {
this.logListener = listener
}
/**
* Build and return the configured ArtiProxy instance.
* @return Configured ArtiProxy (not yet started)
*/
fun build(): ArtiProxy {
return ArtiProxy(application, socksPort, dnsPort, logListener)
}
}
}
@@ -0,0 +1,54 @@
package org.torproject.arti
import info.guardianproject.arti.ArtiLogListener
/**
* JNI wrapper for custom-built Arti (Tor implementation in Rust)
*
* This class provides native bindings to libarti_android.so compiled from
* the latest Arti source with rustls (no OpenSSL dependency).
*
* Features:
* - Latest Arti v1.7.0 code
* - 16KB page size support (Google Play Nov 2025 ready)
* - Onion service client support
* - Pure Rust TLS (rustls)
*/
object ArtiNative {
init {
System.loadLibrary("arti_android")
}
/**
* Get Arti version string
* @return Version string from native library
*/
external fun getVersion(): String
/**
* Set log callback for Arti logs
* @param callback Callback object with onLogLine(String?) method
*/
external fun setLogCallback(callback: ArtiLogListener)
/**
* Initialize Arti runtime
* @param dataDir Directory for Arti state/cache
* @return 0 on success, error code otherwise
*/
external fun initialize(dataDir: String): Int
/**
* Start SOCKS proxy on specified port
* @param port Port number for SOCKS proxy (e.g., 9050)
* @return 0 on success, error code otherwise
*/
external fun startSocksProxy(port: Int): Int
/**
* Stop Arti and cleanup
* @return 0 on success, error code otherwise
*/
external fun stop(): Int
}
Binary file not shown.
Binary file not shown.
+2 -1
View File
@@ -378,8 +378,9 @@
<string name="pwd_label">Password</string>
<string name="join">Join</string>
<string name="cancel">Cancel</string>
<string name="tor_not_available_in_this_build">Tor not available in this build</string>
<!-- Plurals -->
<!-- Plurals -->
<plurals name="notification_and_more">
<item quantity="one">and %d more</item>
<item quantity="other">and %d more</item>