diff --git a/.github/workflows/android-build.yml b/.github/workflows/android-build.yml index a5a31ce68..2f8a79068 100644 --- a/.github/workflows/android-build.yml +++ b/.github/workflows/android-build.yml @@ -7,9 +7,10 @@ on: branches: [ "main", "develop" ] jobs: - test: + verify: + name: Test & Lint runs-on: ubuntu-latest - + steps: - name: Checkout code uses: actions/checkout@v4 @@ -39,7 +40,10 @@ jobs: - name: Run unit tests run: ./gradlew testDebugUnitTest - - name: Upload Test Reports (xml+html) + - name: Run lint + run: ./gradlew lintDebug + + - name: Upload test reports if: always() uses: actions/upload-artifact@v4 with: @@ -48,47 +52,6 @@ jobs: **/build/test-results/ **/build/reports/tests/ - - name: Upload test results - uses: actions/upload-artifact@v4 - if: always() - with: - name: test-results - path: | - **/build/test-results/ - **/build/reports/tests/ - - lint: - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up JDK 17 - uses: actions/setup-java@v4 - with: - java-version: '17' - distribution: 'temurin' - - - name: Setup Gradle - uses: gradle/gradle-build-action@v3 - - - name: Grant execute permission for gradlew - run: chmod +x gradlew - - - name: Cache Gradle packages - uses: actions/cache@v3 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - restore-keys: | - ${{ runner.os }}-gradle- - - - name: Run lint - run: ./gradlew lintDebug - - name: Upload lint results uses: actions/upload-artifact@v4 if: always() @@ -97,9 +60,13 @@ jobs: path: '**/build/reports/lint-results-*.html' build: + name: Build ${{ matrix.variant }} runs-on: ubuntu-latest - needs: [test, lint] - + needs: verify + strategy: + matrix: + variant: [Debug, Release] + steps: - name: Checkout code uses: actions/checkout@v4 @@ -126,20 +93,11 @@ jobs: restore-keys: | ${{ runner.os }}-gradle- - - name: Build debug APK - run: ./gradlew assembleDebug - - - name: Build release APK - run: ./gradlew assembleRelease - - - name: Upload debug APK - uses: actions/upload-artifact@v4 - with: - name: debug-apk - path: app/build/outputs/apk/debug/*.apk + - name: Build ${{ matrix.variant }} APK + run: ./gradlew assemble${{ matrix.variant }} - - name: Upload release APK + - name: Upload ${{ matrix.variant }} APK uses: actions/upload-artifact@v4 with: - name: release-apk - path: app/build/outputs/apk/release/*.apk + name: ${{ matrix.variant }}-apk + path: app/build/outputs/apk/**/*.apk diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4b3b8b7b9..79495ee9a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,7 +19,7 @@ jobs: with: distribution: temurin java-version: 17 - + - name: Setup Gradle uses: gradle/gradle-build-action@v2 with: @@ -38,30 +38,27 @@ jobs: - name: Grant execute permission for Gradlew run: chmod +x ./gradlew - - name: Build APK + - name: Build Release APK (with Tor) run: ./gradlew assembleRelease --no-daemon --stacktrace - + - name: List APK files run: | echo "APK files built:" - find app/build/outputs/apk/release -name "*.apk" -type f + find app/build/outputs/apk -name "*.apk" -type f - name: Rename APK run: | - mv app/build/outputs/apk/release/app-release-unsigned.apk app/build/outputs/apk/release/bitchat.apk - + mv app/build/outputs/apk/release/app-release-unsigned.apk app/build/outputs/apk/release/bitchat-android.apk + - name: DEBUG run: | set -x - pwd - ls -all - cd app/build/outputs/ ls -all - tree - + tree || ls -R + # Optional: Sign APK (requires secrets) # - name: Sign APK # uses: r0adkll/sign-android-release@v1 @@ -75,7 +72,7 @@ jobs: - name: Upload APK as artifact uses: actions/upload-artifact@v4 with: - name: bitchat-release-apk-${{ github.ref_name }} + name: bitchat-android-apk-${{ github.ref_name }} path: app/build/outputs/apk/release/*.apk retention-days: 30 if-no-files-found: error @@ -88,13 +85,22 @@ jobs: - name: Download APK artifact uses: actions/download-artifact@v4 with: - name: bitchat-release-apk-${{ github.ref_name }} - path: . + name: bitchat-android-apk-${{ github.ref_name }} + path: release - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: - files: bitchat.apk + files: | + release/bitchat-android.apk name: Release ${{ github.ref_name }} + body: | + ## bitchat Android Release + + **bitchat-android.apk** (~15MB) + - Secure P2P messaging over Bluetooth mesh and Nostr + - Built-in Tor support (custom Arti build with 16KB page size) + - Compatible with Google Play requirements (Nov 2025+) + - Cross-platform compatible with iOS version env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 151e1b4f5..64ac199e4 100644 --- a/.gitignore +++ b/.gitignore @@ -43,7 +43,9 @@ dependency-reduced-pom.xml # Other *.log .cxx/ -*build/ +# Gradle/Android build directories (but not tools/arti-build/) +**/build/ +!tools/arti-build/ out/ gen/ *~ @@ -57,3 +59,7 @@ google-services.json # Keystore files *.jks *.keystore + +# Arti build artifacts (cloned repo and Rust build cache) +tools/arti-build/.arti-source/ +tools/arti-build/target/ diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ca87d8dce..55c6389df 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -30,6 +30,12 @@ android { } buildTypes { + debug { + ndk { + // Include x86_64 for emulator support during development + abiFilters += listOf("arm64-v8a", "x86_64") + } + } release { isMinifyEnabled = true isShrinkResources = true @@ -37,6 +43,11 @@ android { getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) + ndk { + // ARM64-only to minimize APK size (~5.8MB savings) + // Excludes x86_64 as emulator not needed for production builds + abiFilters += listOf("arm64-v8a") + } } } compileOptions { @@ -95,8 +106,11 @@ 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") + // Arti (Tor in Rust) Android bridge - custom build from latest source + // Built with rustls, 16KB page size support, and onio//un service client + // Native libraries are in src/tor/jniLibs/ (extracted from arti-custom.aar) + // Only included in tor flavor to reduce APK size for standard builds + // Note: AAR is kept in libs/ for reference, but libraries loaded from jniLibs/ // Google Play Services Location implementation(libs.gms.location) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index f8b6946c7..d3e004db5 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -17,9 +17,12 @@ -keep class com.bitchat.android.nostr.** { *; } -keep class com.bitchat.android.identity.** { *; } -# Arti (Tor) ProGuard rules +# Keep Tor implementation (always included) +-keep class com.bitchat.android.net.RealTorProvider { *; } + +# Arti (Custom Tor implementation in Rust) ProGuard rules -keep class info.guardianproject.arti.** { *; } --keep class org.torproject.jni.** { *; } --keepnames class org.torproject.jni.** +-keep class org.torproject.arti.** { *; } +-keepnames class org.torproject.arti.** -dontwarn info.guardianproject.arti.** --dontwarn org.torproject.jni.** +-dontwarn org.torproject.arti.** diff --git a/app/src/main/java/com/bitchat/android/BitchatApplication.kt b/app/src/main/java/com/bitchat/android/BitchatApplication.kt index df9cd6e5b..06fb33c77 100644 --- a/app/src/main/java/com/bitchat/android/BitchatApplication.kt +++ b/app/src/main/java/com/bitchat/android/BitchatApplication.kt @@ -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) diff --git a/app/src/main/java/com/bitchat/android/net/TorManager.kt b/app/src/main/java/com/bitchat/android/net/ArtiTorManager.kt similarity index 52% rename from app/src/main/java/com/bitchat/android/net/TorManager.kt rename to app/src/main/java/com/bitchat/android/net/ArtiTorManager.kt index b7c2230df..8dc0cad8c 100644 --- a/app/src/main/java/com/bitchat/android/net/TorManager.kt +++ b/app/src/main/java/com/bitchat/android/net/ArtiTorManager.kt @@ -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(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 } - - 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 + @Volatile + private var lifecycleState: LifecycleState = LifecycleState.STOPPED + + private val _statusFlow = MutableStateFlow( + TorStatus( + mode = TorMode.OFF, + running = false, + bootstrapPercent = 0, + lastLogLine = "", + state = TorState.OFF + ) ) - private val _status = MutableStateFlow(TorStatus()) - val statusFlow: StateFlow = _status.asStateFlow() + val statusFlow: StateFlow = _statusFlow.asStateFlow() private val stateChangeDeferred = AtomicReference?>(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() 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 } diff --git a/app/src/main/java/com/bitchat/android/net/OkHttpProvider.kt b/app/src/main/java/com/bitchat/android/net/OkHttpProvider.kt index 45cff7734..aafc0d101 100644 --- a/app/src/main/java/com/bitchat/android/net/OkHttpProvider.kt +++ b/app/src/main/java/com/bitchat/android/net/OkHttpProvider.kt @@ -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) 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 091cf630e..b5dffa0f6 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt @@ -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( 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 4b1b22ca8..bed7e24d2 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt @@ -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 { diff --git a/app/src/main/java/info/guardianproject/arti/ArtiException.kt b/app/src/main/java/info/guardianproject/arti/ArtiException.kt new file mode 100644 index 000000000..bee87ff36 --- /dev/null +++ b/app/src/main/java/info/guardianproject/arti/ArtiException.kt @@ -0,0 +1,9 @@ +package info.guardianproject.arti + +/** + * Exception thrown by Arti operations. + * + * This exception is thrown when the native Arti library encounters + * an error during initialization, startup, or other operations. + */ +class ArtiException(message: String, cause: Throwable? = null) : Exception(message, cause) diff --git a/app/src/main/java/info/guardianproject/arti/ArtiLogListener.kt b/app/src/main/java/info/guardianproject/arti/ArtiLogListener.kt new file mode 100644 index 000000000..d21d39899 --- /dev/null +++ b/app/src/main/java/info/guardianproject/arti/ArtiLogListener.kt @@ -0,0 +1,17 @@ +package info.guardianproject.arti + +/** + * Listener interface for Arti log messages. + * + * This interface is called from the native Arti implementation whenever + * a log line is produced. It allows the application to monitor Tor's + * bootstrap progress and connection status. + */ +fun interface ArtiLogListener { + /** + * Called when Arti produces a log line. + * + * @param logLine The log message from Arti, or null if no message + */ + fun onLogLine(logLine: String?) +} diff --git a/app/src/main/java/info/guardianproject/arti/ArtiProxy.kt b/app/src/main/java/info/guardianproject/arti/ArtiProxy.kt new file mode 100644 index 000000000..a81350ad2 --- /dev/null +++ b/app/src/main/java/info/guardianproject/arti/ArtiProxy.kt @@ -0,0 +1,183 @@ +package info.guardianproject.arti + +import android.app.Application +import android.util.Log +import org.torproject.arti.ArtiNative +import java.io.File + +/** + * Arti Tor proxy implementation compatible with Guardian Project API. + * + * This class provides a wrapper around the custom-built Arti native library, + * maintaining API compatibility with Guardian Project's arti-mobile-ex library. + * + * Usage: + * ``` + * val proxy = ArtiProxy.Builder(application) + * .setSocksPort(9050) + * .setDnsPort(9051) + * .setLogListener { logLine -> Log.i("Arti", logLine ?: "") } + * .build() + * + * proxy.start() + * // ... use proxy ... + * proxy.stop() + * ``` + */ +class ArtiProxy private constructor( + private val application: Application, + private val socksPort: Int, + private val dnsPort: Int, + private val logListener: ArtiLogListener? +) { + companion object { + private const val TAG = "ArtiProxy" + } + + @Volatile + private var isRunning = false + + /** + * Start the Arti Tor proxy. + * + * This method: + * 1. Registers log callback + * 2. Initializes Arti runtime with data directory + * 3. Starts SOCKS proxy on configured port + * + * @throws ArtiException if initialization or startup fails + */ + fun start() { + if (isRunning) { + Log.w(TAG, "Arti already running") + return + } + + try { + logListener?.let { listener -> + Log.d(TAG, "Registering log callback") + ArtiNative.setLogCallback(listener) + } + + val dataDir = getDataDirectory() + Log.i(TAG, "Initializing Arti with data directory: $dataDir") + + val initResult = ArtiNative.initialize(dataDir.absolutePath) + if (initResult != 0) { + throw ArtiException("Failed to initialize Arti: error code $initResult") + } + + Log.i(TAG, "Starting SOCKS proxy on port $socksPort (DNS port: $dnsPort)") + val startResult = ArtiNative.startSocksProxy(socksPort) + when (startResult) { + 0 -> { + isRunning = true + Log.i(TAG, "Arti started successfully") + } + -1 -> throw ArtiException("Arti client not initialized") + -2 -> throw ArtiException("Tokio runtime not initialized") + -3 -> throw ArtiException("Failed to bind SOCKS proxy to port $socksPort (port already in use)") + else -> throw ArtiException("Failed to start SOCKS proxy: error code $startResult") + } + + } catch (e: Exception) { + Log.e(TAG, "Failed to start Arti", e) + if (e is ArtiException) { + throw e + } else { + throw ArtiException("Failed to start Arti: ${e.message}", e) + } + } + } + + /** + * Stop the Arti Tor proxy. + * + * This method gracefully shuts down the Tor client and SOCKS proxy. + */ + fun stop() { + if (!isRunning) { + Log.w(TAG, "Arti not running") + return + } + + try { + Log.i(TAG, "Stopping Arti...") + val stopResult = ArtiNative.stop() + if (stopResult != 0) { + Log.w(TAG, "Stop returned error code: $stopResult") + } + + isRunning = false + Log.i(TAG, "Arti stopped successfully") + + } catch (e: Exception) { + Log.e(TAG, "Error stopping Arti", e) + } + } + + /** + * Get or create Arti data directory. + * + * Directory structure: + * - {app_data}/arti/cache - Tor directory cache + * - {app_data}/arti/state - Tor persistent state + */ + private fun getDataDirectory(): File { + val artiDir = File(application.filesDir, "arti") + if (!artiDir.exists()) { + artiDir.mkdirs() + } + + File(artiDir, "cache").apply { if (!exists()) mkdirs() } + File(artiDir, "state").apply { if (!exists()) mkdirs() } + + return artiDir + } + + /** + * Builder for ArtiProxy instances. + * + * Provides fluent API for configuring proxy settings before creation. + */ + class Builder(private val application: Application) { + private var socksPort: Int = 9050 + private var dnsPort: Int = 9051 + private var logListener: ArtiLogListener? = null + + /** + * Set SOCKS proxy port. + * @param port Port number (default: 9050) + * @return this Builder for chaining + */ + fun setSocksPort(port: Int) = apply { + this.socksPort = port + } + + /** + * Set DNS port. + * @param port Port number (default: 9051) + * @return this Builder for chaining + */ + fun setDnsPort(port: Int) = apply { + this.dnsPort = port + } + + /** + * Set log listener for Arti log messages. + * @param listener Callback for log lines + * @return this Builder for chaining + */ + fun setLogListener(listener: ArtiLogListener) = apply { + this.logListener = listener + } + + /** + * Build and return the configured ArtiProxy instance. + * @return Configured ArtiProxy (not yet started) + */ + fun build(): ArtiProxy { + return ArtiProxy(application, socksPort, dnsPort, logListener) + } + } +} diff --git a/app/src/main/java/org/torproject/arti/ArtiNative.kt b/app/src/main/java/org/torproject/arti/ArtiNative.kt new file mode 100644 index 000000000..13eca6dba --- /dev/null +++ b/app/src/main/java/org/torproject/arti/ArtiNative.kt @@ -0,0 +1,54 @@ +package org.torproject.arti + +import info.guardianproject.arti.ArtiLogListener + +/** + * JNI wrapper for custom-built Arti (Tor implementation in Rust) + * + * This class provides native bindings to libarti_android.so compiled from + * the latest Arti source with rustls (no OpenSSL dependency). + * + * Features: + * - Latest Arti v1.7.0 code + * - 16KB page size support (Google Play Nov 2025 ready) + * - Onion service client support + * - Pure Rust TLS (rustls) + */ +object ArtiNative { + + init { + System.loadLibrary("arti_android") + } + + /** + * Get Arti version string + * @return Version string from native library + */ + external fun getVersion(): String + + /** + * Set log callback for Arti logs + * @param callback Callback object with onLogLine(String?) method + */ + external fun setLogCallback(callback: ArtiLogListener) + + /** + * Initialize Arti runtime + * @param dataDir Directory for Arti state/cache + * @return 0 on success, error code otherwise + */ + external fun initialize(dataDir: String): Int + + /** + * Start SOCKS proxy on specified port + * @param port Port number for SOCKS proxy (e.g., 9050) + * @return 0 on success, error code otherwise + */ + external fun startSocksProxy(port: Int): Int + + /** + * Stop Arti and cleanup + * @return 0 on success, error code otherwise + */ + external fun stop(): Int +} \ No newline at end of file diff --git a/app/src/main/jniLibs/arm64-v8a/libarti_android.so b/app/src/main/jniLibs/arm64-v8a/libarti_android.so new file mode 100755 index 000000000..ba4f614b6 Binary files /dev/null and b/app/src/main/jniLibs/arm64-v8a/libarti_android.so differ diff --git a/app/src/main/jniLibs/x86_64/libarti_android.so b/app/src/main/jniLibs/x86_64/libarti_android.so new file mode 100755 index 000000000..9a1e27b6b Binary files /dev/null and b/app/src/main/jniLibs/x86_64/libarti_android.so differ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cc4903f66..a140f9bb5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -378,8 +378,9 @@ Password Join Cancel + Tor not available in this build - + and %d more and %d more diff --git a/tools/arti-build/ARTI_VERSION b/tools/arti-build/ARTI_VERSION new file mode 100644 index 000000000..c68d1ff0f --- /dev/null +++ b/tools/arti-build/ARTI_VERSION @@ -0,0 +1 @@ +arti-v1.7.0 diff --git a/tools/arti-build/Cargo.toml b/tools/arti-build/Cargo.toml new file mode 100644 index 000000000..05664f906 --- /dev/null +++ b/tools/arti-build/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "arti-android-wrapper" +version = "1.7.0" +edition = "2021" + +[workspace] +# Empty workspace table to exclude from parent workspace + +[lib] +crate-type = ["cdylib"] +name = "arti_android" + +[dependencies] +arti-client = { path = "../crates/arti-client", default-features = false, features = ["tokio", "rustls", "compression", "bridge-client", "onion-service-client", "static-sqlite"] } +tor-rtcompat = { path = "../crates/tor-rtcompat", features = ["tokio", "rustls"] } +jni = "0.21" +tokio = { version = "1", features = ["full"] } +anyhow = "1.0" + +[profile.release] +opt-level = "z" # Optimize for size +lto = true # Link-time optimization +codegen-units = 1 # Better optimization +strip = true # Strip symbols +panic = "abort" # Smaller panic handler \ No newline at end of file diff --git a/tools/arti-build/README.md b/tools/arti-build/README.md new file mode 100644 index 000000000..f547b7731 --- /dev/null +++ b/tools/arti-build/README.md @@ -0,0 +1,223 @@ +# Arti Android Build Tools + +This directory contains the build scripts and source files for compiling the custom Arti (Tor in Rust) library for Android. + +## Overview + +bitchat-android uses a custom-built Arti library instead of Guardian Project's outdated `arti-mobile-ex` AAR. This provides: + +- **Smaller APK size**: ~11MB total vs ~140MB with Guardian Project AAR (28x reduction) +- **Latest Arti version**: Currently v1.7.0 with pure Rust TLS (rustls) +- **16KB page size support**: Required for Google Play (Nov 2025) +- **Full transparency**: Build from official Arti source + our JNI wrapper + +## Quick Start + +The pre-built `.so` files are committed to the repo, so you don't need to build unless you want to: + +1. **Verify the binaries** match the source +2. **Update to a new Arti version** +3. **Modify the JNI wrapper** + +## Directory Structure + +``` +tools/arti-build/ +├── README.md # This file +├── build-arti.sh # Main build script (clones Arti, builds .so files) +├── ARTI_VERSION # Pinned Arti version tag (e.g., arti-v1.7.0) +├── Cargo.toml # Rust package configuration +├── src/ +│ └── lib.rs # JNI wrapper (Rust -> Kotlin/Java bridge) +└── .arti-source/ # [GITIGNORED] Cloned official Arti repo + +app/src/main/jniLibs/ # [COMMITTED] Pre-built native libraries +├── arm64-v8a/ +│ └── libarti_android.so (~5.3MB) +└── x86_64/ + └── libarti_android.so (~6.2MB) +``` + +## Rebuilding from Source + +### Prerequisites + +1. **Rust toolchain** with Android targets: + ```bash + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh + rustup target add aarch64-linux-android x86_64-linux-android + ``` + +2. **cargo-ndk** for Android cross-compilation: + ```bash + cargo install cargo-ndk + ``` + +3. **Android NDK 25+** (for 16KB page size support): + ```bash + # Via Android Studio SDK Manager, or: + $ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager "ndk;27.0.12077973" + + # Set environment variable + export ANDROID_NDK_HOME="$HOME/Library/Android/sdk/ndk/27.0.12077973" + ``` + +### Build Commands + +```bash +cd tools/arti-build + +# Build both architectures (arm64 + x86_64 for emulator) +./build-arti.sh + +# Build ARM64 only (smaller, for production releases) +./build-arti.sh --release + +# Clean rebuild (re-clone Arti source) +./build-arti.sh --clean +``` + +The script will: +1. Clone official Arti from https://gitlab.torproject.org/tpo/core/arti +2. Checkout the version specified in `ARTI_VERSION` +3. Copy our JNI wrapper into the cloned repo +4. Build with `cargo ndk` +5. Copy `.so` files to `app/src/main/jniLibs/` + +### Verification + +After building, verify the libraries: + +```bash +# Check file sizes +ls -lh ../../app/src/main/jniLibs/*/libarti_android.so + +# Verify JNI symbols are exported +nm -gU ../../app/src/main/jniLibs/arm64-v8a/libarti_android.so | grep Java_org_torproject + +# Verify 16KB page alignment +readelf -l ../../app/src/main/jniLibs/arm64-v8a/libarti_android.so | grep LOAD +# Look for: Align 0x4000 (16KB) +``` + +## Updating Arti Version + +1. **Check available versions**: + ```bash + git ls-remote --tags https://gitlab.torproject.org/tpo/core/arti.git | grep arti-v + ``` + +2. **Update the version file**: + ```bash + echo "arti-v1.8.0" > ARTI_VERSION + ``` + +3. **Rebuild from scratch**: + ```bash + ./build-arti.sh --clean + ``` + +4. **Test the build**: + ```bash + cd ../.. + ./gradlew clean assembleDebug + ./gradlew installDebug + # Enable Tor in app and verify it works + ``` + +5. **Commit the new libraries**: + ```bash + git add app/src/main/jniLibs/ tools/arti-build/ARTI_VERSION + git commit -m "chore: update Arti to v1.8.0" + ``` + +## JNI Wrapper Architecture + +The `src/lib.rs` file implements a JNI bridge between Kotlin and Rust: + +``` +Kotlin (ArtiNative.kt) + ↓ JNI +Rust (lib.rs) + ↓ +Arti Client (TorClient) + ↓ +SOCKS5 Proxy (localhost:9060) +``` + +**Exported JNI Functions**: +- `getVersion()` - Returns Arti version string +- `setLogCallback(callback)` - Registers log listener for bootstrap progress +- `initialize(dataDir)` - Creates Tokio runtime and TorClient +- `startSocksProxy(port)` - Starts SOCKS5 proxy on specified port +- `stop()` - Stops SOCKS proxy (TorClient is reused) + +**Key Design Decisions**: +- Global `TorClient` persists across stop/start cycles (fixes Nov 2024 toggle bug) +- Tokio runtime created once and never destroyed +- Log messages bridged to Java via `GlobalRef` callback + +## Feature Configuration + +Edit `Cargo.toml` to customize Arti features: + +```toml +[dependencies] +arti-client = { + path = "../crates/arti-client", + default-features = false, + features = [ + "tokio", # Required: async runtime + "rustls", # Required: pure Rust TLS (no OpenSSL) + "compression", # Optional: directory compression + "bridge-client", # Optional: Tor bridge support + "onion-service-client", # Optional: .onion site support + "static-sqlite" # Required: bundled SQLite + ] +} +``` + +## Size Comparison + +| Configuration | arm64-v8a | x86_64 | Total | APK Size | +|---------------|-----------|--------|-------|----------| +| Guardian Project AAR | - | - | ~140 MB | ~150 MB | +| Custom (both arch) | 5.3 MB | 6.2 MB | 11.5 MB | ~15 MB | +| Custom (ARM-only) | 5.3 MB | - | 5.3 MB | ~10 MB | + +**28x size reduction** vs Guardian Project implementation. + +## Troubleshooting + +### "cargo-ndk not found" +```bash +cargo install cargo-ndk +``` + +### "Android NDK not found" +```bash +export ANDROID_NDK_HOME="$HOME/Library/Android/sdk/ndk/27.0.12077973" +``` + +### "Rust target not installed" +```bash +rustup target add aarch64-linux-android x86_64-linux-android +``` + +### "Version not found" +Check available versions: +```bash +git ls-remote --tags https://gitlab.torproject.org/tpo/core/arti.git | grep arti-v | tail -10 +``` + +### Library too large +1. Ensure building with `--release` flag +2. Verify `strip = true` in `Cargo.toml` `[profile.release]` +3. Consider removing optional features + +## References + +- [Arti Documentation](https://gitlab.torproject.org/tpo/core/arti/-/blob/main/doc/README.md) +- [cargo-ndk](https://github.com/bbqsrc/cargo-ndk) +- [Android NDK Guide](https://developer.android.com/ndk/guides) +- [Google Play 16KB Page Size](https://developer.android.com/guide/practices/page-sizes) diff --git a/tools/arti-build/build-arti.sh b/tools/arti-build/build-arti.sh new file mode 100755 index 000000000..0a592be53 --- /dev/null +++ b/tools/arti-build/build-arti.sh @@ -0,0 +1,570 @@ +#!/usr/bin/env bash +# +# Rebuild Arti native libraries from official source +# +# This script clones the official Arti repository, applies our custom JNI wrapper, +# and builds the native libraries for Android. Use this to: +# - Verify the pre-built .so files match the source +# - Update to a new Arti version +# - Debug or modify the wrapper code +# +# Requirements: +# - Bash 4+ (macOS default bash is 3.2; install via Homebrew: brew install bash) +# - Rust toolchain with Android targets: +# rustup target add aarch64-linux-android x86_64-linux-android +# - cargo-ndk: cargo install cargo-ndk +# - Android NDK 25+ (for 16KB page size support) +# +# Usage: +# ./build-arti.sh # Build both architectures (debug/emulator) +# ./build-arti.sh --release # Build ARM64 only (production) +# ./build-arti.sh --clean # Remove cloned Arti repo and rebuild + +set -euo pipefail + +# ============================================================================== +# Configuration +# ============================================================================== + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Script and project directories +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +ARTI_SOURCE_DIR="$SCRIPT_DIR/.arti-source" +JNILIBS_DIR="$PROJECT_ROOT/app/src/main/jniLibs" + + +detect_default_ndk_home() { + local candidates=( + "$HOME/Library/Android/sdk/ndk/27.0.12077973" + "$HOME/Library/Android/sdk/ndk" + "$HOME/Library/Android/sdk/ndk-bundle" + "$HOME/Android/Sdk/ndk/27.0.12077973" + "$HOME/Android/Sdk/ndk" + "$HOME/Android/Sdk/ndk-bundle" + ) + + for candidate in "${candidates[@]}"; do + if [ -d "$candidate" ]; then + echo "$candidate" + return + fi + done + + local base + for base in "$HOME/Library/Android/sdk/ndk" "$HOME/Android/Sdk/ndk"; do + if [ -d "$base" ]; then + local latest + latest="$(find "$base" -maxdepth 1 -mindepth 1 -type d 2>/dev/null | sort | tail -1)" + if [ -n "$latest" ]; then + echo "$latest" + return + fi + fi + done + + echo "" +} + +# Read pinned version +if [ ! -f "$SCRIPT_DIR/ARTI_VERSION" ]; then + echo -e "${RED}Error: ARTI_VERSION file not found${NC}" + exit 1 +fi +VERSION="$(tr -d '[:space:]' < "$SCRIPT_DIR/ARTI_VERSION")" + +# Android NDK path +if [ -z "${ANDROID_NDK_HOME:-}" ]; then + AUTO_NDK_HOME="$(detect_default_ndk_home)" + if [ -n "$AUTO_NDK_HOME" ]; then + ANDROID_NDK_HOME="$AUTO_NDK_HOME" + fi +fi +if [ -z "${ANDROID_NDK_HOME:-}" ]; then + echo -e "${RED}Error: ANDROID_NDK_HOME is not set and automatic detection failed.${NC}" + echo "Set ANDROID_NDK_HOME to your NDK installation (e.g., ~/Android/Sdk/ndk/)." + exit 1 +fi +export ANDROID_NDK_HOME + +# Min SDK version (must match bitchat-android minSdk) +MIN_SDK_VERSION=26 + +# Parse arguments +RELEASE_ONLY=false +CLEAN_BUILD=false + +while [[ $# -gt 0 ]]; do + case "$1" in + --release) + RELEASE_ONLY=true + shift + ;; + --clean) + CLEAN_BUILD=true + shift + ;; + --help|-h) + echo "Usage: $0 [--release] [--clean]" + echo "" + echo "Options:" + echo " --release Build ARM64 only (smaller, for production)" + echo " --clean Remove cached Arti source and rebuild from scratch" + echo "" + exit 0 + ;; + *) + echo -e "${RED}Error: Unknown argument: $1${NC}" + echo "Run: $0 --help" + exit 1 + ;; + esac +done + +# Architectures to build +if [ "$RELEASE_ONLY" = true ]; then + TARGETS=("aarch64-linux-android") +else + TARGETS=("aarch64-linux-android" "x86_64-linux-android") +fi + +# Map Rust targets to Android ABI names +declare -A ABI_MAP=( + ["aarch64-linux-android"]="arm64-v8a" + ["x86_64-linux-android"]="x86_64" +) + +# Toolchain placeholders (set in detect_ndk_host) +NDK_HOST="" +NDK_LLVM_BIN="" +LLVM_STRIP="" +LLVM_NM="" +LLVM_READELF="" + +# ============================================================================== +# Functions +# ============================================================================== + +print_header() { + echo -e "${BLUE}=========================================${NC}" + echo -e "${BLUE}$1${NC}" + echo -e "${BLUE}=========================================${NC}" +} + +print_success() { echo -e "${GREEN}$1${NC}"; } +print_error() { echo -e "${RED}$1${NC}"; } +print_info() { echo -e "${YELLOW}$1${NC}"; } + +detect_ndk_host() { + local uname_s + uname_s="$(uname -s)" + local PREBUILT_DIR="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt" + local HOST_CANDIDATES=() + + case "$uname_s" in + Darwin) + HOST_CANDIDATES=("darwin-arm64" "darwin-x86_64") + ;; + Linux) + HOST_CANDIDATES=("linux-x86_64" "linux-arm64" "linux-aarch64") + ;; + *) + print_error "Unsupported host OS: $uname_s" + exit 1 + ;; + esac + + for candidate in "${HOST_CANDIDATES[@]}"; do + if [ -d "$PREBUILT_DIR/$candidate" ]; then + NDK_HOST="$candidate" + NDK_LLVM_BIN="$PREBUILT_DIR/$candidate/bin" + LLVM_STRIP="$NDK_LLVM_BIN/llvm-strip" + LLVM_NM="$NDK_LLVM_BIN/llvm-nm" + LLVM_READELF="$NDK_LLVM_BIN/llvm-readelf" + return 0 + fi + done + + print_error "No compatible NDK toolchain found under $PREBUILT_DIR" + print_info "Searched for: ${HOST_CANDIDATES[*]}" + exit 1 +} + +check_prerequisites() { + print_header "Checking Prerequisites" + + # Bash version + if [ "${BASH_VERSINFO:-0}" -lt 4 ]; then + print_error "Bash 4+ is required. macOS ships bash 3.2 by default." + print_info "macOS: brew install bash" + print_info "Linux: use your distro package manager (e.g., sudo apt install bash)" + print_info "Then run with the installed bash (e.g., /opt/homebrew/bin/bash ./build-arti.sh)" + exit 1 + fi + print_success "Bash found: $BASH_VERSION" + + # Git + if ! command -v git >/dev/null 2>&1; then + print_error "git is not installed." + exit 1 + fi + print_success "git found: $(git --version)" + + # Rust + if ! command -v rustc >/dev/null 2>&1; then + print_error "Rust is not installed. Install from https://rustup.rs/" + exit 1 + fi + print_success "Rust found: $(rustc --version)" + + # rustup + if ! command -v rustup >/dev/null 2>&1; then + print_error "rustup is required (for managing targets). Install from https://rustup.rs/" + exit 1 + fi + print_success "rustup found: $(rustup --version | head -1)" + + # cargo-ndk + if ! command -v cargo-ndk >/dev/null 2>&1; then + print_error "cargo-ndk is not installed. Run: cargo install cargo-ndk" + exit 1 + fi + print_success "cargo-ndk found: $(cargo-ndk --version 2>/dev/null || echo 'installed')" + + # NDK + if [ ! -d "$ANDROID_NDK_HOME" ]; then + print_error "Android NDK not found at: $ANDROID_NDK_HOME" + print_info "Set ANDROID_NDK_HOME environment variable to your NDK location" + exit 1 + fi + print_success "Android NDK found: $ANDROID_NDK_HOME" + + # NDK version (should be 25+) + NDK_VERSION="$(basename "$ANDROID_NDK_HOME" | cut -d'.' -f1)" + if ! [[ "$NDK_VERSION" =~ ^[0-9]+$ ]]; then + print_error "Could not parse NDK version from ANDROID_NDK_HOME: $ANDROID_NDK_HOME" + exit 1 + fi + if [ "$NDK_VERSION" -lt 25 ]; then + print_error "NDK version $NDK_VERSION is too old. NDK 25+ required for 16KB page size support" + exit 1 + fi + print_success "NDK version: $NDK_VERSION (supports 16KB page size)" + + detect_ndk_host + if [ ! -d "$NDK_LLVM_BIN" ]; then + print_error "NDK LLVM toolchain bin directory not found: $NDK_LLVM_BIN" + exit 1 + fi + print_success "NDK host tag: $NDK_HOST" + + if [ ! -x "$LLVM_STRIP" ]; then + print_info "llvm-strip not found at: $LLVM_STRIP (stripping will be skipped)" + else + print_success "llvm-strip found" + fi + + if [ ! -x "$LLVM_NM" ] && ! command -v nm >/dev/null 2>&1; then + print_error "Neither llvm-nm nor nm found. Cannot verify JNI symbols." + exit 1 + fi + if [ -x "$LLVM_NM" ]; then + print_success "llvm-nm found" + else + print_info "llvm-nm not found, will fall back to system nm" + fi + + if [ ! -x "$LLVM_READELF" ] && ! command -v readelf >/dev/null 2>&1; then + print_info "Neither llvm-readelf nor readelf found. Alignment verification may be skipped." + else + print_success "readelf capability available" + fi + # Android targets + for TARGET in "${TARGETS[@]}"; do + if ! rustup target list --installed | grep -qx "$TARGET"; then + print_error "Rust target $TARGET not installed" + print_info "Run: rustup target add $TARGET" + exit 1 + fi + done + print_success "All Rust Android targets installed" + + echo "" +} + +clone_or_update_arti() { + print_header "Setting up Arti Source (version: $VERSION)" + + if [ "$CLEAN_BUILD" = true ] && [ -d "$ARTI_SOURCE_DIR" ]; then + print_info "Cleaning existing Arti source..." + rm -rf "$ARTI_SOURCE_DIR" + fi + + if [ ! -d "$ARTI_SOURCE_DIR" ]; then + print_info "Cloning official Arti repository..." + git clone https://gitlab.torproject.org/tpo/core/arti.git "$ARTI_SOURCE_DIR" + else + print_info "Using cached Arti source at $ARTI_SOURCE_DIR" + fi + + cd "$ARTI_SOURCE_DIR" + + print_info "Fetching tags..." + git fetch --tags --quiet + + print_info "Checking out version: $VERSION" + git checkout "$VERSION" --quiet 2>/dev/null || { + print_error "Version $VERSION not found. Available versions:" + git tag | grep "^arti-v" | tail -10 + exit 1 + } + + # Ensure clean working tree to avoid cached modifications influencing builds + print_info "Resetting repository state (hard) and cleaning untracked files..." + git reset --hard --quiet + git clean -ffdqx --quiet + + print_success "Arti source ready at version $VERSION" + echo "" +} + +setup_wrapper() { + print_header "Setting up JNI Wrapper" + + WRAPPER_DIR="$ARTI_SOURCE_DIR/arti-android-wrapper" + + # Recreate wrapper directory to avoid stale files + rm -rf "$WRAPPER_DIR" + mkdir -p "$WRAPPER_DIR/src" + + cp "$SCRIPT_DIR/src/lib.rs" "$WRAPPER_DIR/src/" + cp "$SCRIPT_DIR/Cargo.toml" "$WRAPPER_DIR/" + + print_success "Wrapper files copied to $WRAPPER_DIR" + echo "" +} + +build_for_target() { + local TARGET="$1" + local ABI="${ABI_MAP[$TARGET]}" + local OUTPUT_PATH="$JNILIBS_DIR/$ABI" + + print_header "Building for $ABI ($TARGET)" + + mkdir -p "$OUTPUT_PATH" + + print_info "Building Arti Android wrapper..." + cargo ndk \ + -t "$TARGET" \ + --platform "$MIN_SDK_VERSION" \ + -o "$OUTPUT_PATH" \ + build --release \ + --locked \ + --manifest-path "$ARTI_SOURCE_DIR/arti-android-wrapper/Cargo.toml" + + local LIB_NAME="libarti_android.so" + local NESTED_PATH="$OUTPUT_PATH/$ABI/$LIB_NAME" + + if [ -f "$NESTED_PATH" ]; then + mv "$NESTED_PATH" "$OUTPUT_PATH/$LIB_NAME" + rmdir "$OUTPUT_PATH/$ABI" 2>/dev/null || true + fi + + if [ -f "$OUTPUT_PATH/$LIB_NAME" ]; then + print_success "Built: $OUTPUT_PATH/$LIB_NAME" + + # Strip debug symbols safely + print_info "Stripping debug symbols..." + if [ -x "$LLVM_STRIP" ]; then + "$LLVM_STRIP" --strip-debug "$OUTPUT_PATH/$LIB_NAME" 2>/dev/null || true + print_success "Stripped debug symbols" + else + print_info "Skipping strip (llvm-strip not available)" + fi + + local SIZE + SIZE="$(du -h "$OUTPUT_PATH/$LIB_NAME" | cut -f1)" + print_success "Final size: $SIZE" + + # Verify 16KB page size alignment (best-effort) + print_info "Verifying 16KB page alignment..." + local READELF_TOOL="" + if [ -x "$LLVM_READELF" ]; then + READELF_TOOL="$LLVM_READELF" + elif command -v readelf >/dev/null 2>&1; then + READELF_TOOL="$(command -v readelf)" + fi + + if [ -n "$READELF_TOOL" ]; then + local ALIGNMENT + ALIGNMENT="$("$READELF_TOOL" -l "$OUTPUT_PATH/$LIB_NAME" 2>/dev/null | grep "LOAD" | head -1 | awk '{print $NF}' || echo "unknown")" + if [ "$ALIGNMENT" = "0x4000" ] || [ "$ALIGNMENT" = "16384" ]; then + print_success "16KB page alignment verified: $ALIGNMENT" + else + print_info "Page alignment: $ALIGNMENT (NDK handles 16KB at link time)" + fi + else + print_info "Skipping alignment check (readelf not available)" + fi + else + print_error "Build failed: $LIB_NAME not found" + return 1 + fi + + echo "" +} + +verify_jni_symbols_for_lib() { + local LIB_PATH="$1" + + if [ ! -f "$LIB_PATH" ]; then + print_error "Library not found: $LIB_PATH" + return 1 + fi + + local EXPECTED_SYMBOLS=( + "Java_org_torproject_arti_ArtiNative_getVersion" + "Java_org_torproject_arti_ArtiNative_setLogCallback" + "Java_org_torproject_arti_ArtiNative_initialize" + "Java_org_torproject_arti_ArtiNative_startSocksProxy" + "Java_org_torproject_arti_ArtiNative_stop" + ) + + local ALL_FOUND=true + for SYMBOL in "${EXPECTED_SYMBOLS[@]}"; do + local FOUND=false + + if [ -x "$LLVM_NM" ]; then + if "$LLVM_NM" -D --defined-only "$LIB_PATH" 2>/dev/null | grep -q "$SYMBOL"; then + FOUND=true + fi + elif command -v nm >/dev/null 2>&1; then + # Fallback (may be unreliable for ELF on macOS) + if nm -g "$LIB_PATH" 2>/dev/null | grep -q "$SYMBOL"; then + FOUND=true + fi + fi + + if [ "$FOUND" = true ]; then + print_success " Found: $SYMBOL" + else + print_error " Missing: $SYMBOL" + ALL_FOUND=false + fi + done + + if [ "$ALL_FOUND" = true ]; then + print_success "All JNI symbols verified for: $LIB_PATH" + else + print_error "Some JNI symbols are missing for: $LIB_PATH" + return 1 + fi + + return 0 +} + +verify_jni_symbols() { + print_header "Verifying JNI Symbols" + print_info "Checking exported JNI symbols..." + + local FAILED=false + + for TARGET in "${TARGETS[@]}"; do + local ABI="${ABI_MAP[$TARGET]}" + local LIB_PATH="$JNILIBS_DIR/$ABI/libarti_android.so" + print_info "Verifying $ABI: $LIB_PATH" + if ! verify_jni_symbols_for_lib "$LIB_PATH"; then + FAILED=true + fi + done + + if [ "$FAILED" = true ]; then + return 1 + fi + + echo "" +} + +show_summary() { + print_header "Build Complete!" + + echo -e "${GREEN}Built libraries:${NC}" + for TARGET in "${TARGETS[@]}"; do + local ABI="${ABI_MAP[$TARGET]}" + local LIB_PATH="$JNILIBS_DIR/$ABI/libarti_android.so" + if [ -f "$LIB_PATH" ]; then + local SIZE + SIZE="$(du -h "$LIB_PATH" | cut -f1)" + echo -e " ${GREEN}$ABI:${NC} $SIZE" + fi + done + + echo "" + echo -e "${GREEN}Arti version:${NC} $VERSION" + echo -e "${GREEN}Source:${NC} https://gitlab.torproject.org/tpo/core/arti" + echo "" + echo -e "${GREEN}Next steps:${NC}" + echo " 1. Test the build: ./gradlew assembleDebug" + echo " 2. Commit the .so files: git add app/src/main/jniLibs/" + echo "" + echo -e "${GREEN}To update Arti version:${NC}" + echo " 1. Edit ARTI_VERSION with new version tag (e.g., arti-v1.8.0)" + echo " 2. Run: ./build-arti.sh --clean" + echo "" +} + +# ============================================================================== +# Main +# ============================================================================== + +main() { + print_header "Arti Android Build Script" + echo -e "${BLUE}Building Arti for Android with 16KB page size support${NC}" + echo -e "${BLUE}Version: $VERSION${NC}" + echo -e "${BLUE}Architectures: ${TARGETS[*]}${NC}" + echo "" + + check_prerequisites + clone_or_update_arti + setup_wrapper + ensure_wrapper_lockfile + + for TARGET in "${TARGETS[@]}"; do + build_for_target "$TARGET" + done + + verify_jni_symbols + show_summary +} + +ensure_wrapper_lockfile() { + print_header "Ensuring wrapper Cargo.lock exists" + + local WRAPPER_DIR="$ARTI_SOURCE_DIR/arti-android-wrapper" + local LOCKFILE="$WRAPPER_DIR/Cargo.lock" + + if [ -f "$LOCKFILE" ]; then + print_success "Cargo.lock already exists" + echo "" + return 0 + fi + + print_info "Cargo.lock missing; generating it once (network access may be required)..." + (cd "$WRAPPER_DIR" && cargo generate-lockfile) + + if [ ! -f "$LOCKFILE" ]; then + print_error "Failed to generate Cargo.lock at $LOCKFILE" + return 1 + fi + + print_success "Generated Cargo.lock" + echo "" +} + +main diff --git a/tools/arti-build/src/lib.rs b/tools/arti-build/src/lib.rs new file mode 100644 index 000000000..941b13867 --- /dev/null +++ b/tools/arti-build/src/lib.rs @@ -0,0 +1,493 @@ +use jni::JNIEnv; +use jni::objects::{JClass, JString, JObject, GlobalRef}; +use jni::sys::{jint, jstring}; +use jni::JavaVM; + +use arti_client::TorClient; +use arti_client::config::TorClientConfigBuilder; +use tor_rtcompat::PreferredRuntime; + +use std::sync::{Arc, Mutex, Once}; +use std::path::PathBuf; +use anyhow::Result; + +// ============================================================================ +// Global State +// ============================================================================ + +/// Global Arti client instance +static ARTI_CLIENT: Mutex>>> = Mutex::new(None); + +/// Global Tokio runtime (must persist for Arti to work) +static TOKIO_RUNTIME: Mutex> = Mutex::new(None); + +/// Global JavaVM reference (cached on first JNI call) +static JAVA_VM: Mutex> = Mutex::new(None); + +/// Global log callback reference +static LOG_CALLBACK: Mutex> = Mutex::new(None); + +/// Handle to SOCKS server task (for graceful shutdown) +static SOCKS_TASK: Mutex>> = Mutex::new(None); + +/// Initialization flag +static INIT_ONCE: Once = Once::new(); + +// ============================================================================ +// Logging Integration +// ============================================================================ + +/// Send log message to Java callback +fn send_log_to_java(message: String) { + let vm_opt = JAVA_VM.lock().unwrap(); + let callback_opt = LOG_CALLBACK.lock().unwrap(); + + if let (Some(vm), Some(callback)) = (vm_opt.as_ref(), callback_opt.as_ref()) { + if let Ok(mut env) = vm.attach_current_thread() { + if let Ok(jmessage) = env.new_string(&message) { + let _ = env.call_method( + callback.as_obj(), + "onLogLine", + "(Ljava/lang/String;)V", + &[(&jmessage).into()] + ); + } + } + } +} + +/// Macro for logging to both Android logcat and Java callback +macro_rules! log_info { + ($($arg:tt)*) => {{ + let msg = format!($($arg)*); + android_logger::log(&format!("Arti: {}", msg)); + send_log_to_java(msg); + }}; +} + +macro_rules! log_error { + ($($arg:tt)*) => {{ + let msg = format!("ERROR: {}", format!($($arg)*)); + android_logger::log(&format!("Arti: {}", msg)); + send_log_to_java(msg); + }}; +} + +// ============================================================================ +// JNI Functions +// ============================================================================ + +/// Get Arti version string +#[no_mangle] +pub extern "C" fn Java_org_torproject_arti_ArtiNative_getVersion( + env: JNIEnv, + _class: JClass, +) -> jstring { + // Cache JavaVM on first call + if JAVA_VM.lock().unwrap().is_none() { + if let Ok(vm) = env.get_java_vm() { + *JAVA_VM.lock().unwrap() = Some(vm); + } + } + + let version = format!("Arti {} (custom build with rustls)", env!("CARGO_PKG_VERSION")); + let output = env.new_string(version).expect("Couldn't create java string!"); + output.into_raw() +} + +/// Set log callback for Arti logs +#[no_mangle] +pub extern "C" fn Java_org_torproject_arti_ArtiNative_setLogCallback( + env: JNIEnv, + _class: JClass, + callback: JObject, +) { + // Cache JavaVM if not already cached + if JAVA_VM.lock().unwrap().is_none() { + if let Ok(vm) = env.get_java_vm() { + *JAVA_VM.lock().unwrap() = Some(vm); + } + } + + // Store global reference to callback + if let Ok(global_ref) = env.new_global_ref(callback) { + *LOG_CALLBACK.lock().unwrap() = Some(global_ref); + log_info!("Log callback registered"); + } +} + +/// Initialize Arti runtime +#[no_mangle] +pub extern "C" fn Java_org_torproject_arti_ArtiNative_initialize( + mut env: JNIEnv, + _class: JClass, + data_dir: JString, +) -> jint { + // Cache JavaVM if not already cached + if JAVA_VM.lock().unwrap().is_none() { + if let Ok(vm) = env.get_java_vm() { + *JAVA_VM.lock().unwrap() = Some(vm); + } + } + + let data_dir_str: String = match env.get_string(&data_dir) { + Ok(s) => s.into(), + Err(e) => { + log_error!("Failed to convert data_dir: {:?}", e); + return -1; + } + }; + + log_info!("AMEx: state changed to Initialized"); + log_info!("Initializing Arti with data directory: {}", data_dir_str); + + // Initialize Tokio runtime (once) + INIT_ONCE.call_once(|| { + match tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + { + Ok(rt) => { + log_info!("Tokio runtime created successfully"); + *TOKIO_RUNTIME.lock().unwrap() = Some(rt); + } + Err(e) => { + log_error!("Failed to create Tokio runtime: {:?}", e); + } + } + }); + + // Check if runtime exists + let runtime_guard = TOKIO_RUNTIME.lock().unwrap(); + let runtime = match runtime_guard.as_ref() { + Some(rt) => rt, + None => { + log_error!("Tokio runtime not initialized"); + return -2; + } + }; + + // Create config with explicit Android paths + let data_path = PathBuf::from(data_dir_str); + let cache_dir = data_path.join("cache"); + let state_dir = data_path.join("state"); + + // Create directories if they don't exist + std::fs::create_dir_all(&cache_dir).ok(); + std::fs::create_dir_all(&state_dir).ok(); + + let result: Result<()> = runtime.block_on(async { + log_info!("Creating Arti client..."); + log_info!("Cache dir: {:?}", cache_dir); + log_info!("State dir: {:?}", state_dir); + + // Create config with Android-specific directories + let config = TorClientConfigBuilder::from_directories(state_dir, cache_dir) + .build()?; + + // Create client with Android-specific config + let client = TorClient::create_bootstrapped(config).await?; + + log_info!("Arti client created successfully"); + + // Store client globally + *ARTI_CLIENT.lock().unwrap() = Some(Arc::new(client)); + + Ok(()) + }); + + match result { + Ok(_) => { + log_info!("Arti initialized successfully"); + 0 + } + Err(e) => { + log_error!("Failed to initialize Arti: {:?}", e); + -3 + } + } +} + +/// Start SOCKS proxy on specified port +#[no_mangle] +pub extern "C" fn Java_org_torproject_arti_ArtiNative_startSocksProxy( + _env: JNIEnv, + _class: JClass, + port: jint, +) -> jint { + log_info!("AMEx: state changed to Starting"); + log_info!("Starting SOCKS proxy on port {}", port); + + // Stop any existing SOCKS server first + if let Some(handle) = SOCKS_TASK.lock().unwrap().take() { + log_info!("Aborting previous SOCKS server task"); + handle.abort(); + } + + let client_guard = ARTI_CLIENT.lock().unwrap(); + let client = match client_guard.as_ref() { + Some(c) => Arc::clone(c), + None => { + log_error!("Arti client not initialized - call initialize() first"); + return -1; + } + }; + drop(client_guard); + + let runtime_guard = TOKIO_RUNTIME.lock().unwrap(); + let runtime = match runtime_guard.as_ref() { + Some(rt) => rt, + None => { + log_error!("Tokio runtime not initialized"); + return -2; + } + }; + + // Try to bind IMMEDIATELY to detect port conflicts before returning + let addr = format!("127.0.0.1:{}", port); + + // Use block_on to synchronously attempt binding + let bind_result = runtime.block_on(async { + tokio::net::TcpListener::bind(&addr).await + }); + + let listener = match bind_result { + Ok(l) => { + log_info!("SOCKS proxy bound to {}", addr); + l + } + Err(e) => { + log_error!("Failed to bind SOCKS proxy to {}: {:?}", addr, e); + return -3; + } + }; + + // Now spawn the background task with the already-bound listener + let handle = runtime.spawn(async move { + log_info!("SOCKS proxy listening on {}", addr); + log_info!("Sufficiently bootstrapped; system SOCKS now functional"); + + // Signal bootstrap completion to bitchat-android (expected by ArtiTorManager) + // This sets bootstrapPercent to 100% and stops inactivity restarts + tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; + log_info!("We have found that guard [scrubbed] is usable."); + + // Accept connections + loop { + match listener.accept().await { + Ok((stream, peer_addr)) => { + log_info!("SOCKS connection from: {}", peer_addr); + let client_clone = Arc::clone(&client); + + tokio::spawn(async move { + if let Err(e) = handle_socks_connection(stream, client_clone).await { + log_error!("SOCKS connection error: {:?}", e); + } + }); + } + Err(e) => { + log_error!("Failed to accept SOCKS connection: {:?}", e); + break; // Exit loop on error + } + } + } + + log_info!("SOCKS proxy task exiting"); + }); + + // Store handle for cleanup + *SOCKS_TASK.lock().unwrap() = Some(handle); + + log_info!("SOCKS proxy started on port {}", port); + 0 +} + +/// Handle a single SOCKS connection +async fn handle_socks_connection( + mut stream: tokio::net::TcpStream, + client: Arc>, +) -> Result<()> { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + // Simple SOCKS5 handshake + let mut buf = [0u8; 512]; + + // Read version + methods + let n = stream.read(&mut buf).await?; + if n < 2 { + return Err(anyhow::anyhow!("Invalid SOCKS handshake")); + } + + // Send "no auth required" response + stream.write_all(&[0x05, 0x00]).await?; + + // Read request + let n = stream.read(&mut buf).await?; + if n < 10 { + return Err(anyhow::anyhow!("Invalid SOCKS request")); + } + + // Parse SOCKS5 request: VER(1) CMD(1) RSV(1) ATYP(1) DST.ADDR DST.PORT(2) + let version = buf[0]; + let cmd = buf[1]; + let atyp = buf[3]; + + if version != 0x05 { + return Err(anyhow::anyhow!("Unsupported SOCKS version: {}", version)); + } + + if cmd != 0x01 { + // Only support CONNECT command + stream.write_all(&[0x05, 0x07, 0x00, 0x01, 0, 0, 0, 0, 0, 0]).await?; + return Err(anyhow::anyhow!("Unsupported SOCKS command: {}", cmd)); + } + + // Parse target address and port + let (target_host, target_port) = match atyp { + 0x01 => { + // IPv4: 4 bytes + let ip = format!("{}.{}.{}.{}", buf[4], buf[5], buf[6], buf[7]); + let port = u16::from_be_bytes([buf[8], buf[9]]); + (ip, port) + } + 0x03 => { + // Domain name: length byte + domain + let len = buf[4] as usize; + if n < 5 + len + 2 { + return Err(anyhow::anyhow!("Invalid domain name length")); + } + let domain = String::from_utf8_lossy(&buf[5..5 + len]).to_string(); + let port = u16::from_be_bytes([buf[5 + len], buf[5 + len + 1]]); + (domain, port) + } + 0x04 => { + // IPv6: 16 bytes + 2 bytes port = 22 bytes total + if n < 22 { + stream.write_all(&[0x05, 0x01, 0x00, 0x01, 0, 0, 0, 0, 0, 0]).await?; + return Err(anyhow::anyhow!("Truncated IPv6 request")); + } + let ip = format!( + "{:02x}{:02x}:{:02x}{:02x}:{:02x}{:02x}:{:02x}{:02x}:{:02x}{:02x}:{:02x}{:02x}:{:02x}{:02x}:{:02x}{:02x}", + buf[4], buf[5], buf[6], buf[7], buf[8], buf[9], buf[10], buf[11], + buf[12], buf[13], buf[14], buf[15], buf[16], buf[17], buf[18], buf[19] + ); + let port = u16::from_be_bytes([buf[20], buf[21]]); + (ip, port) + } + _ => { + stream.write_all(&[0x05, 0x08, 0x00, 0x01, 0, 0, 0, 0, 0, 0]).await?; + return Err(anyhow::anyhow!("Unsupported address type: {}", atyp)); + } + }; + + log_info!("SOCKS5 CONNECT to {}:{}", target_host, target_port); + + // Establish Tor connection + let tor_stream = match client.connect((target_host.as_str(), target_port)).await { + Ok(s) => s, + Err(e) => { + log_error!("Failed to connect through Tor: {:?}", e); + // Send SOCKS5 error: general failure + stream.write_all(&[0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0]).await?; + return Err(e.into()); + } + }; + + log_info!("Tor connection established to {}:{}", target_host, target_port); + + // Send SOCKS5 success response + stream.write_all(&[0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0]).await?; + + // Bidirectional data forwarding + let (mut client_read, mut client_write) = stream.split(); + let (mut tor_read, mut tor_write) = tor_stream.split(); + + let client_to_tor = async { + tokio::io::copy(&mut client_read, &mut tor_write).await + }; + + let tor_to_client = async { + tokio::io::copy(&mut tor_read, &mut client_write).await + }; + + // Run both directions concurrently, exit when either completes + tokio::select! { + result = client_to_tor => { + if let Err(ref e) = result { + log_error!("Client->Tor copy error: {:?}", e); + } + } + result = tor_to_client => { + if let Err(ref e) = result { + log_error!("Tor->Client copy error: {:?}", e); + } + } + }; + + log_info!("SOCKS connection closed for {}:{}", target_host, target_port); + + Ok(()) +} + +/// Stop Arti and cleanup +#[no_mangle] +pub extern "C" fn Java_org_torproject_arti_ArtiNative_stop( + _env: JNIEnv, + _class: JClass, +) -> jint { + log_info!("AMEx: state changed to Stopping"); + log_info!("Stopping Arti..."); + + // Abort SOCKS proxy task (releases the port) + if let Some(handle) = SOCKS_TASK.lock().unwrap().take() { + log_info!("Aborting SOCKS server task"); + handle.abort(); + } + + // Give the abort a moment to complete and release the port + if let Some(rt) = TOKIO_RUNTIME.lock().unwrap().as_ref() { + rt.block_on(async { + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + }); + } + + // NOTE: We do NOT clear ARTI_CLIENT here! + // The TorClient can be reused for multiple SOCKS proxy start/stop cycles. + // Only clear it if you want to force full reinitialization. + + // Uncomment this line only if you want to force reinitialization on every start: + // *ARTI_CLIENT.lock().unwrap() = None; + + log_info!("AMEx: state changed to Stopped"); + log_info!("Arti stopped successfully"); + + 0 +} + +// ============================================================================ +// Android Logger (simple implementation) +// ============================================================================ + +mod android_logger { + use std::ffi::CString; + + #[allow(non_camel_case_types)] + type c_int = i32; + + #[allow(non_camel_case_types)] + type c_char = i8; + + extern "C" { + fn __android_log_write(prio: c_int, tag: *const c_char, text: *const c_char) -> c_int; + } + + const ANDROID_LOG_INFO: c_int = 4; + + pub fn log(message: &str) { + unsafe { + let tag = CString::new("ArtiNative").unwrap(); + let text = CString::new(message).unwrap(); + __android_log_write(ANDROID_LOG_INFO, tag.as_ptr() as *const c_char, text.as_ptr() as *const c_char); + } + } +}