mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-24 20:45:19 +00:00
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
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<OkHttpClient?>(null)
|
||||
private val wsClientRef = AtomicReference<OkHttpClient?>(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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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 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<TorStatus> = _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 }
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.bitchat.android.net
|
||||
|
||||
enum class TorMode {
|
||||
OFF,
|
||||
ON,
|
||||
ISOLATION
|
||||
}
|
||||
|
||||
@@ -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<TorMode> = _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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() }
|
||||
|
||||
|
||||
@@ -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 {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user