diff --git a/amethyst/build.gradle b/amethyst/build.gradle index 9a5578bcd..a270ad1bf 100644 --- a/amethyst/build.gradle +++ b/amethyst/build.gradle @@ -372,8 +372,6 @@ dependencies { // Kotlin serialization for the times where we need the Json tree and performance is not that important. implementation(libs.kotlinx.serialization.json) - implementation libs.arti.mobile.ex - testImplementation libs.junit testImplementation libs.mockk testImplementation libs.kotlinx.coroutines.test diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/ArtiNative.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/ArtiNative.kt new file mode 100644 index 000000000..ec6f86b82 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/ArtiNative.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.tor + +/** + * JNI bridge to the custom-built Arti native library (libarti_android.so). + * + * The native TorClient is created once via [initialize] and persists for the + * app's lifetime — its state file lock is never released until the process exits. + * + * The SOCKS proxy can be started and stopped independently via [startSocksProxy] + * and [stopSocksProxy] without affecting the TorClient. + */ +object ArtiNative { + init { + System.loadLibrary("arti_android") + } + + external fun getVersion(): String + + external fun setLogCallback(callback: ArtiLogCallback) + + /** + * Initialize the Arti runtime and bootstrap the Tor client. + * @param dataDir Path to the app's private data directory for Arti state/cache. + * @return 0 on success, negative on error. + */ + external fun initialize(dataDir: String): Int + + /** + * Start the SOCKS5 proxy on the given port. + * Can be called multiple times — stops any existing listener first. + * @return 0 on success, negative on error. + */ + external fun startSocksProxy(port: Int): Int + + /** + * Stop the SOCKS5 proxy listener and release the port. + * The TorClient stays alive — no state file lock issues. + * @return 0 on success. + */ + external fun stopSocksProxy(): Int +} + +/** + * Callback interface for Arti log messages from the native layer. + */ +fun interface ArtiLogCallback { + fun onLogLine(line: String) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorManager.kt index 52add284b..2b174560b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorManager.kt @@ -63,10 +63,12 @@ class TorManager( } TorType.OFF -> { + service.stop() emit(TorServiceStatus.Off) } TorType.EXTERNAL -> { + service.stop() if (externalSocksPort > 0) { emit(TorServiceStatus.Active(externalSocksPort)) } else { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorService.kt index 1cbf2ab3e..2fc8f8b25 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorService.kt @@ -22,95 +22,100 @@ package com.vitorpamplona.amethyst.ui.tor import android.content.Context import com.vitorpamplona.quartz.utils.Log -import info.guardianproject.arti.ArtiLogListener -import info.guardianproject.arti.ArtiProxy import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.withContext +import java.io.File import java.util.concurrent.atomic.AtomicBoolean private const val DEFAULT_SOCKS_PORT = 19050 /** - * Manages a single ArtiProxy instance for the app's lifetime. + * Manages the Arti Tor client via custom JNI bindings. * - * Arti's state file lock is tied to the TorClient object's lifetime — - * it is only released when the object is garbage collected, not when - * stop() is called. Calling stop()+start() on ArtiProxy creates a new - * internal TorClient that conflicts with the old lock. - * - * Therefore, ArtiProxy is created and started once. It runs for the - * entire process lifetime. When the user turns Tor "off", TorManager - * simply stops emitting Active status — no traffic is routed through - * the proxy, but the proxy itself stays alive. This is safe because - * an idle Arti uses negligible resources and maintains no circuits - * when no SOCKS connections are made. + * The native TorClient is initialized once and persists for the app's + * lifetime — its state file lock is never released until the process exits. + * The SOCKS proxy can be started/stopped independently without affecting + * the TorClient or its file locks. */ class TorService( val context: Context, ) { private val socksPort = DEFAULT_SOCKS_PORT - private val bootstrapped = AtomicBoolean(false) - private val started = AtomicBoolean(false) + private val initialized = AtomicBoolean(false) + private val proxyRunning = AtomicBoolean(false) private val _status = MutableStateFlow(TorServiceStatus.Off) val status: StateFlow = _status.asStateFlow() - private val logListener = - ArtiLogListener { logLine -> - val text = logLine ?: return@ArtiLogListener - Log.d("TorService") { "Arti: $text" } + init { + ArtiNative.setLogCallback( + ArtiLogCallback { text -> + Log.d("TorService") { "Arti: $text" } - when { - text.contains("Sufficiently bootstrapped", ignoreCase = true) || - text.contains("is usable", ignoreCase = true) -> { - if (bootstrapped.compareAndSet(false, true)) { + when { + text.contains("Sufficiently bootstrapped", ignoreCase = true) -> { _status.value = TorServiceStatus.Active(socksPort) - Log.d("TorService") { "Arti bootstrapped on port $socksPort" } + Log.d("TorService") { "Arti SOCKS proxy active on port $socksPort" } } } + }, + ) + } - text.contains( - "Another process has the lock", - ignoreCase = true, - ) -> { - Log.e("TorService") { "Arti state file lock conflict" } - } - } - } - - private val artiProxy: ArtiProxy = - ArtiProxy - .Builder(context.applicationContext) - .setSocksPort(socksPort) - .setDnsPort(socksPort + 1) - .setLogListener(logListener) - .build() - + /** + * Initialize the TorClient (once) and start the SOCKS proxy. + */ suspend fun start() { - if (started.get()) { - // Already started — just emit current state - if (bootstrapped.get()) { - _status.value = TorServiceStatus.Active(socksPort) - } else { - _status.value = TorServiceStatus.Connecting - } + if (proxyRunning.get()) { + if (_status.value is TorServiceStatus.Active) return + _status.value = TorServiceStatus.Connecting return } _status.value = TorServiceStatus.Connecting withContext(Dispatchers.IO) { - try { - artiProxy.start() - started.set(true) - Log.d("TorService") { "Arti started on port $socksPort" } - } catch (e: Exception) { - Log.e("TorService") { "Failed to start Arti: ${e.message}" } - _status.value = TorServiceStatus.Off + // Initialize TorClient once — this bootstraps the Tor network + if (initialized.compareAndSet(false, true)) { + val dataDir = File(context.filesDir, "arti").absolutePath + Log.d("TorService") { "Initializing Arti with data dir: $dataDir" } + + val initResult = ArtiNative.initialize(dataDir) + if (initResult != 0) { + Log.e("TorService") { "Failed to initialize Arti: error $initResult" } + initialized.set(false) + _status.value = TorServiceStatus.Off + return@withContext + } } + + // Start the SOCKS proxy (can be called multiple times safely) + val proxyResult = ArtiNative.startSocksProxy(socksPort) + if (proxyResult != 0) { + Log.e("TorService") { "Failed to start SOCKS proxy: error $proxyResult" } + _status.value = TorServiceStatus.Off + return@withContext + } + + proxyRunning.set(true) } } + + /** + * Stop the SOCKS proxy and release the port. + * The TorClient stays alive — no file lock issues on restart. + */ + suspend fun stop() { + if (!proxyRunning.compareAndSet(true, false)) return + + withContext(Dispatchers.IO) { + ArtiNative.stopSocksProxy() + Log.d("TorService") { "SOCKS proxy stopped" } + } + + _status.value = TorServiceStatus.Off + } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a88aff95a..5e03f4b08 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -25,7 +25,6 @@ fragmentKtx = "1.8.9" gms = "4.4.4" jacksonModuleKotlin = "2.21.2" javaKeyring = "1.0.4" -artiMobileEx = "1.2.3" junit = "4.13.2" kchesslib = "1.0.5" kotlin = "2.3.20" @@ -140,7 +139,6 @@ google-mlkit-language-id = { group = "com.google.mlkit", name = "language-id", v google-mlkit-translate = { group = "com.google.mlkit", name = "translate", version.ref = "translate" } jackson-module-kotlin = { group = "com.fasterxml.jackson.module", name = "jackson-module-kotlin", version.ref = "jacksonModuleKotlin" } java-keyring = { group = "com.github.javakeyring", name = "java-keyring", version.ref = "javaKeyring" } -arti-mobile-ex = { module = "info.guardianproject:arti-mobile-ex", version.ref = "artiMobileEx" } junit = { group = "junit", name = "junit", version.ref = "junit" } kchesslib = { module = "io.github.cvb941:kchesslib", version.ref = "kchesslib" } kotlinx-collections-immutable = { group = "org.jetbrains.kotlinx", name = "kotlinx-collections-immutable", version.ref = "kotlinxCollectionsImmutable" } diff --git a/tools/arti-build/.gitignore b/tools/arti-build/.gitignore new file mode 100644 index 000000000..87c48ae23 --- /dev/null +++ b/tools/arti-build/.gitignore @@ -0,0 +1,2 @@ +.arti-source/ +target/ diff --git a/tools/arti-build/ARTI_VERSION b/tools/arti-build/ARTI_VERSION new file mode 100644 index 000000000..5273a52b3 --- /dev/null +++ b/tools/arti-build/ARTI_VERSION @@ -0,0 +1 @@ +arti-v1.2.3 diff --git a/tools/arti-build/Cargo.toml b/tools/arti-build/Cargo.toml new file mode 100644 index 000000000..3c6e708cf --- /dev/null +++ b/tools/arti-build/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "arti-android" +version = "1.2.3" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[workspace] + +[dependencies] +arti-client = { version = "0.25", features = [ + "tokio", + "rustls", + "compression", + "bridge-client", + "onion-service-client", + "static-sqlite", +] } +tor-rtcompat = { version = "0.25", features = ["tokio", "rustls"] } +jni = "0.21" +tokio = { version = "1", features = ["full"] } +anyhow = "1" + +[profile.release] +opt-level = "z" +lto = true +codegen-units = 1 +strip = true +panic = "abort" diff --git a/tools/arti-build/build-arti.sh b/tools/arti-build/build-arti.sh new file mode 100755 index 000000000..71c313619 --- /dev/null +++ b/tools/arti-build/build-arti.sh @@ -0,0 +1,248 @@ +#!/usr/bin/env bash +# +# Build Arti native libraries for Android from source. +# +# Prerequisites: +# - Rust toolchain: rustup, cargo +# - 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 for all targets (arm64 + x86_64) +# ./build-arti.sh --release # Build arm64 only (for release) +# ./build-arti.sh --clean # Clean and rebuild +# +set -euo pipefail + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +ARTI_SOURCE_DIR="$SCRIPT_DIR/.arti-source" +ARTI_VERSION=$(cat "$SCRIPT_DIR/ARTI_VERSION" | tr -d '[:space:]') +OUTPUT_DIR="$PROJECT_ROOT/amethyst/src/main/jniLibs" +LIB_NAME="libarti_android.so" +MIN_SDK_VERSION=26 + +# Default targets +TARGETS=("aarch64-linux-android" "x86_64-linux-android") +RELEASE_ONLY=false +CLEAN=false + +# Parse arguments +for arg in "$@"; do + case $arg in + --release) RELEASE_ONLY=true; TARGETS=("aarch64-linux-android") ;; + --clean) CLEAN=true ;; + --help) echo "Usage: $0 [--release] [--clean] [--help]"; exit 0 ;; + esac +done + +print_header() { echo -e "\n${BLUE}=== $1 ===${NC}"; } +print_success() { echo -e "${GREEN}✓ $1${NC}"; } +print_error() { echo -e "${RED}✗ $1${NC}"; } +print_info() { echo -e "${YELLOW}→ $1${NC}"; } + +# ============================================================================ +# Prerequisites +# ============================================================================ + +check_prerequisites() { + print_header "Checking prerequisites" + + command -v git >/dev/null 2>&1 || { print_error "git not found"; exit 1; } + command -v rustup >/dev/null 2>&1 || { print_error "rustup not found"; exit 1; } + command -v cargo >/dev/null 2>&1 || { print_error "cargo not found"; exit 1; } + command -v cargo-ndk >/dev/null 2>&1 || { print_error "cargo-ndk not found. Install: cargo install cargo-ndk"; exit 1; } + + if [ -z "${ANDROID_NDK_HOME:-}" ]; then + # Try common locations + for candidate in \ + "$HOME/Android/Sdk/ndk/"*/ \ + "$HOME/Library/Android/sdk/ndk/"*/ \ + "/usr/local/lib/android/sdk/ndk/"*/; do + if [ -d "$candidate" ]; then + export ANDROID_NDK_HOME="${candidate%/}" + break + fi + done + fi + + if [ -z "${ANDROID_NDK_HOME:-}" ]; then + print_error "ANDROID_NDK_HOME not set and NDK not found in common locations" + exit 1 + fi + + print_success "NDK: $ANDROID_NDK_HOME" + + for target in "${TARGETS[@]}"; do + if ! rustup target list --installed | grep -q "$target"; then + print_info "Adding Rust target: $target" + rustup target add "$target" + fi + print_success "Target: $target" + done +} + +# ============================================================================ +# Source Management +# ============================================================================ + +clone_or_update_arti() { + print_header "Setting up Arti source ($ARTI_VERSION)" + + if [ "$CLEAN" = true ] && [ -d "$ARTI_SOURCE_DIR" ]; then + print_info "Cleaning existing source" + rm -rf "$ARTI_SOURCE_DIR" + fi + + if [ ! -d "$ARTI_SOURCE_DIR" ]; then + print_info "Cloning Arti repository..." + git clone --depth 1 --branch "$ARTI_VERSION" \ + https://gitlab.torproject.org/tpo/core/arti.git \ + "$ARTI_SOURCE_DIR" + else + print_info "Updating existing clone to $ARTI_VERSION" + cd "$ARTI_SOURCE_DIR" + git fetch --depth 1 origin tag "$ARTI_VERSION" + git checkout "$ARTI_VERSION" + cd "$SCRIPT_DIR" + fi + + print_success "Arti source ready at $ARTI_SOURCE_DIR" +} + +# ============================================================================ +# Wrapper Setup +# ============================================================================ + +setup_wrapper() { + print_header "Setting up JNI wrapper" + + local wrapper_dir="$ARTI_SOURCE_DIR/arti-android-wrapper" + mkdir -p "$wrapper_dir/src" + + cp "$SCRIPT_DIR/Cargo.toml" "$wrapper_dir/Cargo.toml" + cp "$SCRIPT_DIR/src/lib.rs" "$wrapper_dir/src/lib.rs" + + # Patch Cargo.toml to use local arti-client from the source tree + # instead of pulling from crates.io + cd "$wrapper_dir" + + # Add path overrides for the local arti source + cat >> Cargo.toml << 'PATCH' + +[patch.crates-io] +arti-client = { path = "../crates/arti-client" } +tor-rtcompat = { path = "../crates/tor-rtcompat" } +PATCH + + cd "$SCRIPT_DIR" + print_success "JNI wrapper configured" +} + +# ============================================================================ +# Build +# ============================================================================ + +build_for_target() { + local target="$1" + print_header "Building for $target" + + local arch_dir + case "$target" in + aarch64-linux-android) arch_dir="arm64-v8a" ;; + x86_64-linux-android) arch_dir="x86_64" ;; + armv7-linux-androideabi) arch_dir="armeabi-v7a" ;; + i686-linux-android) arch_dir="x86" ;; + esac + + local out_dir="$OUTPUT_DIR/$arch_dir" + mkdir -p "$out_dir" + + cargo ndk \ + -t "$target" \ + --platform "$MIN_SDK_VERSION" \ + -o "$OUTPUT_DIR" \ + build --release \ + --manifest-path "$ARTI_SOURCE_DIR/arti-android-wrapper/Cargo.toml" + + if [ -f "$out_dir/$LIB_NAME" ]; then + local size=$(du -h "$out_dir/$LIB_NAME" | cut -f1) + print_success "Built $arch_dir/$LIB_NAME ($size)" + else + print_error "Build failed — $out_dir/$LIB_NAME not found" + exit 1 + fi +} + +# ============================================================================ +# Verification +# ============================================================================ + +verify_jni_symbols() { + print_header "Verifying JNI symbols" + + local expected_symbols=( + "Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_getVersion" + "Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_setLogCallback" + "Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_initialize" + "Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_startSocksProxy" + "Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_stopSocksProxy" + ) + + for arch_dir in "$OUTPUT_DIR"/*/; do + local lib="$arch_dir$LIB_NAME" + [ -f "$lib" ] || continue + + local arch=$(basename "$arch_dir") + local missing=0 + + for sym in "${expected_symbols[@]}"; do + if ! nm -D "$lib" 2>/dev/null | grep -q "$sym"; then + print_error "$arch: Missing symbol $sym" + missing=1 + fi + done + + if [ "$missing" -eq 0 ]; then + print_success "$arch: All JNI symbols present" + fi + done +} + +# ============================================================================ +# Main +# ============================================================================ + +main() { + echo -e "${BLUE}Arti Android Build — version $ARTI_VERSION${NC}" + + check_prerequisites + clone_or_update_arti + setup_wrapper + + for target in "${TARGETS[@]}"; do + build_for_target "$target" + done + + verify_jni_symbols + + print_header "Build complete" + echo "" + echo "Libraries written to: $OUTPUT_DIR" + echo "" + echo "Next steps:" + echo " 1. Verify 16KB page alignment: readelf -l | grep LOAD" + echo " 2. Build the app: ./gradlew :amethyst:assembleDebug" + echo " 3. Test on device" + echo "" +} + +main "$@" diff --git a/tools/arti-build/src/lib.rs b/tools/arti-build/src/lib.rs new file mode 100644 index 000000000..bbfad3ff9 --- /dev/null +++ b/tools/arti-build/src/lib.rs @@ -0,0 +1,415 @@ +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 +// ============================================================================ + +static ARTI_CLIENT: Mutex>>> = Mutex::new(None); +static TOKIO_RUNTIME: Mutex> = Mutex::new(None); +static JAVA_VM: Mutex> = Mutex::new(None); +static LOG_CALLBACK: Mutex> = Mutex::new(None); +static SOCKS_TASK: Mutex>> = Mutex::new(None); +static INIT_ONCE: Once = Once::new(); + +// ============================================================================ +// Logging +// ============================================================================ + +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_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 — package: com.vitorpamplona.amethyst.ui.tor +// ============================================================================ + +#[no_mangle] +pub extern "C" fn Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_getVersion( + env: JNIEnv, + _class: JClass, +) -> jstring { + 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() +} + +#[no_mangle] +pub extern "C" fn Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_setLogCallback( + env: JNIEnv, + _class: JClass, + callback: JObject, +) { + if JAVA_VM.lock().unwrap().is_none() { + if let Ok(vm) = env.get_java_vm() { + *JAVA_VM.lock().unwrap() = Some(vm); + } + } + + 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 and bootstrap the TorClient. +/// The TorClient is created once and reused for the app's lifetime. +#[no_mangle] +pub extern "C" fn Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_initialize( + mut env: JNIEnv, + _class: JClass, + data_dir: JString, +) -> jint { + if JAVA_VM.lock().unwrap().is_none() { + if let Ok(vm) = env.get_java_vm() { + *JAVA_VM.lock().unwrap() = Some(vm); + } + } + + // Already initialized — skip + if ARTI_CLIENT.lock().unwrap().is_some() { + log_info!("Arti already initialized, reusing existing client"); + return 0; + } + + 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!("Initializing Arti with data directory: {}", data_dir_str); + + 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); + } + } + }); + + 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; + } + }; + + let data_path = PathBuf::from(data_dir_str); + let cache_dir = data_path.join("cache"); + let state_dir = data_path.join("state"); + + 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..."); + + let config = TorClientConfigBuilder::from_directories(state_dir, cache_dir) + .build()?; + + let client = TorClient::create_bootstrapped(config).await?; + + log_info!("Arti client created and bootstrapped"); + + *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 the SOCKS5 proxy on the specified port. +/// Can be called multiple times — stops any existing listener first. +#[no_mangle] +pub extern "C" fn Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_startSocksProxy( + _env: JNIEnv, + _class: JClass, + port: jint, +) -> jint { + 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; + } + }; + + let addr = format!("127.0.0.1:{}", port); + + 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; + } + }; + + let handle = runtime.spawn(async move { + log_info!("Sufficiently bootstrapped; system SOCKS now functional"); + + loop { + match listener.accept().await { + Ok((stream, _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; + } + } + } + }); + + *SOCKS_TASK.lock().unwrap() = Some(handle); + log_info!("SOCKS proxy started on port {}", port); + 0 +} + +/// Handle a single SOCKS5 connection through Tor. +async fn handle_socks_connection( + mut stream: tokio::net::TcpStream, + client: Arc>, +) -> Result<()> { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let mut buf = [0u8; 512]; + + // SOCKS5 handshake: read version + methods + let n = stream.read(&mut buf).await?; + if n < 2 { + return Err(anyhow::anyhow!("Invalid SOCKS handshake")); + } + + // No auth required + 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")); + } + + 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 { + stream.write_all(&[0x05, 0x07, 0x00, 0x01, 0, 0, 0, 0, 0, 0]).await?; + return Err(anyhow::anyhow!("Unsupported SOCKS command: {}", cmd)); + } + + let (target_host, target_port) = match atyp { + 0x01 => { + let ip = format!("{}.{}.{}.{}", buf[4], buf[5], buf[6], buf[7]); + let port = u16::from_be_bytes([buf[8], buf[9]]); + (ip, port) + } + 0x03 => { + 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 => { + 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)); + } + }; + + 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 to {}:{}: {:?}", target_host, target_port, e); + stream.write_all(&[0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0]).await?; + return Err(e.into()); + } + }; + + // SOCKS5 success + stream.write_all(&[0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0]).await?; + + // Bidirectional forwarding + let (mut client_read, mut client_write) = stream.split(); + let (mut tor_read, mut tor_write) = tor_stream.split(); + + tokio::select! { + r = tokio::io::copy(&mut client_read, &mut tor_write) => { + if let Err(ref e) = r { log_error!("Client->Tor error: {:?}", e); } + } + r = tokio::io::copy(&mut tor_read, &mut client_write) => { + if let Err(ref e) = r { log_error!("Tor->Client error: {:?}", e); } + } + }; + + Ok(()) +} + +/// Stop the SOCKS proxy listener. The TorClient stays alive. +#[no_mangle] +pub extern "C" fn Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_stopSocksProxy( + _env: JNIEnv, + _class: JClass, +) -> jint { + log_info!("Stopping SOCKS proxy..."); + + if let Some(handle) = SOCKS_TASK.lock().unwrap().take() { + handle.abort(); + } + + 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: TorClient is NOT destroyed — it persists for reuse. + + log_info!("SOCKS proxy stopped"); + 0 +} + +// ============================================================================ +// Android Logger +// ============================================================================ + +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); + } + } +}