Bundle tor (#339)

* tor started

* tor works

* tor code

* improve manager

* works

* move tor icon

* werks

* arti works

* arti works

* arti works with reconnect

* delay fix

* refactor
This commit is contained in:
callebtc
2025-08-29 14:37:35 +02:00
committed by GitHub
parent 7f4bd96739
commit 686e2e78ec
12 changed files with 551 additions and 16 deletions
+3
View File
@@ -95,6 +95,9 @@ dependencies {
// WebSocket // WebSocket
implementation(libs.okhttp) implementation(libs.okhttp)
// Arti (Tor in Rust) Android bridge - use published AAR with native libs
implementation("info.guardianproject:arti-mobile-ex:1.2.3")
// Google Play Services Location // Google Play Services Location
implementation(libs.gms.location) implementation(libs.gms.location)
@@ -3,6 +3,7 @@ package com.bitchat.android
import android.app.Application import android.app.Application
import com.bitchat.android.nostr.RelayDirectory import com.bitchat.android.nostr.RelayDirectory
import com.bitchat.android.ui.theme.ThemePreferenceManager import com.bitchat.android.ui.theme.ThemePreferenceManager
import com.bitchat.android.net.TorManager
/** /**
* Main application class for bitchat Android * Main application class for bitchat Android
@@ -12,6 +13,9 @@ class BitchatApplication : Application() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
// Initialize Tor first so any early network goes over Tor
try { TorManager.init(this) } catch (_: Exception) { }
// Initialize relay directory (loads assets/nostr_relays.csv) // Initialize relay directory (loads assets/nostr_relays.csv)
RelayDirectory.initialize(this) RelayDirectory.initialize(this)
@@ -27,5 +31,7 @@ class BitchatApplication : Application() {
// Initialize theme preference // Initialize theme preference
ThemePreferenceManager.init(this) ThemePreferenceManager.init(this)
// TorManager already initialized above
} }
} }
@@ -0,0 +1,53 @@
package com.bitchat.android.net
import okhttp3.OkHttpClient
import java.net.InetSocketAddress
import java.net.Proxy
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicReference
/**
* Centralized OkHttp provider to ensure all network traffic honors Tor settings.
*/
object OkHttpProvider {
private val httpClientRef = AtomicReference<OkHttpClient?>(null)
private val wsClientRef = AtomicReference<OkHttpClient?>(null)
fun reset() {
httpClientRef.set(null)
wsClientRef.set(null)
}
fun httpClient(): OkHttpClient {
httpClientRef.get()?.let { return it }
val client = baseBuilderForCurrentProxy()
.callTimeout(15, TimeUnit.SECONDS)
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.build()
httpClientRef.set(client)
return client
}
fun webSocketClient(): OkHttpClient {
wsClientRef.get()?.let { return it }
val client = baseBuilderForCurrentProxy()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(0, TimeUnit.SECONDS)
.writeTimeout(10, TimeUnit.SECONDS)
.build()
wsClientRef.set(client)
return client
}
private fun baseBuilderForCurrentProxy(): OkHttpClient.Builder {
val builder = OkHttpClient.Builder()
val socks: InetSocketAddress? = TorManager.currentSocksAddress()
if (socks != null && TorManager.isProxyEnabled()) {
val proxy = Proxy(Proxy.Type.SOCKS, socks)
builder.proxy(proxy)
}
return builder
}
}
@@ -0,0 +1,318 @@
package com.bitchat.android.net
import android.app.Application
import android.util.Log
import info.guardianproject.arti.ArtiLogListener
import info.guardianproject.arti.ArtiProxy
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.launch
import kotlinx.coroutines.delay
import kotlinx.coroutines.Job
import java.net.InetSocketAddress
import java.util.concurrent.atomic.AtomicReference
import java.util.concurrent.atomic.AtomicLong
/**
* Manages embedded Tor lifecycle & provides SOCKS proxy address.
* Uses Arti (Tor in Rust) for improved security and reliability.
*/
object TorManager {
private const val TAG = "TorManager"
private const val DEFAULT_SOCKS_PORT = 9060
private const val RESTART_DELAY_MS = 2000L // 2 seconds between stop/start
private const val INACTIVITY_TIMEOUT_MS = 5000L // 5 seconds of no activity before restart
private const val MAX_RETRY_ATTEMPTS = 5
private val appScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
@Volatile private var initialized = false
@Volatile private var socksAddr: InetSocketAddress? = null
private val artiProxyRef = AtomicReference<ArtiProxy?>(null)
@Volatile private var lastMode: TorMode = TorMode.OFF
private val applyMutex = Mutex()
@Volatile private var desiredMode: TorMode = TorMode.OFF
@Volatile private var currentSocksPort: Int = DEFAULT_SOCKS_PORT
@Volatile private var nextSocksPort: Int = DEFAULT_SOCKS_PORT
@Volatile private var lastLogTime = AtomicLong(0L)
@Volatile private var retryAttempts = 0
private var inactivityJob: Job? = null
private var retryJob: Job? = null
private var currentApplication: Application? = null
private enum class LifecycleState { STOPPED, STARTING, RUNNING, STOPPING }
@Volatile private var lifecycleState: LifecycleState = LifecycleState.STOPPED
data class TorStatus(
val mode: TorMode = TorMode.OFF,
val running: Boolean = false,
val bootstrapPercent: Int = 0,
val lastLogLine: String = ""
)
private val _status = MutableStateFlow(TorStatus())
val statusFlow: StateFlow<TorStatus> = _status.asStateFlow()
fun isProxyEnabled(): Boolean {
val s = _status.value
return s.mode != TorMode.OFF && s.running && s.bootstrapPercent >= 100 && socksAddr != null
}
fun init(application: Application) {
if (initialized) return
synchronized(this) {
if (initialized) return
initialized = true
currentApplication = application
TorPreferenceManager.init(application)
// Apply saved mode at startup
appScope.launch {
applyMode(application, TorPreferenceManager.get(application))
}
// Observe changes
appScope.launch {
TorPreferenceManager.modeFlow.collect { mode ->
applyMode(application, mode)
}
}
}
}
fun currentSocksAddress(): InetSocketAddress? = socksAddr
suspend fun applyMode(application: Application, mode: TorMode) {
applyMutex.withLock {
try {
desiredMode = mode
lastMode = mode
val s = _status.value
if (mode == s.mode && mode != TorMode.OFF && (lifecycleState == LifecycleState.STARTING || lifecycleState == LifecycleState.RUNNING)) {
Log.i(TAG, "applyMode: already in progress/running mode=$mode, state=$lifecycleState; skip")
return
}
when (mode) {
TorMode.OFF -> {
Log.i(TAG, "applyMode: OFF -> stopping tor")
lifecycleState = LifecycleState.STOPPING
stopArti()
socksAddr = null
_status.value = _status.value.copy(mode = TorMode.OFF, running = false, bootstrapPercent = 0)
nextSocksPort = DEFAULT_SOCKS_PORT
lifecycleState = LifecycleState.STOPPED
// Rebuild clients WITHOUT proxy and reconnect relays
try {
OkHttpProvider.reset()
com.bitchat.android.nostr.NostrRelayManager.shared.resetAllConnections()
} catch (_: Throwable) { }
}
TorMode.ON -> {
Log.i(TAG, "applyMode: ON -> starting arti")
nextSocksPort = if (currentSocksPort < DEFAULT_SOCKS_PORT) DEFAULT_SOCKS_PORT else currentSocksPort
lifecycleState = LifecycleState.STARTING
// For OFF->ON, no delay needed
val needsDelay = s.mode == TorMode.ISOLATION
startArti(application, isolation = false, useDelay = needsDelay)
_status.value = _status.value.copy(mode = TorMode.ON)
// Defer enabling proxy until bootstrap completes
appScope.launch {
waitUntilBootstrapped()
if (_status.value.running && desiredMode == TorMode.ON) {
socksAddr = InetSocketAddress("127.0.0.1", currentSocksPort)
Log.i(TAG, "Tor ON: proxy set to ${socksAddr}")
OkHttpProvider.reset()
try { com.bitchat.android.nostr.NostrRelayManager.shared.resetAllConnections() } catch (_: Throwable) { }
}
}
}
TorMode.ISOLATION -> {
Log.i(TAG, "applyMode: ISOLATION -> starting arti")
nextSocksPort = if (currentSocksPort < DEFAULT_SOCKS_PORT) DEFAULT_SOCKS_PORT else currentSocksPort
lifecycleState = LifecycleState.STARTING
// For ON->ISOLATION, immediate status change and delay
_status.value = _status.value.copy(running = false, bootstrapPercent = 0)
val needsDelay = s.mode == TorMode.ON
startArti(application, isolation = true, useDelay = needsDelay)
_status.value = _status.value.copy(mode = TorMode.ISOLATION)
appScope.launch {
waitUntilBootstrapped()
if (_status.value.running && desiredMode == TorMode.ISOLATION) {
socksAddr = InetSocketAddress("127.0.0.1", currentSocksPort)
Log.i(TAG, "Arti ISOLATION: proxy set to ${socksAddr}")
OkHttpProvider.reset()
try { com.bitchat.android.nostr.NostrRelayManager.shared.resetAllConnections() } catch (_: Throwable) { }
}
}
}
}
} catch (e: Exception) {
Log.e(TAG, "Failed to apply Arti mode: ${e.message}")
}
}
}
private suspend fun startArti(application: Application, isolation: Boolean, useDelay: Boolean = false) {
try {
stopArtiInternal()
Log.i(TAG, "Starting Arti…")
if (useDelay) {
delay(RESTART_DELAY_MS)
}
// Determine port
val port = nextSocksPort
currentSocksPort = port
val logListener = ArtiLogListener { logLine ->
val text = logLine ?: return@ArtiLogListener
val s = text.toString()
Log.i(TAG, "arti: $s")
lastLogTime.set(System.currentTimeMillis())
_status.value = _status.value.copy(lastLogLine = s)
if (
s.contains("Sufficiently bootstrapped", ignoreCase = true) ||
s.contains("AMEx: state changed to Running", ignoreCase = true)
) {
_status.value = _status.value.copy(bootstrapPercent = 100)
retryAttempts = 0 // Reset retry attempts on successful bootstrap
startInactivityMonitoring()
}
}
val proxy = ArtiProxy.Builder(application)
.setSocksPort(port)
.setDnsPort(port + 1)
.setLogListener(logListener)
.build()
artiProxyRef.set(proxy)
proxy.start()
lastLogTime.set(System.currentTimeMillis())
_status.value = _status.value.copy(running = true, bootstrapPercent = 0)
lifecycleState = LifecycleState.RUNNING
startInactivityMonitoring()
} catch (e: Exception) {
Log.e(TAG, "Error starting Arti: ${e.message}")
scheduleRetry(application, isolation)
}
}
private fun stopArtiInternal() {
try {
val proxy = artiProxyRef.getAndSet(null)
if (proxy != null) {
Log.i(TAG, "Stopping Arti…")
try { proxy.stop() } catch (_: Throwable) {}
}
stopInactivityMonitoring()
stopRetryMonitoring()
} catch (e: Exception) {
Log.w(TAG, "Error stopping Arti: ${e.message}")
}
}
private fun stopArti() {
stopArtiInternal()
socksAddr = null
_status.value = _status.value.copy(running = false, bootstrapPercent = 0)
}
private suspend fun restartArti(application: Application, isolation: Boolean) {
Log.i(TAG, "Restarting Arti (keeping SOCKS proxy enabled)...")
stopArtiInternal()
delay(RESTART_DELAY_MS)
startArti(application, isolation, useDelay = false) // Already delayed above
}
private fun startInactivityMonitoring() {
inactivityJob?.cancel()
inactivityJob = appScope.launch {
while (true) {
delay(INACTIVITY_TIMEOUT_MS)
val currentTime = System.currentTimeMillis()
val lastActivity = lastLogTime.get()
val timeSinceLastActivity = currentTime - lastActivity
if (timeSinceLastActivity > INACTIVITY_TIMEOUT_MS) {
val currentMode = _status.value.mode
if (currentMode == TorMode.ON || currentMode == TorMode.ISOLATION) {
val bootstrapPercent = _status.value.bootstrapPercent
if (bootstrapPercent < 100) {
Log.w(TAG, "Inactivity detected (${timeSinceLastActivity}ms), restarting Arti")
currentApplication?.let { app ->
appScope.launch {
restartArti(app, currentMode == TorMode.ISOLATION)
}
}
break
}
}
}
}
}
}
private fun stopInactivityMonitoring() {
inactivityJob?.cancel()
inactivityJob = null
}
private fun scheduleRetry(application: Application, isolation: Boolean) {
retryJob?.cancel()
if (retryAttempts < MAX_RETRY_ATTEMPTS) {
retryAttempts++
val delayMs = (1000L * (1 shl retryAttempts)).coerceAtMost(30000L) // Exponential backoff, max 30s
Log.w(TAG, "Scheduling Arti retry attempt $retryAttempts in ${delayMs}ms")
retryJob = appScope.launch {
delay(delayMs)
val currentMode = _status.value.mode
if (currentMode == TorMode.ON || currentMode == TorMode.ISOLATION) {
Log.i(TAG, "Retrying Arti start (attempt $retryAttempts)")
restartArti(application, currentMode == TorMode.ISOLATION)
}
}
} else {
Log.e(TAG, "Max retry attempts reached, giving up on Arti connection")
}
}
private fun stopRetryMonitoring() {
retryJob?.cancel()
retryJob = null
}
// Removed Tor resource installation: not needed for Arti
/**
* Build an execution command that works on Android 10+ where app data dirs are mounted noexec.
* We invoke the platform dynamic linker and pass the PIE binary path as its first arg.
*/
// Removed exec command builder: not needed for Arti
private suspend fun waitUntilBootstrapped() {
val current = _status.value
if (!current.running) return
if (current.bootstrapPercent >= 100) return
// Suspend until we observe 100% at least once
while (true) {
val s = statusFlow.first { it.bootstrapPercent >= 100 || !it.running }
if (!s.running) return
if (s.bootstrapPercent >= 100) return
}
}
// Visible for instrumentation tests to validate installation
fun installResourcesForTest(application: Application): Boolean { return true }
}
@@ -0,0 +1,8 @@
package com.bitchat.android.net
enum class TorMode {
OFF,
ON,
ISOLATION
}
@@ -0,0 +1,33 @@
package com.bitchat.android.net
import android.content.Context
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
object TorPreferenceManager {
private const val PREFS_NAME = "bitchat_settings"
private const val KEY_TOR_MODE = "tor_mode"
private val _modeFlow = MutableStateFlow(TorMode.OFF)
val modeFlow: StateFlow<TorMode> = _modeFlow
fun init(context: Context) {
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
val saved = prefs.getString(KEY_TOR_MODE, TorMode.OFF.name)
val mode = runCatching { TorMode.valueOf(saved ?: TorMode.OFF.name) }.getOrDefault(TorMode.OFF)
_modeFlow.value = mode
}
fun set(context: Context, mode: TorMode) {
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit().putString(KEY_TOR_MODE, mode.name).apply()
_modeFlow.value = mode
}
fun get(context: Context): TorMode {
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
val saved = prefs.getString(KEY_TOR_MODE, TorMode.OFF.name)
return runCatching { TorMode.valueOf(saved ?: TorMode.OFF.name) }.getOrDefault(TorMode.OFF)
}
}
@@ -113,12 +113,9 @@ class NostrRelayManager private constructor() {
private var subscriptionValidationJob: Job? = null private var subscriptionValidationJob: Job? = null
private val SUBSCRIPTION_VALIDATION_INTERVAL = 30000L // 30 seconds private val SUBSCRIPTION_VALIDATION_INTERVAL = 30000L // 30 seconds
// OkHttp client for WebSocket connections // OkHttp client for WebSocket connections (via provider to honor Tor)
private val httpClient = OkHttpClient.Builder() private val httpClient: OkHttpClient
.connectTimeout(10, TimeUnit.SECONDS) get() = com.bitchat.android.net.OkHttpProvider.webSocketClient()
.readTimeout(0, TimeUnit.SECONDS) // No read timeout for WebSocket
.writeTimeout(10, TimeUnit.SECONDS)
.build()
private val gson by lazy { NostrRequest.createGson() } private val gson by lazy { NostrRequest.createGson() }
@@ -34,13 +34,8 @@ object RelayDirectory {
private val ONE_DAY_MS = TimeUnit.DAYS.toMillis(1) private val ONE_DAY_MS = TimeUnit.DAYS.toMillis(1)
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val httpClient: OkHttpClient by lazy { private val httpClient: OkHttpClient
OkHttpClient.Builder() get() = com.bitchat.android.net.OkHttpProvider.httpClient()
.callTimeout(15, TimeUnit.SECONDS)
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.build()
}
data class RelayInfo( data class RelayInfo(
val url: String, val url: String,
@@ -306,4 +301,3 @@ object RelayDirectory {
} }
} }
@@ -195,6 +195,97 @@ fun AboutSheet(
} }
} }
// Network (Tor) section
item {
val ctx = LocalContext.current
val torMode = remember { mutableStateOf(com.bitchat.android.net.TorPreferenceManager.get(ctx)) }
val torStatus by com.bitchat.android.net.TorManager.statusFlow.collectAsState()
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = "network",
fontSize = 12.sp,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Medium,
color = colorScheme.onSurface.copy(alpha = 0.8f)
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
FilterChip(
selected = torMode.value == com.bitchat.android.net.TorMode.OFF,
onClick = {
torMode.value = com.bitchat.android.net.TorMode.OFF
com.bitchat.android.net.TorPreferenceManager.set(ctx, torMode.value)
},
label = { Text("tor off", fontFamily = FontFamily.Monospace) }
)
FilterChip(
selected = torMode.value == com.bitchat.android.net.TorMode.ON,
onClick = {
torMode.value = com.bitchat.android.net.TorMode.ON
com.bitchat.android.net.TorPreferenceManager.set(ctx, torMode.value)
},
label = { Text("tor on", fontFamily = FontFamily.Monospace) }
)
FilterChip(
selected = torMode.value == com.bitchat.android.net.TorMode.ISOLATION,
onClick = {
torMode.value = com.bitchat.android.net.TorMode.ISOLATION
com.bitchat.android.net.TorPreferenceManager.set(ctx, torMode.value)
},
label = { Text("isolation mode", fontFamily = FontFamily.Monospace) }
)
// Status indicator (red/orange/green) shown to the right of buttons
val statusColor = when {
torStatus.running && torStatus.bootstrapPercent < 100 -> Color(0xFFFF9500)
torStatus.running && torStatus.bootstrapPercent >= 100 -> if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D)
else -> Color.Red
}
Surface(
color = statusColor,
shape = RoundedCornerShape(50)
) { Box(Modifier.size(10.dp)) }
}
Text(
text = "route internet over tor. isolation uses separate circuits per relay.",
fontSize = 10.sp,
fontFamily = FontFamily.Monospace,
color = colorScheme.onSurface.copy(alpha = 0.6f)
)
// Debug status (temporary)
Surface(
modifier = Modifier.fillMaxWidth(),
color = colorScheme.surfaceVariant.copy(alpha = 0.25f),
shape = RoundedCornerShape(8.dp)
) {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(
text = "tor status: " +
(if (torStatus.running) "running" else "stopped") +
", bootstrap=" + torStatus.bootstrapPercent + "%",
fontSize = 11.sp,
fontFamily = FontFamily.Monospace,
color = colorScheme.onSurface.copy(alpha = 0.75f)
)
val last = torStatus.lastLogLine
if (last.isNotEmpty()) {
Text(
text = "last: " + last.take(160),
fontSize = 10.sp,
fontFamily = FontFamily.Monospace,
color = colorScheme.onSurface.copy(alpha = 0.6f)
)
}
}
}
}
}
// Emergency warning // Emergency warning
item { item {
Surface( Surface(
@@ -48,6 +48,27 @@ fun isFavoriteReactive(
} }
} }
@Composable
fun TorStatusIcon(
modifier: Modifier = Modifier
) {
val torStatus by com.bitchat.android.net.TorManager.statusFlow.collectAsState()
if (torStatus.mode != com.bitchat.android.net.TorMode.OFF) {
val cableColor = when {
torStatus.running && torStatus.bootstrapPercent < 100 -> Color(0xFFFF9500)
torStatus.running && torStatus.bootstrapPercent >= 100 -> Color(0xFF00C851)
else -> Color.Red
}
Icon(
imageVector = Icons.Outlined.Cable,
contentDescription = "Tor status",
modifier = modifier,
tint = cableColor
)
}
}
@Composable @Composable
fun NoiseSessionIcon( fun NoiseSessionIcon(
sessionState: String?, sessionState: String?,
@@ -539,9 +560,12 @@ private fun MainHeader(
// Right section with location channels button and peer counter // Right section with location channels button and peer counter
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp) horizontalArrangement = Arrangement.spacedBy(5.dp)
) { ) {
// Tor status cable icon when Tor is enabled
TorStatusIcon(modifier = Modifier.size(15.dp))
// Location channels button (matching iOS implementation) // Location channels button (matching iOS implementation)
LocationChannelsButton( LocationChannelsButton(
viewModel = viewModel, viewModel = viewModel,
+5
View File
@@ -36,6 +36,8 @@ nordic-ble = "2.6.1"
# WebSocket # WebSocket
okhttp = "4.12.0" okhttp = "4.12.0"
tor-android-binary = "0.4.4.6"
# Google Play Services # Google Play Services
gms-location = "21.3.0" gms-location = "21.3.0"
@@ -93,6 +95,9 @@ nordic-ble = { module = "no.nordicsemi.android:ble", version.ref = "nordic-ble"
# WebSocket # WebSocket
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
tor-android-binary = { module = "org.torproject:tor-android-binary", version.ref = "tor-android-binary" }
# Tor (embed) intentionally not pinned yet; add once repo is chosen
# Google Play Services # Google Play Services
gms-location = { module = "com.google.android.gms:play-services-location", version.ref = "gms-location" } gms-location = { module = "com.google.android.gms:play-services-location", version.ref = "gms-location" }
+3
View File
@@ -10,8 +10,11 @@ dependencyResolutionManagement {
repositories { repositories {
google() google()
mavenCentral() mavenCentral()
// Guardian Project raw GitHub Maven (hosts info.guardianproject:arti-mobile-ex)
maven { url = uri("https://raw.githubusercontent.com/guardianproject/gpmaven/master") }
} }
} }
rootProject.name = "bitchat-android" rootProject.name = "bitchat-android"
include(":app") include(":app")
// Using published Arti AAR; local module not included