feat: replace arti-mobile-ex with custom-built Arti native library

The Guardian Project's arti-mobile-ex AAR has three problems:
1. No 16KB page-aligned binaries (required for Google Play)
2. ArtiProxy's stop()+start() causes state file lock conflicts
   (lock is tied to TorClient object lifetime, released only on GC)
3. ~140MB AAR size

Replace with a custom JNI bridge built from Arti source, following
BitChat's proven approach:

Build tooling (tools/arti-build/):
- build-arti.sh: Clones official Arti, compiles with cargo-ndk
  for ARM64 + x86_64, NDK 25+ for 16KB page alignment
- Cargo.toml: Minimal deps with size-optimized release profile
- src/lib.rs: Custom SOCKS5 proxy with proper lifecycle:
  - initialize() creates TorClient once (holds state lock forever)
  - startSocksProxy() binds port and accepts connections
  - stopSocksProxy() aborts listener only (TorClient stays alive)
  This cleanly separates "stop routing traffic" from "destroy client"

Kotlin side:
- ArtiNative.kt: JNI declarations + ArtiLogCallback interface
- TorService.kt: Uses ArtiNative directly, start() initializes +
  starts proxy, stop() only stops proxy (no lock issues)
- TorManager.kt: Restored stop() calls for OFF/EXTERNAL modes
  since our native stop is now safe

Removed: arti-mobile-ex dependency from build.gradle and version catalog

Native libraries must be built separately:
  cd tools/arti-build && ./build-arti.sh

https://claude.ai/code/session_01BApgDd5udqBzMqysSRMpZu
This commit is contained in:
Claude
2026-04-01 16:36:12 +00:00
parent abe1082121
commit e50ae0fb1e
10 changed files with 827 additions and 60 deletions
-2
View File
@@ -372,8 +372,6 @@ dependencies {
// Kotlin serialization for the times where we need the Json tree and performance is not that important. // Kotlin serialization for the times where we need the Json tree and performance is not that important.
implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.serialization.json)
implementation libs.arti.mobile.ex
testImplementation libs.junit testImplementation libs.junit
testImplementation libs.mockk testImplementation libs.mockk
testImplementation libs.kotlinx.coroutines.test testImplementation libs.kotlinx.coroutines.test
@@ -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)
}
@@ -63,10 +63,12 @@ class TorManager(
} }
TorType.OFF -> { TorType.OFF -> {
service.stop()
emit(TorServiceStatus.Off) emit(TorServiceStatus.Off)
} }
TorType.EXTERNAL -> { TorType.EXTERNAL -> {
service.stop()
if (externalSocksPort > 0) { if (externalSocksPort > 0) {
emit(TorServiceStatus.Active(externalSocksPort)) emit(TorServiceStatus.Active(externalSocksPort))
} else { } else {
@@ -22,95 +22,100 @@ package com.vitorpamplona.amethyst.ui.tor
import android.content.Context import android.content.Context
import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.Log
import info.guardianproject.arti.ArtiLogListener
import info.guardianproject.arti.ArtiProxy
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.File
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
private const val DEFAULT_SOCKS_PORT = 19050 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 — * The native TorClient is initialized once and persists for the app's
* it is only released when the object is garbage collected, not when * lifetime — its state file lock is never released until the process exits.
* stop() is called. Calling stop()+start() on ArtiProxy creates a new * The SOCKS proxy can be started/stopped independently without affecting
* internal TorClient that conflicts with the old lock. * the TorClient or its file locks.
*
* 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.
*/ */
class TorService( class TorService(
val context: Context, val context: Context,
) { ) {
private val socksPort = DEFAULT_SOCKS_PORT private val socksPort = DEFAULT_SOCKS_PORT
private val bootstrapped = AtomicBoolean(false) private val initialized = AtomicBoolean(false)
private val started = AtomicBoolean(false) private val proxyRunning = AtomicBoolean(false)
private val _status = MutableStateFlow<TorServiceStatus>(TorServiceStatus.Off) private val _status = MutableStateFlow<TorServiceStatus>(TorServiceStatus.Off)
val status: StateFlow<TorServiceStatus> = _status.asStateFlow() val status: StateFlow<TorServiceStatus> = _status.asStateFlow()
private val logListener = init {
ArtiLogListener { logLine -> ArtiNative.setLogCallback(
val text = logLine ?: return@ArtiLogListener ArtiLogCallback { text ->
Log.d("TorService") { "Arti: $text" } Log.d("TorService") { "Arti: $text" }
when { when {
text.contains("Sufficiently bootstrapped", ignoreCase = true) || text.contains("Sufficiently bootstrapped", ignoreCase = true) -> {
text.contains("is usable", ignoreCase = true) -> {
if (bootstrapped.compareAndSet(false, true)) {
_status.value = TorServiceStatus.Active(socksPort) _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", * Initialize the TorClient (once) and start the SOCKS proxy.
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()
suspend fun start() { suspend fun start() {
if (started.get()) { if (proxyRunning.get()) {
// Already started — just emit current state if (_status.value is TorServiceStatus.Active) return
if (bootstrapped.get()) { _status.value = TorServiceStatus.Connecting
_status.value = TorServiceStatus.Active(socksPort)
} else {
_status.value = TorServiceStatus.Connecting
}
return return
} }
_status.value = TorServiceStatus.Connecting _status.value = TorServiceStatus.Connecting
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
try { // Initialize TorClient once — this bootstraps the Tor network
artiProxy.start() if (initialized.compareAndSet(false, true)) {
started.set(true) val dataDir = File(context.filesDir, "arti").absolutePath
Log.d("TorService") { "Arti started on port $socksPort" } Log.d("TorService") { "Initializing Arti with data dir: $dataDir" }
} catch (e: Exception) {
Log.e("TorService") { "Failed to start Arti: ${e.message}" } val initResult = ArtiNative.initialize(dataDir)
_status.value = TorServiceStatus.Off 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
}
} }
-2
View File
@@ -25,7 +25,6 @@ fragmentKtx = "1.8.9"
gms = "4.4.4" gms = "4.4.4"
jacksonModuleKotlin = "2.21.2" jacksonModuleKotlin = "2.21.2"
javaKeyring = "1.0.4" javaKeyring = "1.0.4"
artiMobileEx = "1.2.3"
junit = "4.13.2" junit = "4.13.2"
kchesslib = "1.0.5" kchesslib = "1.0.5"
kotlin = "2.3.20" 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" } 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" } 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" } 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" } junit = { group = "junit", name = "junit", version.ref = "junit" }
kchesslib = { module = "io.github.cvb941:kchesslib", version.ref = "kchesslib" } kchesslib = { module = "io.github.cvb941:kchesslib", version.ref = "kchesslib" }
kotlinx-collections-immutable = { group = "org.jetbrains.kotlinx", name = "kotlinx-collections-immutable", version.ref = "kotlinxCollectionsImmutable" } kotlinx-collections-immutable = { group = "org.jetbrains.kotlinx", name = "kotlinx-collections-immutable", version.ref = "kotlinxCollectionsImmutable" }
+2
View File
@@ -0,0 +1,2 @@
.arti-source/
target/
+1
View File
@@ -0,0 +1 @@
arti-v1.2.3
+30
View File
@@ -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"
+248
View File
@@ -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 <lib> | grep LOAD"
echo " 2. Build the app: ./gradlew :amethyst:assembleDebug"
echo " 3. Test on device"
echo ""
}
main "$@"
+415
View File
@@ -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<Option<Arc<TorClient<PreferredRuntime>>>> = Mutex::new(None);
static TOKIO_RUNTIME: Mutex<Option<tokio::runtime::Runtime>> = Mutex::new(None);
static JAVA_VM: Mutex<Option<JavaVM>> = Mutex::new(None);
static LOG_CALLBACK: Mutex<Option<GlobalRef>> = Mutex::new(None);
static SOCKS_TASK: Mutex<Option<tokio::task::JoinHandle<()>>> = 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<TorClient<PreferredRuntime>>,
) -> 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);
}
}
}