mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-25 10:25:19 +00:00
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:
@@ -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)
|
||||
|
||||
+268
-124
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user