mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-24 21:45:21 +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
|
// WebSocket
|
||||||
implementation(libs.okhttp)
|
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
|
// Google Play Services Location
|
||||||
implementation(libs.gms.location)
|
implementation(libs.gms.location)
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.bitchat.android
|
|||||||
import android.app.Application
|
import android.app.Application
|
||||||
import com.bitchat.android.nostr.RelayDirectory
|
import com.bitchat.android.nostr.RelayDirectory
|
||||||
import com.bitchat.android.ui.theme.ThemePreferenceManager
|
import com.bitchat.android.ui.theme.ThemePreferenceManager
|
||||||
|
import com.bitchat.android.net.TorManager
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main application class for bitchat Android
|
* Main application class for bitchat Android
|
||||||
@@ -12,6 +13,9 @@ class BitchatApplication : Application() {
|
|||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.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)
|
// Initialize relay directory (loads assets/nostr_relays.csv)
|
||||||
RelayDirectory.initialize(this)
|
RelayDirectory.initialize(this)
|
||||||
|
|
||||||
@@ -27,5 +31,7 @@ class BitchatApplication : Application() {
|
|||||||
|
|
||||||
// Initialize theme preference
|
// Initialize theme preference
|
||||||
ThemePreferenceManager.init(this)
|
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 var subscriptionValidationJob: Job? = null
|
||||||
private val SUBSCRIPTION_VALIDATION_INTERVAL = 30000L // 30 seconds
|
private val SUBSCRIPTION_VALIDATION_INTERVAL = 30000L // 30 seconds
|
||||||
|
|
||||||
// OkHttp client for WebSocket connections
|
// OkHttp client for WebSocket connections (via provider to honor Tor)
|
||||||
private val httpClient = OkHttpClient.Builder()
|
private val httpClient: OkHttpClient
|
||||||
.connectTimeout(10, TimeUnit.SECONDS)
|
get() = com.bitchat.android.net.OkHttpProvider.webSocketClient()
|
||||||
.readTimeout(0, TimeUnit.SECONDS) // No read timeout for WebSocket
|
|
||||||
.writeTimeout(10, TimeUnit.SECONDS)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
private val gson by lazy { NostrRequest.createGson() }
|
private val gson by lazy { NostrRequest.createGson() }
|
||||||
|
|
||||||
|
|||||||
@@ -34,13 +34,8 @@ object RelayDirectory {
|
|||||||
private val ONE_DAY_MS = TimeUnit.DAYS.toMillis(1)
|
private val ONE_DAY_MS = TimeUnit.DAYS.toMillis(1)
|
||||||
|
|
||||||
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
private val httpClient: OkHttpClient by lazy {
|
private val httpClient: OkHttpClient
|
||||||
OkHttpClient.Builder()
|
get() = com.bitchat.android.net.OkHttpProvider.httpClient()
|
||||||
.callTimeout(15, TimeUnit.SECONDS)
|
|
||||||
.connectTimeout(10, TimeUnit.SECONDS)
|
|
||||||
.readTimeout(15, TimeUnit.SECONDS)
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
data class RelayInfo(
|
data class RelayInfo(
|
||||||
val url: String,
|
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
|
// Emergency warning
|
||||||
item {
|
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
|
@Composable
|
||||||
fun NoiseSessionIcon(
|
fun NoiseSessionIcon(
|
||||||
sessionState: String?,
|
sessionState: String?,
|
||||||
@@ -539,9 +560,12 @@ private fun MainHeader(
|
|||||||
// Right section with location channels button and peer counter
|
// Right section with location channels button and peer counter
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
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)
|
// Location channels button (matching iOS implementation)
|
||||||
LocationChannelsButton(
|
LocationChannelsButton(
|
||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ nordic-ble = "2.6.1"
|
|||||||
|
|
||||||
# WebSocket
|
# WebSocket
|
||||||
okhttp = "4.12.0"
|
okhttp = "4.12.0"
|
||||||
|
tor-android-binary = "0.4.4.6"
|
||||||
|
|
||||||
|
|
||||||
# Google Play Services
|
# Google Play Services
|
||||||
gms-location = "21.3.0"
|
gms-location = "21.3.0"
|
||||||
@@ -93,6 +95,9 @@ nordic-ble = { module = "no.nordicsemi.android:ble", version.ref = "nordic-ble"
|
|||||||
|
|
||||||
# WebSocket
|
# WebSocket
|
||||||
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
|
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
|
# Google Play Services
|
||||||
gms-location = { module = "com.google.android.gms:play-services-location", version.ref = "gms-location" }
|
gms-location = { module = "com.google.android.gms:play-services-location", version.ref = "gms-location" }
|
||||||
@@ -150,4 +155,4 @@ testing = [
|
|||||||
compose-testing = [
|
compose-testing = [
|
||||||
"androidx-compose-ui-test-junit4",
|
"androidx-compose-ui-test-junit4",
|
||||||
"androidx-compose-ui-test-manifest"
|
"androidx-compose-ui-test-manifest"
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -10,8 +10,11 @@ dependencyResolutionManagement {
|
|||||||
repositories {
|
repositories {
|
||||||
google()
|
google()
|
||||||
mavenCentral()
|
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"
|
rootProject.name = "bitchat-android"
|
||||||
include(":app")
|
include(":app")
|
||||||
|
// Using published Arti AAR; local module not included
|
||||||
|
|||||||
Reference in New Issue
Block a user