mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-24 21:45:21 +00:00
feat: add product flavors and TorProvider abstraction (#508)
* feat: add product flavors and TorProvider abstraction Introduces build flavors to separate Tor functionality from the standard build, reducing APK size for users who don't need Tor. - Creates `standard` and `tor` product flavors. - The `standard` flavor is the default, lightweight build. - The `tor` flavor includes the Arti (Tor) dependency and is identified by the `.tor` application ID suffix. - Adds a `TorProvider` interface and a `TorProviderFactory` to abstract Tor implementation details between flavors. Prepares architecture for optional Tor support to reduce APK size from 142MB to ~4-5MB for standard builds Related to #454 * Refactor: implement StandardTorProvider and RealTorProvider This commit refactors the Tor integration by introducing a `TorProvider` interface and creating separate implementations for 'tor' and 'standard' product flavors. - The original `TorManager` singleton has been moved into `RealTorProvider` for the 'tor' flavor. - A no-op `StandardTorProvider` is introduced for the 'standard' flavor, which reports Tor as unavailable. - A `TorProviderFactory` is used to create the appropriate provider at runtime based on the build variant. * refactor: migrate to TorProvider abstraction Replaced direct calls to the static `TorManager` with an instance obtained from `TorProviderFactory`. This change allows for different Tor implementations based on build flavors, improving modularity and abstracting the Tor provider logic. Updated `BitchatApplication`, `ChatHeader`, `OkHttpProvider`, and `AboutSheet` to use the new factory pattern for accessing Tor functionalities. * build: add flavor-specific ProGuard rules and CI/CD - Split ProGuard rules: base, standard-specific, tor-specific - Move Arti/Guardian Project rules to proguard-tor.pro - Update CI/CD workflow to build both flavors - Add separate artifact uploads for each flavor - Add descriptive release notes template CI now builds both standard (~4-5MB) and tor (~140MB) APKs." resolves #454 * refactor: centralize network reset logic Extracts the repeated network connection reset logic into a new private function `resetNetworkConnections()`. This change also replaces direct `_statusFlow.value = ...` assignments with the safer `_statusFlow.update { ... }` function to prevent race conditions. * ci: run separate build steps for flavors * feat: disable Tor toggle if not available in build * ci: parallelize builds and add conditional tor lint Optimizes CI workflow: - Parallel matrix builds (4 runners instead of sequential) - Conditional tor lint only when app/src/tor/ changes in PRs - Merged test+lint jobs to reduce setup overhead Reduces CI time by ~50% (8-11 min vs 18-26 min) * refactor: Unify Tor implementation and remove build flavors This commit refactors the Tor integration by removing the `standard` and `tor` product flavors in favor of a single, unified build that always includes a custom-built Arti (Tor) library. Key changes include: * **Removed Build Flavors:** Deleted the `standard` and `tor` product flavors from `build.gradle.kts`, simplifying the build process and CI configuration. * **Unified Tor Manager:** Replaced the `TorProvider` interface and flavor-specific implementations (`StandardTorProvider`, `RealTorProvider`) with a new singleton, `ArtiTorManager`. This class now manages the Arti lifecycle for all builds. * **Custom Arti Wrapper:** Introduced a new `ArtiProxy` class to provide a compatible API wrapper around the custom-built native Arti library (`libarti_android.so`). This replaces the dependency on the external `arti-mobile-ex` library. * **Updated Proguard:** Consolidated and updated Proguard rules into the main `proguard-rules.pro` file to keep the necessary `ArtiTorManager` and native library classes. * **CI/CD Simplification:** Updated GitHub Actions workflows (`release.yml`, `android-build.yml`) to build and release a single APK instead of separate ones for each flavor. * build: Configure ABI filters for debug and release builds For debug builds, include `x86_64` to support emulators. For release builds, only include `arm64-v8a` to minimize the final APK size. * feat: Ignore jniLibs directory This change adds the `app/src/main/jniLibs/` directory to the `.gitignore` file to prevent native libraries from being committed to the repository. * feat: Refine .gitignore for Arti build artifacts Improves the `.gitignore` file by: - Ignoring all `build/` directories except for `tools/arti-build/`. - Adding specific ignores for Arti build artifacts, including the cloned source repository and the Rust build cache directory. * feat: Update arti android native library * feat: add build script and JNI wrapper for Arti Adds a comprehensive build system for creating custom Arti (Tor in Rust) shared libraries for Android. This replaces the dependency on external, outdated AARs with a fully transparent and reproducible build process. Key changes: - Introduces `build-arti.sh`, a script to clone the official Arti repository, apply a JNI wrapper, and build `.so` files for Android. - Adds `ARTI_VERSION` to pin the build to a specific Arti release (v1.7.0). - Implements a new Rust JNI wrapper (`src/lib.rs`) that exposes core functions like `initialize`, `startSocksProxy`, and `stop` to the Android app. - Includes a `Cargo.toml` with release profile optimizations for size (`lto`, `strip`, `opt-level = "z"`). - Provides detailed documentation in `README.md` explaining the build process, prerequisites, and architecture. * build script for mac * consolidate both scripts * improve script --------- Co-authored-by: callebtc <93376500+callebtc@users.noreply.github.com>
This commit is contained in:
@@ -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 ${{ matrix.variant }} APK
|
||||
run: ./gradlew assemble${{ matrix.variant }}
|
||||
|
||||
- name: Build release APK
|
||||
run: ./gradlew assembleRelease
|
||||
|
||||
- name: Upload debug APK
|
||||
- name: Upload ${{ matrix.variant }} APK
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: debug-apk
|
||||
path: app/build/outputs/apk/debug/*.apk
|
||||
|
||||
- name: Upload release 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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
+7
-1
@@ -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/
|
||||
|
||||
+16
-2
@@ -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)
|
||||
|
||||
Vendored
+7
-4
@@ -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.**
|
||||
|
||||
@@ -3,18 +3,21 @@ package com.bitchat.android
|
||||
import android.app.Application
|
||||
import com.bitchat.android.nostr.RelayDirectory
|
||||
import com.bitchat.android.ui.theme.ThemePreferenceManager
|
||||
import com.bitchat.android.net.TorManager
|
||||
import com.bitchat.android.net.ArtiTorManager
|
||||
|
||||
/**
|
||||
* Main application class for bitchat Android
|
||||
*/
|
||||
class BitchatApplication : Application() {
|
||||
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
|
||||
// Initialize Tor first so any early network goes over Tor
|
||||
try { TorManager.init(this) } catch (_: Exception) { }
|
||||
try {
|
||||
val torProvider = ArtiTorManager.getInstance()
|
||||
torProvider.init(this)
|
||||
} catch (_: Exception){}
|
||||
|
||||
// Initialize relay directory (loads assets/nostr_relays.csv)
|
||||
RelayDirectory.initialize(this)
|
||||
|
||||
+268
-124
@@ -2,6 +2,7 @@ package com.bitchat.android.net
|
||||
|
||||
import android.app.Application
|
||||
import android.util.Log
|
||||
import com.bitchat.android.util.AppConstants
|
||||
import info.guardianproject.arti.ArtiLogListener
|
||||
import info.guardianproject.arti.ArtiProxy
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -11,6 +12,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -24,54 +26,97 @@ import java.util.concurrent.atomic.AtomicReference
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
/**
|
||||
* Manages embedded Tor lifecycle & provides SOCKS proxy address.
|
||||
* Uses Arti (Tor in Rust) for improved security and reliability.
|
||||
* Tor provider implementation using custom-built Arti (Tor-in-Rust).
|
||||
*
|
||||
* This singleton provides Tor anonymity features using a custom Arti build
|
||||
* compiled with 16KB page size support for Google Play compliance.
|
||||
*
|
||||
* Based on the original TorManager implementation.
|
||||
*/
|
||||
object TorManager {
|
||||
private const val TAG = "TorManager"
|
||||
private const val DEFAULT_SOCKS_PORT = com.bitchat.android.util.AppConstants.Tor.DEFAULT_SOCKS_PORT
|
||||
private const val RESTART_DELAY_MS = com.bitchat.android.util.AppConstants.Tor.RESTART_DELAY_MS // 2 seconds between stop/start
|
||||
private const val INACTIVITY_TIMEOUT_MS = com.bitchat.android.util.AppConstants.Tor.INACTIVITY_TIMEOUT_MS // 5 seconds of no activity before restart
|
||||
private const val MAX_RETRY_ATTEMPTS = com.bitchat.android.util.AppConstants.Tor.MAX_RETRY_ATTEMPTS
|
||||
private const val STOP_TIMEOUT_MS = com.bitchat.android.util.AppConstants.Tor.STOP_TIMEOUT_MS
|
||||
class ArtiTorManager private constructor() {
|
||||
enum class TorState {
|
||||
OFF,
|
||||
STARTING,
|
||||
BOOTSTRAPPING,
|
||||
RUNNING,
|
||||
STOPPING,
|
||||
ERROR
|
||||
}
|
||||
|
||||
data class TorStatus(
|
||||
val mode: TorMode = TorMode.OFF,
|
||||
val running: Boolean = false,
|
||||
val bootstrapPercent: Int = 0,
|
||||
val lastLogLine: String = "",
|
||||
val state: TorState = TorState.OFF
|
||||
)
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ArtiTorManager"
|
||||
private const val DEFAULT_SOCKS_PORT = AppConstants.Tor.DEFAULT_SOCKS_PORT
|
||||
private const val RESTART_DELAY_MS = AppConstants.Tor.RESTART_DELAY_MS
|
||||
private const val INACTIVITY_TIMEOUT_MS = AppConstants.Tor.INACTIVITY_TIMEOUT_MS
|
||||
private const val MAX_RETRY_ATTEMPTS = AppConstants.Tor.MAX_RETRY_ATTEMPTS
|
||||
private const val STOP_TIMEOUT_MS = AppConstants.Tor.STOP_TIMEOUT_MS
|
||||
|
||||
@Volatile
|
||||
private var INSTANCE: ArtiTorManager? = null
|
||||
|
||||
fun getInstance(): ArtiTorManager {
|
||||
return INSTANCE ?: synchronized(this) {
|
||||
INSTANCE ?: ArtiTorManager().also { INSTANCE = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val appScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
@Volatile private var initialized = false
|
||||
@Volatile private var socksAddr: InetSocketAddress? = null
|
||||
private val artiProxyRef = AtomicReference<ArtiProxy?>(null)
|
||||
@Volatile private var lastMode: TorMode = TorMode.OFF
|
||||
@Volatile
|
||||
private var initialized = false
|
||||
@Volatile
|
||||
private var socksAddr: InetSocketAddress? = null
|
||||
@Volatile
|
||||
private var artiProxy: ArtiProxy? = null
|
||||
@Volatile
|
||||
private var lastMode: TorMode = TorMode.OFF
|
||||
private val applyMutex = Mutex()
|
||||
@Volatile private var desiredMode: TorMode = TorMode.OFF
|
||||
@Volatile private var currentSocksPort: Int = DEFAULT_SOCKS_PORT
|
||||
@Volatile private var lastLogTime = AtomicLong(0L)
|
||||
@Volatile private var retryAttempts = 0
|
||||
@Volatile private var bindRetryAttempts = 0
|
||||
@Volatile
|
||||
private var desiredMode: TorMode = TorMode.OFF
|
||||
@Volatile
|
||||
private var currentSocksPort: Int = DEFAULT_SOCKS_PORT
|
||||
@Volatile
|
||||
private var lastLogTime = AtomicLong(0L)
|
||||
@Volatile
|
||||
private var retryAttempts = 0
|
||||
@Volatile
|
||||
private var bindRetryAttempts = 0
|
||||
private var inactivityJob: Job? = null
|
||||
private var retryJob: Job? = null
|
||||
private var currentApplication: Application? = null
|
||||
|
||||
private enum class LifecycleState { STOPPED, STARTING, RUNNING, STOPPING }
|
||||
@Volatile private var lifecycleState: LifecycleState = LifecycleState.STOPPED
|
||||
|
||||
enum class TorState { OFF, STARTING, BOOTSTRAPPING, RUNNING, STOPPING, ERROR }
|
||||
@Volatile
|
||||
private var lifecycleState: LifecycleState = LifecycleState.STOPPED
|
||||
|
||||
data class TorStatus(
|
||||
val mode: TorMode = TorMode.OFF,
|
||||
val running: Boolean = false,
|
||||
val bootstrapPercent: Int = 0, // kept for backwards compatibility with UI; 0 or 100 only
|
||||
val lastLogLine: String = "",
|
||||
val state: TorState = TorState.OFF
|
||||
private val _statusFlow = MutableStateFlow(
|
||||
TorStatus(
|
||||
mode = TorMode.OFF,
|
||||
running = false,
|
||||
bootstrapPercent = 0,
|
||||
lastLogLine = "",
|
||||
state = TorState.OFF
|
||||
)
|
||||
)
|
||||
|
||||
private val _status = MutableStateFlow(TorStatus())
|
||||
val statusFlow: StateFlow<TorStatus> = _status.asStateFlow()
|
||||
val statusFlow: StateFlow<TorStatus> = _statusFlow.asStateFlow()
|
||||
|
||||
private val stateChangeDeferred = AtomicReference<CompletableDeferred<TorState>?>(null)
|
||||
|
||||
fun isProxyEnabled(): Boolean {
|
||||
val s = _status.value
|
||||
return s.mode != TorMode.OFF && s.running && s.bootstrapPercent >= 100 && socksAddr != null && s.state == TorState.RUNNING
|
||||
val s = _statusFlow.value
|
||||
return s.mode != TorMode.OFF && s.running && s.bootstrapPercent >= 100 &&
|
||||
socksAddr != null && s.state == TorState.RUNNING
|
||||
}
|
||||
|
||||
fun init(application: Application) {
|
||||
@@ -82,7 +127,21 @@ object TorManager {
|
||||
currentApplication = application
|
||||
TorPreferenceManager.init(application)
|
||||
|
||||
// Apply saved mode at startup. If ON, set planned SOCKS immediately to avoid any leak.
|
||||
val logListener = ArtiLogListener { logLine ->
|
||||
val text = logLine ?: return@ArtiLogListener
|
||||
val s = text
|
||||
Log.i(TAG, "arti: $s")
|
||||
lastLogTime.set(System.currentTimeMillis())
|
||||
_statusFlow.update { it.copy(lastLogLine = s) }
|
||||
handleArtiLogLine(s)
|
||||
}
|
||||
|
||||
artiProxy = ArtiProxy.Builder(application)
|
||||
.setSocksPort(currentSocksPort)
|
||||
.setDnsPort(currentSocksPort + 1)
|
||||
.setLogListener(logListener)
|
||||
.build()
|
||||
|
||||
val savedMode = TorPreferenceManager.get(application)
|
||||
if (savedMode == TorMode.ON) {
|
||||
if (currentSocksPort < DEFAULT_SOCKS_PORT) {
|
||||
@@ -90,13 +149,15 @@ object TorManager {
|
||||
}
|
||||
desiredMode = savedMode
|
||||
socksAddr = InetSocketAddress("127.0.0.1", currentSocksPort)
|
||||
try { OkHttpProvider.reset() } catch (_: Throwable) { }
|
||||
try {
|
||||
OkHttpProvider.reset()
|
||||
} catch (_: Throwable) {
|
||||
} // Only reset OkHttp during init
|
||||
}
|
||||
appScope.launch {
|
||||
applyMode(application, savedMode)
|
||||
}
|
||||
|
||||
// Observe changes
|
||||
appScope.launch {
|
||||
TorPreferenceManager.modeFlow.collect { mode ->
|
||||
applyMode(application, mode)
|
||||
@@ -112,53 +173,63 @@ object TorManager {
|
||||
try {
|
||||
desiredMode = mode
|
||||
lastMode = mode
|
||||
val s = _status.value
|
||||
if (mode == s.mode && mode != TorMode.OFF && (lifecycleState == LifecycleState.STARTING || lifecycleState == LifecycleState.RUNNING)) {
|
||||
Log.i(TAG, "applyMode: already in progress/running mode=$mode, state=$lifecycleState; skip")
|
||||
val s = _statusFlow.value
|
||||
if (mode == s.mode && mode != TorMode.OFF &&
|
||||
(lifecycleState == LifecycleState.STARTING || lifecycleState == LifecycleState.RUNNING)
|
||||
) {
|
||||
Log.i(
|
||||
TAG,
|
||||
"applyMode: already in progress/running mode=$mode, state=$lifecycleState; skip"
|
||||
)
|
||||
return
|
||||
}
|
||||
when (mode) {
|
||||
TorMode.OFF -> {
|
||||
Log.i(TAG, "applyMode: OFF -> stopping tor")
|
||||
lifecycleState = LifecycleState.STOPPING
|
||||
_status.value = _status.value.copy(mode = TorMode.OFF, running = false, bootstrapPercent = 0, state = TorState.STOPPING)
|
||||
stopArti() // non-suspending immediate request
|
||||
// Best-effort wait for STOPPED before we declare OFF
|
||||
_statusFlow.value = _statusFlow.value.copy(
|
||||
mode = TorMode.OFF,
|
||||
running = false,
|
||||
bootstrapPercent = 0,
|
||||
state = TorState.STOPPING
|
||||
)
|
||||
stopArti()
|
||||
waitForStateTransition(target = TorState.OFF, timeoutMs = STOP_TIMEOUT_MS)
|
||||
socksAddr = null
|
||||
_status.value = _status.value.copy(mode = TorMode.OFF, running = false, bootstrapPercent = 0, state = TorState.OFF)
|
||||
_statusFlow.value = _statusFlow.value.copy(
|
||||
mode = TorMode.OFF,
|
||||
running = false,
|
||||
bootstrapPercent = 0,
|
||||
state = TorState.OFF
|
||||
)
|
||||
currentSocksPort = DEFAULT_SOCKS_PORT
|
||||
bindRetryAttempts = 0
|
||||
lifecycleState = LifecycleState.STOPPED
|
||||
// Rebuild clients WITHOUT proxy and reconnect relays
|
||||
try {
|
||||
OkHttpProvider.reset()
|
||||
com.bitchat.android.nostr.NostrRelayManager.shared.resetAllConnections()
|
||||
} catch (_: Throwable) { }
|
||||
resetNetworkConnections()
|
||||
}
|
||||
|
||||
TorMode.ON -> {
|
||||
Log.i(TAG, "applyMode: ON -> starting arti")
|
||||
// Reset port to default unless we're already using a higher port
|
||||
if (currentSocksPort < DEFAULT_SOCKS_PORT) {
|
||||
currentSocksPort = DEFAULT_SOCKS_PORT
|
||||
}
|
||||
bindRetryAttempts = 0
|
||||
lifecycleState = LifecycleState.STARTING
|
||||
_status.value = _status.value.copy(mode = TorMode.ON, running = false, bootstrapPercent = 0, state = TorState.STARTING)
|
||||
// Immediately set the planned SOCKS address so all traffic is forced through it,
|
||||
// even before Tor is fully bootstrapped. This prevents any direct connections.
|
||||
_statusFlow.value = _statusFlow.value.copy(
|
||||
mode = TorMode.ON,
|
||||
running = false,
|
||||
bootstrapPercent = 0,
|
||||
state = TorState.STARTING
|
||||
)
|
||||
socksAddr = InetSocketAddress("127.0.0.1", currentSocksPort)
|
||||
try { OkHttpProvider.reset() } catch (_: Throwable) { }
|
||||
try { com.bitchat.android.nostr.NostrRelayManager.shared.resetAllConnections() } catch (_: Throwable) { }
|
||||
resetNetworkConnections()
|
||||
startArti(application, useDelay = false)
|
||||
// Defer enabling proxy until bootstrap completes
|
||||
appScope.launch {
|
||||
waitUntilBootstrapped()
|
||||
if (_status.value.running && desiredMode == TorMode.ON) {
|
||||
if (_statusFlow.value.running && desiredMode == TorMode.ON) {
|
||||
socksAddr = InetSocketAddress("127.0.0.1", currentSocksPort)
|
||||
Log.i(TAG, "Tor ON: proxy set to ${socksAddr}")
|
||||
OkHttpProvider.reset()
|
||||
try { com.bitchat.android.nostr.NostrRelayManager.shared.resetAllConnections() } catch (_: Throwable) { }
|
||||
resetNetworkConnections()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -171,84 +242,95 @@ object TorManager {
|
||||
|
||||
private suspend fun startArti(application: Application, useDelay: Boolean = false) {
|
||||
try {
|
||||
// Ensure any previous instance is fully stopped before starting a new one
|
||||
stopArtiAndWait()
|
||||
|
||||
Log.i(TAG, "Starting Arti on port $currentSocksPort…")
|
||||
if (useDelay) {
|
||||
delay(RESTART_DELAY_MS)
|
||||
}
|
||||
|
||||
val logListener = ArtiLogListener { logLine ->
|
||||
val text = logLine ?: return@ArtiLogListener
|
||||
val s = text.toString()
|
||||
Log.i(TAG, "arti: $s")
|
||||
lastLogTime.set(System.currentTimeMillis())
|
||||
_status.value = _status.value.copy(lastLogLine = s)
|
||||
handleArtiLogLine(s)
|
||||
val proxy = artiProxy ?: run {
|
||||
Log.e(TAG, "ArtiProxy not initialized! This should not happen.")
|
||||
_statusFlow.update { it.copy(state = TorState.ERROR) }
|
||||
return
|
||||
}
|
||||
|
||||
val proxy = ArtiProxy.Builder(application)
|
||||
.setSocksPort(currentSocksPort)
|
||||
.setDnsPort(currentSocksPort + 1)
|
||||
.setLogListener(logListener)
|
||||
.build()
|
||||
|
||||
artiProxyRef.set(proxy)
|
||||
proxy.start()
|
||||
lastLogTime.set(System.currentTimeMillis())
|
||||
|
||||
_status.value = _status.value.copy(running = true, bootstrapPercent = 0, state = TorState.STARTING)
|
||||
_statusFlow.update {
|
||||
it.copy(
|
||||
running = true,
|
||||
bootstrapPercent = 0,
|
||||
state = TorState.STARTING
|
||||
)
|
||||
}
|
||||
lifecycleState = LifecycleState.RUNNING
|
||||
startInactivityMonitoring()
|
||||
|
||||
// Removed onion service startup (BLE-only file transfer in this branch)
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error starting Arti on port $currentSocksPort: ${e.message}")
|
||||
_status.value = _status.value.copy(state = TorState.ERROR)
|
||||
|
||||
// Check if this is a bind error
|
||||
_statusFlow.update { it.copy(state = TorState.ERROR) }
|
||||
|
||||
val isBindError = isBindError(e)
|
||||
if (isBindError && bindRetryAttempts < MAX_RETRY_ATTEMPTS) {
|
||||
bindRetryAttempts++
|
||||
currentSocksPort++
|
||||
Log.w(TAG, "Port bind failed (attempt $bindRetryAttempts/$MAX_RETRY_ATTEMPTS), retrying with port $currentSocksPort")
|
||||
// Update planned SOCKS address immediately so all new connections target the new port
|
||||
Log.w(
|
||||
TAG,
|
||||
"Port bind failed (attempt $bindRetryAttempts/$MAX_RETRY_ATTEMPTS), retrying with port $currentSocksPort"
|
||||
)
|
||||
socksAddr = InetSocketAddress("127.0.0.1", currentSocksPort)
|
||||
try { OkHttpProvider.reset() } catch (_: Throwable) { }
|
||||
try { com.bitchat.android.nostr.NostrRelayManager.shared.resetAllConnections() } catch (_: Throwable) { }
|
||||
// Immediate retry with incremented port, no exponential backoff for bind errors
|
||||
resetNetworkConnections()
|
||||
startArti(application, useDelay = false)
|
||||
} else if (isBindError) {
|
||||
Log.e(TAG, "Max bind retry attempts reached ($MAX_RETRY_ATTEMPTS), giving up")
|
||||
lifecycleState = LifecycleState.STOPPED
|
||||
_status.value = _status.value.copy(running = false, bootstrapPercent = 0, state = TorState.ERROR)
|
||||
_statusFlow.update {
|
||||
it.copy(
|
||||
running = false,
|
||||
bootstrapPercent = 0,
|
||||
state = TorState.ERROR
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// For non-bind errors, use the existing retry mechanism
|
||||
scheduleRetry(application)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the exception indicates a port binding failure
|
||||
*/
|
||||
|
||||
private fun isBindError(exception: Exception): Boolean {
|
||||
val message = exception.message?.lowercase() ?: ""
|
||||
return message.contains("bind") ||
|
||||
message.contains("address already in use") ||
|
||||
message.contains("port") && message.contains("use") ||
|
||||
message.contains("permission denied") && message.contains("port") ||
|
||||
message.contains("could not bind")
|
||||
message.contains("address already in use") ||
|
||||
message.contains("port") && message.contains("use") ||
|
||||
message.contains("permission denied") && message.contains("port") ||
|
||||
message.contains("could not bind")
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset network connections after Tor state changes.
|
||||
* Rebuilds OkHttp clients and reconnects Nostr relays.
|
||||
*/
|
||||
private fun resetNetworkConnections() {
|
||||
try {
|
||||
OkHttpProvider.reset()
|
||||
} catch (_: Throwable) {
|
||||
}
|
||||
try {
|
||||
com.bitchat.android.nostr.NostrRelayManager.shared.resetAllConnections()
|
||||
} catch (_: Throwable) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopArtiInternal() {
|
||||
try {
|
||||
val proxy = artiProxyRef.getAndSet(null)
|
||||
val proxy = artiProxy
|
||||
if (proxy != null) {
|
||||
Log.i(TAG, "Stopping Arti…")
|
||||
try { proxy.stop() } catch (_: Throwable) {}
|
||||
try {
|
||||
proxy.stop()
|
||||
} catch (_: Throwable) {
|
||||
}
|
||||
}
|
||||
stopInactivityMonitoring()
|
||||
stopRetryMonitoring()
|
||||
@@ -260,15 +342,16 @@ object TorManager {
|
||||
private fun stopArti() {
|
||||
stopArtiInternal()
|
||||
socksAddr = null
|
||||
_status.value = _status.value.copy(running = false, bootstrapPercent = 0, state = TorState.STOPPING)
|
||||
_statusFlow.value = _statusFlow.value.copy(
|
||||
running = false,
|
||||
bootstrapPercent = 0,
|
||||
state = TorState.STOPPING
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun stopArtiAndWait(timeoutMs: Long = STOP_TIMEOUT_MS) {
|
||||
// Request stop
|
||||
stopArtiInternal()
|
||||
// Wait for confirmation via logs (Stopped) or timeout
|
||||
waitForStateTransition(target = TorState.OFF, timeoutMs = timeoutMs)
|
||||
// Small grace period before relaunch to let file locks clear
|
||||
delay(200)
|
||||
}
|
||||
|
||||
@@ -276,7 +359,7 @@ object TorManager {
|
||||
Log.i(TAG, "Restarting Arti (keeping SOCKS proxy enabled)...")
|
||||
stopArtiAndWait()
|
||||
delay(RESTART_DELAY_MS)
|
||||
startArti(application, useDelay = false) // Already delayed above
|
||||
startArti(application, useDelay = false)
|
||||
}
|
||||
|
||||
private fun startInactivityMonitoring() {
|
||||
@@ -287,13 +370,16 @@ object TorManager {
|
||||
val currentTime = System.currentTimeMillis()
|
||||
val lastActivity = lastLogTime.get()
|
||||
val timeSinceLastActivity = currentTime - lastActivity
|
||||
|
||||
|
||||
if (timeSinceLastActivity > INACTIVITY_TIMEOUT_MS) {
|
||||
val currentMode = _status.value.mode
|
||||
val currentMode = _statusFlow.value.mode
|
||||
if (currentMode == TorMode.ON) {
|
||||
val bootstrapPercent = _status.value.bootstrapPercent
|
||||
val bootstrapPercent = _statusFlow.value.bootstrapPercent
|
||||
if (bootstrapPercent < 100) {
|
||||
Log.w(TAG, "Inactivity detected (${timeSinceLastActivity}ms), restarting Arti")
|
||||
Log.w(
|
||||
TAG,
|
||||
"Inactivity detected (${timeSinceLastActivity}ms), restarting Arti"
|
||||
)
|
||||
currentApplication?.let { app ->
|
||||
appScope.launch {
|
||||
restartArti(app)
|
||||
@@ -316,11 +402,11 @@ object TorManager {
|
||||
retryJob?.cancel()
|
||||
if (retryAttempts < MAX_RETRY_ATTEMPTS) {
|
||||
retryAttempts++
|
||||
val delayMs = (1000L * (1 shl retryAttempts)).coerceAtMost(30000L) // Exponential backoff, max 30s
|
||||
val delayMs = (1000L * (1 shl retryAttempts)).coerceAtMost(30000L)
|
||||
Log.w(TAG, "Scheduling Arti retry attempt $retryAttempts in ${delayMs}ms")
|
||||
retryJob = appScope.launch {
|
||||
delay(delayMs)
|
||||
val currentMode = _status.value.mode
|
||||
val currentMode = _statusFlow.value.mode
|
||||
if (currentMode == TorMode.ON) {
|
||||
Log.i(TAG, "Retrying Arti start (attempt $retryAttempts)")
|
||||
restartArti(application)
|
||||
@@ -337,50 +423,110 @@ object TorManager {
|
||||
}
|
||||
|
||||
private suspend fun waitUntilBootstrapped() {
|
||||
val current = _status.value
|
||||
val current = _statusFlow.value
|
||||
if (!current.running) return
|
||||
if (current.bootstrapPercent >= 100 && current.state == TorState.RUNNING) return
|
||||
// Suspend until we observe RUNNING at least once
|
||||
while (true) {
|
||||
val s = statusFlow.first { (it.bootstrapPercent >= 100 && it.state == TorState.RUNNING) || !it.running || it.state == TorState.ERROR }
|
||||
val s = statusFlow.first {
|
||||
(it.bootstrapPercent >= 100 && it.state == TorState.RUNNING) ||
|
||||
!it.running ||
|
||||
it.state == TorState.ERROR
|
||||
}
|
||||
if (!s.running || s.state == TorState.ERROR) return
|
||||
if (s.bootstrapPercent >= 100 && s.state == TorState.RUNNING) return
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleArtiLogLine(s: String) {
|
||||
val currentState = _statusFlow.value.state
|
||||
val currentLifecycle = lifecycleState
|
||||
|
||||
when {
|
||||
s.contains("AMEx: state changed to Initialized", ignoreCase = true) -> {
|
||||
_status.value = _status.value.copy(state = TorState.STARTING)
|
||||
if (currentLifecycle != LifecycleState.STARTING && currentLifecycle != LifecycleState.RUNNING) {
|
||||
Log.w(TAG, "Ignoring stale 'Initialized' log (lifecycle: $currentLifecycle)")
|
||||
return
|
||||
}
|
||||
_statusFlow.update { it.copy(state = TorState.STARTING) }
|
||||
completeWaitersIf(TorState.STARTING)
|
||||
}
|
||||
|
||||
s.contains("AMEx: state changed to Starting", ignoreCase = true) -> {
|
||||
_status.value = _status.value.copy(state = TorState.STARTING)
|
||||
if (currentLifecycle != LifecycleState.STARTING && currentLifecycle != LifecycleState.RUNNING) {
|
||||
Log.w(TAG, "Ignoring stale 'Starting' log (lifecycle: $currentLifecycle)")
|
||||
return
|
||||
}
|
||||
_statusFlow.update { it.copy(state = TorState.STARTING) }
|
||||
completeWaitersIf(TorState.STARTING)
|
||||
}
|
||||
s.contains("Sufficiently bootstrapped; system SOCKS now functional", ignoreCase = true) -> {
|
||||
_status.value = _status.value.copy(bootstrapPercent = 75, state = TorState.BOOTSTRAPPING)
|
||||
|
||||
s.contains(
|
||||
"Sufficiently bootstrapped; system SOCKS now functional",
|
||||
ignoreCase = true
|
||||
) -> {
|
||||
if (currentLifecycle != LifecycleState.RUNNING) {
|
||||
Log.w(TAG, "Ignoring bootstrap log (lifecycle: $currentLifecycle)")
|
||||
return
|
||||
}
|
||||
_statusFlow.update {
|
||||
it.copy(
|
||||
bootstrapPercent = 75,
|
||||
state = TorState.BOOTSTRAPPING
|
||||
)
|
||||
}
|
||||
retryAttempts = 0
|
||||
bindRetryAttempts = 0
|
||||
startInactivityMonitoring()
|
||||
}
|
||||
//s.contains("AMEx: state changed to Running", ignoreCase = true) -> {
|
||||
|
||||
s.contains("We have found that guard [scrubbed] is usable.", ignoreCase = true) -> {
|
||||
// If we already saw Sufficiently bootstrapped, mark as RUNNING and ready.
|
||||
val bp = if (_status.value.bootstrapPercent >= 100) 100 else 100 // treat Running as ready
|
||||
_status.value = _status.value.copy(state = TorState.RUNNING, bootstrapPercent = bp, running = true)
|
||||
if (currentLifecycle != LifecycleState.RUNNING) {
|
||||
Log.w(TAG, "Ignoring guard discovery log (lifecycle: $currentLifecycle)")
|
||||
return
|
||||
}
|
||||
_statusFlow.update {
|
||||
it.copy(
|
||||
state = TorState.RUNNING,
|
||||
bootstrapPercent = 100,
|
||||
running = true
|
||||
)
|
||||
}
|
||||
completeWaitersIf(TorState.RUNNING)
|
||||
}
|
||||
|
||||
s.contains("AMEx: state changed to Stopping", ignoreCase = true) -> {
|
||||
_status.value = _status.value.copy(state = TorState.STOPPING, running = false)
|
||||
if (currentLifecycle != LifecycleState.STOPPING) {
|
||||
Log.w(TAG, "Ignoring stale 'Stopping' log (lifecycle: $currentLifecycle)")
|
||||
return
|
||||
}
|
||||
_statusFlow.update {
|
||||
it.copy(
|
||||
state = TorState.STOPPING,
|
||||
running = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
s.contains("AMEx: state changed to Stopped", ignoreCase = true) -> {
|
||||
_status.value = _status.value.copy(state = TorState.OFF, running = false, bootstrapPercent = 0)
|
||||
if (currentLifecycle != LifecycleState.STOPPING && currentLifecycle != LifecycleState.STOPPED) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"Ignoring stale 'Stopped' log (lifecycle: $currentLifecycle, preventing state corruption)"
|
||||
)
|
||||
return
|
||||
}
|
||||
_statusFlow.update {
|
||||
it.copy(
|
||||
state = TorState.OFF,
|
||||
running = false,
|
||||
bootstrapPercent = 0
|
||||
)
|
||||
}
|
||||
completeWaitersIf(TorState.OFF)
|
||||
}
|
||||
|
||||
s.contains("Another process has the lock on our state files", ignoreCase = true) -> {
|
||||
// Signal error; we'll likely need to wait longer before restart
|
||||
_status.value = _status.value.copy(state = TorState.ERROR)
|
||||
_statusFlow.update { it.copy(state = TorState.ERROR) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -395,13 +541,11 @@ object TorManager {
|
||||
val def = CompletableDeferred<TorState>()
|
||||
stateChangeDeferred.getAndSet(def)?.cancel()
|
||||
return withTimeoutOrNull(timeoutMs) {
|
||||
// Fast-path: if we're already there
|
||||
val cur = _status.value.state
|
||||
val cur = _statusFlow.value.state
|
||||
if (cur == target) return@withTimeoutOrNull cur
|
||||
def.await()
|
||||
}
|
||||
}
|
||||
|
||||
// Visible for instrumentation tests to validate installation
|
||||
fun installResourcesForTest(application: Application): Boolean { return true }
|
||||
fun isTorAvailable(): Boolean = true
|
||||
}
|
||||
@@ -42,8 +42,9 @@ object OkHttpProvider {
|
||||
|
||||
private fun baseBuilderForCurrentProxy(): OkHttpClient.Builder {
|
||||
val builder = OkHttpClient.Builder()
|
||||
val socks: InetSocketAddress? = TorManager.currentSocksAddress()
|
||||
// If a SOCKS address is defined, always use it. TorManager sets this as soon as Tor mode is ON,
|
||||
val torProvider = ArtiTorManager.getInstance()
|
||||
val socks: InetSocketAddress? = torProvider.currentSocksAddress()
|
||||
// If a SOCKS address is defined, always use it. TorProvider sets this as soon as Tor mode is ON,
|
||||
// even before bootstrap, to prevent any direct connections from occurring.
|
||||
if (socks != null) {
|
||||
val proxy = Proxy(Proxy.Type.SOCKS, socks)
|
||||
|
||||
@@ -11,14 +11,14 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Bluetooth
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.Public
|
||||
import androidx.compose.material.icons.filled.Security
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material.icons.outlined.Info
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import kotlinx.coroutines.launch
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
@@ -28,9 +28,12 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.bitchat.android.nostr.NostrProofOfWork
|
||||
import com.bitchat.android.nostr.PoWPreferenceManager
|
||||
import com.bitchat.android.ui.debug.DebugSettingsSheet
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.bitchat.android.R
|
||||
import com.bitchat.android.net.TorMode
|
||||
import com.bitchat.android.net.TorPreferenceManager
|
||||
import com.bitchat.android.net.ArtiTorManager
|
||||
|
||||
/**
|
||||
* About Sheet for bitchat app information
|
||||
* Matches the design language of LocationChannelsSheet
|
||||
@@ -383,7 +386,9 @@ fun AboutSheet(
|
||||
// Network (Tor) section
|
||||
item(key = "network_section") {
|
||||
val torMode = remember { mutableStateOf(com.bitchat.android.net.TorPreferenceManager.get(context)) }
|
||||
val torStatus by com.bitchat.android.net.TorManager.statusFlow.collectAsState()
|
||||
val torProvider = remember { ArtiTorManager.getInstance() }
|
||||
val torStatus by torProvider.statusFlow.collectAsState()
|
||||
val torAvailable = remember { torProvider.isTorAvailable() }
|
||||
Text(
|
||||
text = stringResource(R.string.about_network),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
@@ -398,19 +403,22 @@ fun AboutSheet(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
FilterChip(
|
||||
selected = torMode.value == com.bitchat.android.net.TorMode.OFF,
|
||||
selected = torMode.value == TorMode.OFF,
|
||||
onClick = {
|
||||
torMode.value = com.bitchat.android.net.TorMode.OFF
|
||||
com.bitchat.android.net.TorPreferenceManager.set(context, torMode.value)
|
||||
torMode.value = TorMode.OFF
|
||||
TorPreferenceManager.set(context, torMode.value)
|
||||
},
|
||||
label = { Text("tor off", fontFamily = FontFamily.Monospace) }
|
||||
)
|
||||
FilterChip(
|
||||
selected = torMode.value == com.bitchat.android.net.TorMode.ON,
|
||||
selected = torMode.value == TorMode.ON,
|
||||
onClick = {
|
||||
torMode.value = com.bitchat.android.net.TorMode.ON
|
||||
com.bitchat.android.net.TorPreferenceManager.set(context, torMode.value)
|
||||
if (torAvailable) {
|
||||
torMode.value = TorMode.ON
|
||||
TorPreferenceManager.set(context, torMode.value)
|
||||
}
|
||||
},
|
||||
enabled = torAvailable,
|
||||
label = {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
@@ -428,13 +436,49 @@ fun AboutSheet(
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if (!torAvailable) {
|
||||
val tooltipState = rememberTooltipState()
|
||||
val scope = rememberCoroutineScope()
|
||||
TooltipBox(
|
||||
positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(),
|
||||
tooltip = {
|
||||
PlainTooltip {
|
||||
Text(
|
||||
text = stringResource(R.string.tor_not_available_in_this_build),
|
||||
fontSize = 11.sp,
|
||||
fontFamily = FontFamily.Monospace
|
||||
)
|
||||
}
|
||||
},
|
||||
state = tooltipState
|
||||
) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
tooltipState.show()
|
||||
}
|
||||
},
|
||||
modifier = Modifier.size(32.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Info,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurface.copy(
|
||||
alpha = 0.6f
|
||||
),
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.about_tor_route),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
if (torMode.value == com.bitchat.android.net.TorMode.ON) {
|
||||
if (torMode.value == TorMode.ON) {
|
||||
val statusText = if (torStatus.running) "Running" else "Stopped"
|
||||
// Debug status (temporary)
|
||||
Surface(
|
||||
|
||||
@@ -28,7 +28,6 @@ import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.bitchat.android.core.ui.utils.singleOrTripleClickable
|
||||
import com.bitchat.android.geohash.LocationChannelManager.PermissionState
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
|
||||
@@ -59,7 +58,8 @@ fun isFavoriteReactive(
|
||||
fun TorStatusDot(
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val torStatus by com.bitchat.android.net.TorManager.statusFlow.collectAsState()
|
||||
val torProvider = remember { com.bitchat.android.net.ArtiTorManager.getInstance() }
|
||||
val torStatus by torProvider.statusFlow.collectAsState()
|
||||
|
||||
if (torStatus.mode != com.bitchat.android.net.TorMode.OFF) {
|
||||
val dotColor = when {
|
||||
|
||||
@@ -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)
|
||||
@@ -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?)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -378,8 +378,9 @@
|
||||
<string name="pwd_label">Password</string>
|
||||
<string name="join">Join</string>
|
||||
<string name="cancel">Cancel</string>
|
||||
<string name="tor_not_available_in_this_build">Tor not available in this build</string>
|
||||
|
||||
<!-- Plurals -->
|
||||
<!-- Plurals -->
|
||||
<plurals name="notification_and_more">
|
||||
<item quantity="one">and %d more</item>
|
||||
<item quantity="other">and %d more</item>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
arti-v1.7.0
|
||||
@@ -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
|
||||
@@ -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)
|
||||
Executable
+570
@@ -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/<version>)."
|
||||
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
|
||||
@@ -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<Option<Arc<TorClient<PreferredRuntime>>>> = Mutex::new(None);
|
||||
|
||||
/// Global Tokio runtime (must persist for Arti to work)
|
||||
static TOKIO_RUNTIME: Mutex<Option<tokio::runtime::Runtime>> = Mutex::new(None);
|
||||
|
||||
/// Global JavaVM reference (cached on first JNI call)
|
||||
static JAVA_VM: Mutex<Option<JavaVM>> = Mutex::new(None);
|
||||
|
||||
/// Global log callback reference
|
||||
static LOG_CALLBACK: Mutex<Option<GlobalRef>> = Mutex::new(None);
|
||||
|
||||
/// Handle to SOCKS server task (for graceful shutdown)
|
||||
static SOCKS_TASK: Mutex<Option<tokio::task::JoinHandle<()>>> = 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<TorClient<PreferredRuntime>>,
|
||||
) -> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user