From 686e2e78ec27b160179fd84e278c6e8c0938c156 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Fri, 29 Aug 2025 14:37:35 +0200 Subject: [PATCH] Bundle tor (#339) * tor started * tor works * tor code * improve manager * works * move tor icon * werks * arti works * arti works * arti works with reconnect * delay fix * refactor --- app/build.gradle.kts | 3 + .../com/bitchat/android/BitchatApplication.kt | 6 + .../com/bitchat/android/net/OkHttpProvider.kt | 53 +++ .../com/bitchat/android/net/TorManager.kt | 318 ++++++++++++++++++ .../java/com/bitchat/android/net/TorMode.kt | 8 + .../android/net/TorPreferenceManager.kt | 33 ++ .../android/nostr/NostrRelayManager.kt | 9 +- .../bitchat/android/nostr/RelayDirectory.kt | 10 +- .../java/com/bitchat/android/ui/AboutSheet.kt | 91 +++++ .../java/com/bitchat/android/ui/ChatHeader.kt | 26 +- gradle/libs.versions.toml | 7 +- settings.gradle.kts | 3 + 12 files changed, 551 insertions(+), 16 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/net/OkHttpProvider.kt create mode 100644 app/src/main/java/com/bitchat/android/net/TorManager.kt create mode 100644 app/src/main/java/com/bitchat/android/net/TorMode.kt create mode 100644 app/src/main/java/com/bitchat/android/net/TorPreferenceManager.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0e3d181d..c1528bcb 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -95,6 +95,9 @@ dependencies { // WebSocket implementation(libs.okhttp) + // Arti (Tor in Rust) Android bridge - use published AAR with native libs + implementation("info.guardianproject:arti-mobile-ex:1.2.3") + // Google Play Services Location implementation(libs.gms.location) diff --git a/app/src/main/java/com/bitchat/android/BitchatApplication.kt b/app/src/main/java/com/bitchat/android/BitchatApplication.kt index f0d15923..2df5e3b4 100644 --- a/app/src/main/java/com/bitchat/android/BitchatApplication.kt +++ b/app/src/main/java/com/bitchat/android/BitchatApplication.kt @@ -3,6 +3,7 @@ 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 /** * Main application class for bitchat Android @@ -12,6 +13,9 @@ class BitchatApplication : Application() { override fun onCreate() { super.onCreate() + // Initialize Tor first so any early network goes over Tor + try { TorManager.init(this) } catch (_: Exception) { } + // Initialize relay directory (loads assets/nostr_relays.csv) RelayDirectory.initialize(this) @@ -27,5 +31,7 @@ class BitchatApplication : Application() { // Initialize theme preference ThemePreferenceManager.init(this) + + // TorManager already initialized above } } diff --git a/app/src/main/java/com/bitchat/android/net/OkHttpProvider.kt b/app/src/main/java/com/bitchat/android/net/OkHttpProvider.kt new file mode 100644 index 00000000..60431476 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/net/OkHttpProvider.kt @@ -0,0 +1,53 @@ +package com.bitchat.android.net + +import okhttp3.OkHttpClient +import java.net.InetSocketAddress +import java.net.Proxy +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference + +/** + * Centralized OkHttp provider to ensure all network traffic honors Tor settings. + */ +object OkHttpProvider { + private val httpClientRef = AtomicReference(null) + private val wsClientRef = AtomicReference(null) + + fun reset() { + httpClientRef.set(null) + wsClientRef.set(null) + } + + fun httpClient(): OkHttpClient { + httpClientRef.get()?.let { return it } + val client = baseBuilderForCurrentProxy() + .callTimeout(15, TimeUnit.SECONDS) + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(15, TimeUnit.SECONDS) + .build() + httpClientRef.set(client) + return client + } + + fun webSocketClient(): OkHttpClient { + wsClientRef.get()?.let { return it } + val client = baseBuilderForCurrentProxy() + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(0, TimeUnit.SECONDS) + .writeTimeout(10, TimeUnit.SECONDS) + .build() + wsClientRef.set(client) + return client + } + + private fun baseBuilderForCurrentProxy(): OkHttpClient.Builder { + val builder = OkHttpClient.Builder() + val socks: InetSocketAddress? = TorManager.currentSocksAddress() + if (socks != null && TorManager.isProxyEnabled()) { + val proxy = Proxy(Proxy.Type.SOCKS, socks) + builder.proxy(proxy) + } + return builder + } +} + diff --git a/app/src/main/java/com/bitchat/android/net/TorManager.kt b/app/src/main/java/com/bitchat/android/net/TorManager.kt new file mode 100644 index 00000000..9b3e2e34 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/net/TorManager.kt @@ -0,0 +1,318 @@ +package com.bitchat.android.net + +import android.app.Application +import android.util.Log +import info.guardianproject.arti.ArtiLogListener +import info.guardianproject.arti.ArtiProxy +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.launch +import kotlinx.coroutines.delay +import kotlinx.coroutines.Job + +import java.net.InetSocketAddress +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. + */ +object TorManager { + private const val TAG = "TorManager" + private const val DEFAULT_SOCKS_PORT = 9060 + private const val RESTART_DELAY_MS = 2000L // 2 seconds between stop/start + private const val INACTIVITY_TIMEOUT_MS = 5000L // 5 seconds of no activity before restart + private const val MAX_RETRY_ATTEMPTS = 5 + + private val appScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + @Volatile private var initialized = false + @Volatile private var socksAddr: InetSocketAddress? = null + private val artiProxyRef = AtomicReference(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 nextSocksPort: Int = DEFAULT_SOCKS_PORT + @Volatile private var lastLogTime = AtomicLong(0L) + @Volatile private var retryAttempts = 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 + + data class TorStatus( + val mode: TorMode = TorMode.OFF, + val running: Boolean = false, + val bootstrapPercent: Int = 0, + val lastLogLine: String = "" + ) + + private val _status = MutableStateFlow(TorStatus()) + val statusFlow: StateFlow = _status.asStateFlow() + + fun isProxyEnabled(): Boolean { + val s = _status.value + return s.mode != TorMode.OFF && s.running && s.bootstrapPercent >= 100 && socksAddr != null + } + + fun init(application: Application) { + if (initialized) return + synchronized(this) { + if (initialized) return + initialized = true + currentApplication = application + TorPreferenceManager.init(application) + + // Apply saved mode at startup + appScope.launch { + applyMode(application, TorPreferenceManager.get(application)) + } + + // Observe changes + appScope.launch { + TorPreferenceManager.modeFlow.collect { mode -> + applyMode(application, mode) + } + } + } + } + + fun currentSocksAddress(): InetSocketAddress? = socksAddr + + suspend fun applyMode(application: Application, mode: TorMode) { + applyMutex.withLock { + 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") + return + } + when (mode) { + TorMode.OFF -> { + Log.i(TAG, "applyMode: OFF -> stopping tor") + lifecycleState = LifecycleState.STOPPING + stopArti() + socksAddr = null + _status.value = _status.value.copy(mode = TorMode.OFF, running = false, bootstrapPercent = 0) + nextSocksPort = DEFAULT_SOCKS_PORT + lifecycleState = LifecycleState.STOPPED + // Rebuild clients WITHOUT proxy and reconnect relays + try { + OkHttpProvider.reset() + com.bitchat.android.nostr.NostrRelayManager.shared.resetAllConnections() + } catch (_: Throwable) { } + } + TorMode.ON -> { + Log.i(TAG, "applyMode: ON -> starting arti") + nextSocksPort = if (currentSocksPort < DEFAULT_SOCKS_PORT) DEFAULT_SOCKS_PORT else currentSocksPort + lifecycleState = LifecycleState.STARTING + // For OFF->ON, no delay needed + val needsDelay = s.mode == TorMode.ISOLATION + startArti(application, isolation = false, useDelay = needsDelay) + _status.value = _status.value.copy(mode = TorMode.ON) + // Defer enabling proxy until bootstrap completes + appScope.launch { + waitUntilBootstrapped() + if (_status.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) { } + } + } + } + TorMode.ISOLATION -> { + Log.i(TAG, "applyMode: ISOLATION -> starting arti") + nextSocksPort = if (currentSocksPort < DEFAULT_SOCKS_PORT) DEFAULT_SOCKS_PORT else currentSocksPort + lifecycleState = LifecycleState.STARTING + // For ON->ISOLATION, immediate status change and delay + _status.value = _status.value.copy(running = false, bootstrapPercent = 0) + val needsDelay = s.mode == TorMode.ON + startArti(application, isolation = true, useDelay = needsDelay) + _status.value = _status.value.copy(mode = TorMode.ISOLATION) + appScope.launch { + waitUntilBootstrapped() + if (_status.value.running && desiredMode == TorMode.ISOLATION) { + socksAddr = InetSocketAddress("127.0.0.1", currentSocksPort) + Log.i(TAG, "Arti ISOLATION: proxy set to ${socksAddr}") + OkHttpProvider.reset() + try { com.bitchat.android.nostr.NostrRelayManager.shared.resetAllConnections() } catch (_: Throwable) { } + } + } + } + } + } catch (e: Exception) { + Log.e(TAG, "Failed to apply Arti mode: ${e.message}") + } + } + } + + private suspend fun startArti(application: Application, isolation: Boolean, useDelay: Boolean = false) { + try { + stopArtiInternal() + + Log.i(TAG, "Starting Arti…") + if (useDelay) { + delay(RESTART_DELAY_MS) + } + + // Determine port + val port = nextSocksPort + currentSocksPort = port + + 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) + if ( + s.contains("Sufficiently bootstrapped", ignoreCase = true) || + s.contains("AMEx: state changed to Running", ignoreCase = true) + ) { + _status.value = _status.value.copy(bootstrapPercent = 100) + retryAttempts = 0 // Reset retry attempts on successful bootstrap + startInactivityMonitoring() + } + } + + val proxy = ArtiProxy.Builder(application) + .setSocksPort(port) + .setDnsPort(port + 1) + .setLogListener(logListener) + .build() + + artiProxyRef.set(proxy) + proxy.start() + lastLogTime.set(System.currentTimeMillis()) + + _status.value = _status.value.copy(running = true, bootstrapPercent = 0) + lifecycleState = LifecycleState.RUNNING + startInactivityMonitoring() + + } catch (e: Exception) { + Log.e(TAG, "Error starting Arti: ${e.message}") + scheduleRetry(application, isolation) + } + } + + private fun stopArtiInternal() { + try { + val proxy = artiProxyRef.getAndSet(null) + if (proxy != null) { + Log.i(TAG, "Stopping Arti…") + try { proxy.stop() } catch (_: Throwable) {} + } + stopInactivityMonitoring() + stopRetryMonitoring() + } catch (e: Exception) { + Log.w(TAG, "Error stopping Arti: ${e.message}") + } + } + + private fun stopArti() { + stopArtiInternal() + socksAddr = null + _status.value = _status.value.copy(running = false, bootstrapPercent = 0) + } + + private suspend fun restartArti(application: Application, isolation: Boolean) { + Log.i(TAG, "Restarting Arti (keeping SOCKS proxy enabled)...") + stopArtiInternal() + delay(RESTART_DELAY_MS) + startArti(application, isolation, useDelay = false) // Already delayed above + } + + private fun startInactivityMonitoring() { + inactivityJob?.cancel() + inactivityJob = appScope.launch { + while (true) { + delay(INACTIVITY_TIMEOUT_MS) + val currentTime = System.currentTimeMillis() + val lastActivity = lastLogTime.get() + val timeSinceLastActivity = currentTime - lastActivity + + if (timeSinceLastActivity > INACTIVITY_TIMEOUT_MS) { + val currentMode = _status.value.mode + if (currentMode == TorMode.ON || currentMode == TorMode.ISOLATION) { + val bootstrapPercent = _status.value.bootstrapPercent + if (bootstrapPercent < 100) { + Log.w(TAG, "Inactivity detected (${timeSinceLastActivity}ms), restarting Arti") + currentApplication?.let { app -> + appScope.launch { + restartArti(app, currentMode == TorMode.ISOLATION) + } + } + break + } + } + } + } + } + } + + private fun stopInactivityMonitoring() { + inactivityJob?.cancel() + inactivityJob = null + } + + private fun scheduleRetry(application: Application, isolation: Boolean) { + retryJob?.cancel() + if (retryAttempts < MAX_RETRY_ATTEMPTS) { + retryAttempts++ + val delayMs = (1000L * (1 shl retryAttempts)).coerceAtMost(30000L) // Exponential backoff, max 30s + Log.w(TAG, "Scheduling Arti retry attempt $retryAttempts in ${delayMs}ms") + retryJob = appScope.launch { + delay(delayMs) + val currentMode = _status.value.mode + if (currentMode == TorMode.ON || currentMode == TorMode.ISOLATION) { + Log.i(TAG, "Retrying Arti start (attempt $retryAttempts)") + restartArti(application, currentMode == TorMode.ISOLATION) + } + } + } else { + Log.e(TAG, "Max retry attempts reached, giving up on Arti connection") + } + } + + private fun stopRetryMonitoring() { + retryJob?.cancel() + retryJob = null + } + + // Removed Tor resource installation: not needed for Arti + + /** + * Build an execution command that works on Android 10+ where app data dirs are mounted noexec. + * We invoke the platform dynamic linker and pass the PIE binary path as its first arg. + */ + // Removed exec command builder: not needed for Arti + + private suspend fun waitUntilBootstrapped() { + val current = _status.value + if (!current.running) return + if (current.bootstrapPercent >= 100) return + // Suspend until we observe 100% at least once + while (true) { + val s = statusFlow.first { it.bootstrapPercent >= 100 || !it.running } + if (!s.running) return + if (s.bootstrapPercent >= 100) return + } + } + + // Visible for instrumentation tests to validate installation + fun installResourcesForTest(application: Application): Boolean { return true } +} diff --git a/app/src/main/java/com/bitchat/android/net/TorMode.kt b/app/src/main/java/com/bitchat/android/net/TorMode.kt new file mode 100644 index 00000000..dce3f3f3 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/net/TorMode.kt @@ -0,0 +1,8 @@ +package com.bitchat.android.net + +enum class TorMode { + OFF, + ON, + ISOLATION +} + diff --git a/app/src/main/java/com/bitchat/android/net/TorPreferenceManager.kt b/app/src/main/java/com/bitchat/android/net/TorPreferenceManager.kt new file mode 100644 index 00000000..661cf7c4 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/net/TorPreferenceManager.kt @@ -0,0 +1,33 @@ +package com.bitchat.android.net + +import android.content.Context +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +object TorPreferenceManager { + private const val PREFS_NAME = "bitchat_settings" + private const val KEY_TOR_MODE = "tor_mode" + + private val _modeFlow = MutableStateFlow(TorMode.OFF) + val modeFlow: StateFlow = _modeFlow + + fun init(context: Context) { + val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + val saved = prefs.getString(KEY_TOR_MODE, TorMode.OFF.name) + val mode = runCatching { TorMode.valueOf(saved ?: TorMode.OFF.name) }.getOrDefault(TorMode.OFF) + _modeFlow.value = mode + } + + fun set(context: Context, mode: TorMode) { + val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + prefs.edit().putString(KEY_TOR_MODE, mode.name).apply() + _modeFlow.value = mode + } + + fun get(context: Context): TorMode { + val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + val saved = prefs.getString(KEY_TOR_MODE, TorMode.OFF.name) + return runCatching { TorMode.valueOf(saved ?: TorMode.OFF.name) }.getOrDefault(TorMode.OFF) + } +} + diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt b/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt index 92f4afe8..2bf252ab 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt @@ -113,12 +113,9 @@ class NostrRelayManager private constructor() { private var subscriptionValidationJob: Job? = null private val SUBSCRIPTION_VALIDATION_INTERVAL = 30000L // 30 seconds - // OkHttp client for WebSocket connections - private val httpClient = OkHttpClient.Builder() - .connectTimeout(10, TimeUnit.SECONDS) - .readTimeout(0, TimeUnit.SECONDS) // No read timeout for WebSocket - .writeTimeout(10, TimeUnit.SECONDS) - .build() + // OkHttp client for WebSocket connections (via provider to honor Tor) + private val httpClient: OkHttpClient + get() = com.bitchat.android.net.OkHttpProvider.webSocketClient() private val gson by lazy { NostrRequest.createGson() } diff --git a/app/src/main/java/com/bitchat/android/nostr/RelayDirectory.kt b/app/src/main/java/com/bitchat/android/nostr/RelayDirectory.kt index db353581..f591b2b8 100644 --- a/app/src/main/java/com/bitchat/android/nostr/RelayDirectory.kt +++ b/app/src/main/java/com/bitchat/android/nostr/RelayDirectory.kt @@ -34,13 +34,8 @@ object RelayDirectory { private val ONE_DAY_MS = TimeUnit.DAYS.toMillis(1) private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - private val httpClient: OkHttpClient by lazy { - OkHttpClient.Builder() - .callTimeout(15, TimeUnit.SECONDS) - .connectTimeout(10, TimeUnit.SECONDS) - .readTimeout(15, TimeUnit.SECONDS) - .build() - } + private val httpClient: OkHttpClient + get() = com.bitchat.android.net.OkHttpProvider.httpClient() data class RelayInfo( val url: String, @@ -306,4 +301,3 @@ object RelayDirectory { } } - diff --git a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt index a22bc114..d678cce5 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt @@ -194,6 +194,97 @@ fun AboutSheet( } } } + + // Network (Tor) section + item { + val ctx = LocalContext.current + val torMode = remember { mutableStateOf(com.bitchat.android.net.TorPreferenceManager.get(ctx)) } + val torStatus by com.bitchat.android.net.TorManager.statusFlow.collectAsState() + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "network", + fontSize = 12.sp, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Medium, + color = colorScheme.onSurface.copy(alpha = 0.8f) + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + FilterChip( + selected = torMode.value == com.bitchat.android.net.TorMode.OFF, + onClick = { + torMode.value = com.bitchat.android.net.TorMode.OFF + com.bitchat.android.net.TorPreferenceManager.set(ctx, torMode.value) + }, + label = { Text("tor off", fontFamily = FontFamily.Monospace) } + ) + FilterChip( + selected = torMode.value == com.bitchat.android.net.TorMode.ON, + onClick = { + torMode.value = com.bitchat.android.net.TorMode.ON + com.bitchat.android.net.TorPreferenceManager.set(ctx, torMode.value) + }, + label = { Text("tor on", fontFamily = FontFamily.Monospace) } + ) + FilterChip( + selected = torMode.value == com.bitchat.android.net.TorMode.ISOLATION, + onClick = { + torMode.value = com.bitchat.android.net.TorMode.ISOLATION + com.bitchat.android.net.TorPreferenceManager.set(ctx, torMode.value) + }, + label = { Text("isolation mode", fontFamily = FontFamily.Monospace) } + ) + // Status indicator (red/orange/green) shown to the right of buttons + val statusColor = when { + torStatus.running && torStatus.bootstrapPercent < 100 -> Color(0xFFFF9500) + torStatus.running && torStatus.bootstrapPercent >= 100 -> if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D) + else -> Color.Red + } + Surface( + color = statusColor, + shape = RoundedCornerShape(50) + ) { Box(Modifier.size(10.dp)) } + } + Text( + text = "route internet over tor. isolation uses separate circuits per relay.", + fontSize = 10.sp, + fontFamily = FontFamily.Monospace, + color = colorScheme.onSurface.copy(alpha = 0.6f) + ) + + // Debug status (temporary) + Surface( + modifier = Modifier.fillMaxWidth(), + color = colorScheme.surfaceVariant.copy(alpha = 0.25f), + shape = RoundedCornerShape(8.dp) + ) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Text( + text = "tor status: " + + (if (torStatus.running) "running" else "stopped") + + ", bootstrap=" + torStatus.bootstrapPercent + "%", + fontSize = 11.sp, + fontFamily = FontFamily.Monospace, + color = colorScheme.onSurface.copy(alpha = 0.75f) + ) + val last = torStatus.lastLogLine + if (last.isNotEmpty()) { + Text( + text = "last: " + last.take(160), + fontSize = 10.sp, + fontFamily = FontFamily.Monospace, + color = colorScheme.onSurface.copy(alpha = 0.6f) + ) + } + } + } + } + } // Emergency warning item { diff --git a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt index 85e5ce32..ca886713 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt @@ -48,6 +48,27 @@ fun isFavoriteReactive( } } +@Composable +fun TorStatusIcon( + modifier: Modifier = Modifier +) { + val torStatus by com.bitchat.android.net.TorManager.statusFlow.collectAsState() + + if (torStatus.mode != com.bitchat.android.net.TorMode.OFF) { + val cableColor = when { + torStatus.running && torStatus.bootstrapPercent < 100 -> Color(0xFFFF9500) + torStatus.running && torStatus.bootstrapPercent >= 100 -> Color(0xFF00C851) + else -> Color.Red + } + Icon( + imageVector = Icons.Outlined.Cable, + contentDescription = "Tor status", + modifier = modifier, + tint = cableColor + ) + } +} + @Composable fun NoiseSessionIcon( sessionState: String?, @@ -539,9 +560,12 @@ private fun MainHeader( // Right section with location channels button and peer counter Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp) + horizontalArrangement = Arrangement.spacedBy(5.dp) ) { + // Tor status cable icon when Tor is enabled + TorStatusIcon(modifier = Modifier.size(15.dp)) + // Location channels button (matching iOS implementation) LocationChannelsButton( viewModel = viewModel, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index fe9d7e12..7e3a5fba 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -36,6 +36,8 @@ nordic-ble = "2.6.1" # WebSocket okhttp = "4.12.0" +tor-android-binary = "0.4.4.6" + # Google Play Services gms-location = "21.3.0" @@ -93,6 +95,9 @@ nordic-ble = { module = "no.nordicsemi.android:ble", version.ref = "nordic-ble" # WebSocket okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } +tor-android-binary = { module = "org.torproject:tor-android-binary", version.ref = "tor-android-binary" } + +# Tor (embed) intentionally not pinned yet; add once repo is chosen # Google Play Services gms-location = { module = "com.google.android.gms:play-services-location", version.ref = "gms-location" } @@ -150,4 +155,4 @@ testing = [ compose-testing = [ "androidx-compose-ui-test-junit4", "androidx-compose-ui-test-manifest" -] \ No newline at end of file +] diff --git a/settings.gradle.kts b/settings.gradle.kts index 37f6ce50..53c50616 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -10,8 +10,11 @@ dependencyResolutionManagement { repositories { google() mavenCentral() + // Guardian Project raw GitHub Maven (hosts info.guardianproject:arti-mobile-ex) + maven { url = uri("https://raw.githubusercontent.com/guardianproject/gpmaven/master") } } } rootProject.name = "bitchat-android" include(":app") +// Using published Arti AAR; local module not included