Add built-in Tor support via kmptor library

Introduces embedded Tor functionality as an alternative to the existing
Orbot proxy integration, so users no longer need an external app to
route traffic through the Tor network.

Key changes:
- Add TorMode enum (DISABLED, ORBOT, BUILTIN) replacing the useProxy boolean
- Create TorManager (free-flavor only) using io.matthewnelson.kmp-tor:runtime
  and resource-exec-tor to manage the Tor daemon lifecycle
- Offline flavor gets a no-op TorManager stub to maintain build compatibility
- AmberSettings: replace useProxy field with torMode enum; useProxy becomes
  a computed property (torMode != DISABLED) for backward compatibility
- LocalPreferences: migrate old use_proxy boolean to TorMode on first load,
  save/load torMode, update proxy config per mode
- Amber: start built-in Tor on app launch when torMode == BUILTIN;
  isSocksProxyAlive returns TorManager.isRunning for built-in mode
- ConnectOrbotDialog: add Built-in Tor section above the Orbot section
- SettingsScreen: show mode-specific disconnect label (built-in vs orbot)
- HttpClientManager: add clearProxy() public method
- AndroidManifest: add INTERNET permission explicitly
- gradle: add kmptor 2.6.0 + resource-exec-tor 408.19.0 dependencies,
  enable jniLibs.useLegacyPackaging for native binary extraction

https://claude.ai/code/session_01JwcwxdYrDnr3QyHqTBz33a
This commit is contained in:
Claude
2026-03-13 20:03:58 +00:00
parent 0114167eb4
commit e3914e56a5
16 changed files with 266 additions and 20 deletions
+3
View File
@@ -164,6 +164,7 @@ android {
excludes += '/META-INF/{AL2.0,LGPL2.1}'
excludes +='META-INF/versions/9/OSGI-INF/MANIFEST.MF'
}
jniLibs.useLegacyPackaging = true
}
}
@@ -233,6 +234,8 @@ dependencies {
freeImplementation libs.okhttp
freeImplementation libs.okhttpCoroutines
freeImplementation libs.kmptor.runtime
freeImplementation libs.kmptor.resource.exec
// Load images from the web.
implementation libs.coil.compose
@@ -0,0 +1,86 @@
package com.greenart7c3.nostrsigner.service
import android.content.Context
import android.util.Log
import com.greenart7c3.nostrsigner.okhttp.HttpClientManager
import io.matthewnelson.kmp.tor.resource.exec.tor.ResourceLoaderTorExec
import io.matthewnelson.kmp.tor.runtime.RuntimeEvent
import io.matthewnelson.kmp.tor.runtime.TorRuntime
import io.matthewnelson.kmp.tor.runtime.core.OnEvent
import io.matthewnelson.kmp.tor.runtime.core.config.TorOption
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
object TorManager {
private const val TAG = "TorManager"
@Volatile
private var torRuntime: TorRuntime? = null
private val _isRunning = MutableStateFlow(false)
val isRunning: StateFlow<Boolean> = _isRunning.asStateFlow()
fun start(context: Context, scope: CoroutineScope) {
if (torRuntime != null) return
scope.launch(Dispatchers.IO) {
try {
val workDir = context.filesDir.resolve("kmptor")
val cacheDir = context.cacheDir.resolve("kmptor")
val env = TorRuntime.Environment.Builder(workDir, cacheDir, ResourceLoaderTorExec::getOrCreate)
val runtime = TorRuntime.Builder(env) {
// Log all runtime events for debugging
RuntimeEvent.entries().forEach { event ->
observerStatic(event, OnEvent.Executor.Immediate) { data ->
Log.d(TAG, data.toString())
}
}
// Observe SOCKS listeners to get the assigned port
observerStatic(RuntimeEvent.LISTENERS, OnEvent.Executor.Immediate) { listeners ->
val addr = listeners.socks.firstOrNull()
if (addr != null) {
try {
val port = addr.port.value
Log.i(TAG, "Built-in Tor SOCKS proxy on port $port")
HttpClientManager.setDefaultProxyOnPort(port)
} catch (e: Exception) {
Log.e(TAG, "Failed to read Tor SOCKS port", e)
}
}
}
config {
TorOption.__SocksPort.configure { auto() }
}
}
torRuntime = runtime
runtime.startDaemonSync()
_isRunning.value = true
Log.i(TAG, "Built-in Tor started")
} catch (e: Exception) {
Log.e(TAG, "Failed to start built-in Tor", e)
torRuntime = null
_isRunning.value = false
}
}
}
fun stop() {
try {
torRuntime?.stopDaemonSync()
Log.i(TAG, "Built-in Tor stopped")
} catch (e: Exception) {
Log.e(TAG, "Error stopping built-in Tor", e)
} finally {
torRuntime = null
_isRunning.value = false
}
}
}
+1
View File
@@ -8,6 +8,7 @@
<package android:name="dev.zapstore.app" />
</queries>
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
@@ -37,10 +37,12 @@ import com.greenart7c3.nostrsigner.okhttp.HttpClientManager
import com.greenart7c3.nostrsigner.okhttp.OkHttpWebSocket
import com.greenart7c3.nostrsigner.relays.AmberRelayStats
import com.greenart7c3.nostrsigner.relays.NostrClientLoggerListener
import com.greenart7c3.nostrsigner.models.TorMode
import com.greenart7c3.nostrsigner.service.ClearLogsWorker
import com.greenart7c3.nostrsigner.service.ConnectivityService
import com.greenart7c3.nostrsigner.service.NotificationSubscription
import com.greenart7c3.nostrsigner.service.ProfileSubscription
import com.greenart7c3.nostrsigner.service.TorManager
import com.greenart7c3.nostrsigner.service.crashreports.CrashReportCache
import com.greenart7c3.nostrsigner.service.crashreports.UnexpectedCrashSaver
import com.greenart7c3.nostrsigner.ui.ToastManager
@@ -138,6 +140,9 @@ class Amber :
val notificationCache = LruCache<String, Long>(10)
fun isSocksProxyAlive(proxyHost: String, proxyPort: Int): Boolean {
if (settings.torMode == TorMode.BUILTIN) {
return TorManager.isRunning.value
}
try {
val socket = Socket()
socket.connect(InetSocketAddress(proxyHost, proxyPort), 5000) // 3-second timeout
@@ -245,6 +250,9 @@ class Amber :
LocalPreferences.switchToAccount(this@Amber, LocalPreferences.allSavedAccounts(this@Amber).first().npub)
}
LocalPreferences.reloadApp()
if (settings.torMode == TorMode.BUILTIN) {
TorManager.start(this@Amber, applicationIOScope)
}
checkForNewRelaysAndUpdateAllFilters(true)
if (settings.killSwitch.value) {
disconnectIntentionally()
@@ -10,6 +10,7 @@ import androidx.core.content.edit
import androidx.core.os.LocaleListCompat
import com.greenart7c3.nostrsigner.models.Account
import com.greenart7c3.nostrsigner.models.AmberSettings
import com.greenart7c3.nostrsigner.models.TorMode
import com.greenart7c3.nostrsigner.models.defaultAppRelays
import com.greenart7c3.nostrsigner.models.defaultIndexerRelays
import com.greenart7c3.nostrsigner.okhttp.HttpClientManager
@@ -47,6 +48,7 @@ private enum class SettingsKeys(val key: String) {
SAVED_ACCOUNTS("all_saved_accounts"),
USE_PROXY("use_proxy"),
PROXY_PORT("proxy_port"),
TOR_MODE("tor_mode"),
BATERRY_OPTIMIZATION("battery_optimization"),
KILL_SWITCH("kill_switch"),
LANGUAGE_PREFS("languagePreferences"),
@@ -119,7 +121,7 @@ object LocalPreferences {
putBoolean(SettingsKeys.USE_AUTH.key, settings.useAuth)
putInt(SettingsKeys.BIOMETRICS_TYPE.key, settings.biometricsTimeType.screenCode)
putBoolean(SettingsKeys.USE_PIN.key, settings.usePin)
putBoolean(SettingsKeys.USE_PROXY.key, settings.useProxy)
putString(SettingsKeys.TOR_MODE.key, settings.torMode.name)
putInt(SettingsKeys.PROXY_PORT.key, settings.proxyPort)
putBoolean(SettingsKeys.KILL_SWITCH.key, settings.killSwitch.value)
putString(SettingsKeys.LANGUAGE_PREFS.key, settings.language)
@@ -192,8 +194,23 @@ object LocalPreferences {
fun loadSettingsFromEncryptedStorage(context: Context = Amber.instance): AmberSettings {
checkNotInMainThread()
sharedPrefs(context).apply {
// Migrate old use_proxy boolean to TorMode enum
val torMode = if (contains(SettingsKeys.TOR_MODE.key)) {
try {
TorMode.valueOf(getString(SettingsKeys.TOR_MODE.key, TorMode.DISABLED.name)!!)
} catch (e: IllegalArgumentException) {
TorMode.DISABLED
}
} else if (getBoolean(SettingsKeys.USE_PROXY.key, false)) {
TorMode.ORBOT
} else {
TorMode.DISABLED
}
val proxyPort = getInt(SettingsKeys.PROXY_PORT.key, 9050)
if (torMode == TorMode.ORBOT) {
HttpClientManager.setDefaultProxyOnPort(proxyPort)
}
return AmberSettings(
defaultRelays = getStringSet(SettingsKeys.DEFAULT_RELAYS.key, null)?.mapNotNull {
@@ -206,8 +223,8 @@ object LocalPreferences {
useAuth = getBoolean(SettingsKeys.USE_AUTH.key, false),
biometricsTimeType = parseBiometricsTimeType(getInt(SettingsKeys.BIOMETRICS_TYPE.key, 0)),
usePin = getBoolean(SettingsKeys.USE_PIN.key, false),
useProxy = getBoolean(SettingsKeys.USE_PROXY.key, false),
proxyPort = getInt(SettingsKeys.PROXY_PORT.key, 9050),
torMode = torMode,
proxyPort = proxyPort,
killSwitch = MutableStateFlow(getBoolean(SettingsKeys.KILL_SWITCH.key, false)),
language = getString(SettingsKeys.LANGUAGE_PREFS.key, null),
)
@@ -425,15 +442,28 @@ object LocalPreferences {
context: Context,
useProxy: Boolean,
port: Int,
) {
val torMode = if (useProxy) TorMode.ORBOT else TorMode.DISABLED
updateTorMode(context, torMode, port)
}
fun updateTorMode(
context: Context,
torMode: TorMode,
port: Int = Amber.instance.settings.proxyPort,
) {
sharedPrefs(context).edit {
apply {
putBoolean(SettingsKeys.USE_PROXY.key, useProxy)
putString(SettingsKeys.TOR_MODE.key, torMode.name)
putInt(SettingsKeys.PROXY_PORT.key, port)
}
}
Amber.instance.settings = loadSettingsFromEncryptedStorage()
HttpClientManager.setDefaultProxyOnPort(port)
when (torMode) {
TorMode.ORBOT -> HttpClientManager.setDefaultProxyOnPort(port)
TorMode.DISABLED -> HttpClientManager.clearProxy()
TorMode.BUILTIN -> { /* Proxy port set by TorManager once Tor starts */ }
}
}
suspend fun loadFromEncryptedStorage(context: Context, npub: String): Account? {
@@ -25,8 +25,10 @@ data class AmberSettings(
val useAuth: Boolean = false,
val biometricsTimeType: BiometricsTimeType = BiometricsTimeType.EVERY_TIME,
val usePin: Boolean = false,
val useProxy: Boolean = false,
val torMode: TorMode = TorMode.DISABLED,
val proxyPort: Int = 9050,
val killSwitch: MutableStateFlow<Boolean> = MutableStateFlow(false),
var language: String? = null,
)
) {
val useProxy: Boolean get() = torMode != TorMode.DISABLED
}
@@ -0,0 +1,7 @@
package com.greenart7c3.nostrsigner.models
enum class TorMode {
DISABLED,
ORBOT,
BUILTIN,
}
@@ -110,4 +110,8 @@ object HttpClientManager {
fun setDefaultProxyOnPort(port: Int) {
setDefaultProxy(Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", port)))
}
fun clearProxy() {
setDefaultProxy(null)
}
}
@@ -6,6 +6,7 @@ import androidx.lifecycle.ViewModel
import com.greenart7c3.nostrsigner.Amber
import com.greenart7c3.nostrsigner.LocalPreferences
import com.greenart7c3.nostrsigner.models.Account
import com.greenart7c3.nostrsigner.models.TorMode
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
@@ -111,7 +112,7 @@ class AccountStateViewModel(npub: String?) : ViewModel() {
if (LocalPreferences.allSavedAccounts(Amber.instance).isEmpty()) {
Amber.instance.settings = Amber.instance.settings.copy(
useProxy = useProxy,
torMode = if (useProxy) TorMode.ORBOT else TorMode.DISABLED,
proxyPort = proxyPort,
)
LocalPreferences.saveSettingsToEncryptedStorage(
@@ -145,7 +146,7 @@ class AccountStateViewModel(npub: String?) : ViewModel() {
)
if (LocalPreferences.allSavedAccounts(Amber.instance).isEmpty()) {
Amber.instance.settings = Amber.instance.settings.copy(
useProxy = useProxy,
torMode = if (useProxy) TorMode.ORBOT else TorMode.DISABLED,
proxyPort = proxyPort,
)
LocalPreferences.saveSettingsToEncryptedStorage(
@@ -58,10 +58,13 @@ import com.greenart7c3.nostrsigner.Amber
import com.greenart7c3.nostrsigner.BuildFlavorChecker
import com.greenart7c3.nostrsigner.LocalPreferences
import com.greenart7c3.nostrsigner.R
import com.greenart7c3.nostrsigner.okhttp.HttpClientManager
import com.greenart7c3.nostrsigner.models.Account
import com.greenart7c3.nostrsigner.models.AmberBunkerRequest
import com.greenart7c3.nostrsigner.models.IntentData
import com.greenart7c3.nostrsigner.models.IntentResultType
import com.greenart7c3.nostrsigner.models.TorMode
import com.greenart7c3.nostrsigner.service.TorManager
import com.greenart7c3.nostrsigner.service.crashreports.DisplayCrashMessages
import com.greenart7c3.nostrsigner.ui.actions.AccountBackupScreen
import com.greenart7c3.nostrsigner.ui.actions.AccountsBottomSheet
@@ -848,6 +851,9 @@ fun MainScreen(
.padding(top = verticalPadding * 1.5f),
onPost = {
scope.launch(Dispatchers.IO) {
if (Amber.instance.settings.torMode == TorMode.BUILTIN) {
TorManager.stop()
}
LocalPreferences.updateProxy(context, true, it)
Amber.instance.checkForNewRelaysAndUpdateAllFilters()
scope.launch {
@@ -857,6 +863,21 @@ fun MainScreen(
}
}
},
onBuiltinTor = {
scope.launch(Dispatchers.IO) {
if (Amber.instance.settings.torMode == TorMode.ORBOT) {
HttpClientManager.clearProxy()
}
LocalPreferences.updateTorMode(context, TorMode.BUILTIN)
TorManager.start(context, Amber.instance.applicationIOScope)
Amber.instance.checkForNewRelaysAndUpdateAllFilters()
scope.launch {
navController.navigate(Route.Settings.route) {
popUpTo(0)
}
}
}
},
onError = {
scope.launch {
Toast.makeText(
@@ -55,6 +55,7 @@ import com.greenart7c3.nostrsigner.BuildFlavorChecker
import com.greenart7c3.nostrsigner.LocalPreferences
import com.greenart7c3.nostrsigner.R
import com.greenart7c3.nostrsigner.models.Account
import com.greenart7c3.nostrsigner.models.TorMode
import com.greenart7c3.nostrsigner.ui.actions.LogoutDialog
import com.greenart7c3.nostrsigner.ui.components.AmberButton
import com.greenart7c3.nostrsigner.ui.components.IconRow
@@ -78,7 +79,7 @@ fun SettingsScreen(
) {
var logoutDialog by remember { mutableStateOf(false) }
val context = LocalContext.current
var checked by remember { mutableStateOf(Amber.instance.settings.useProxy) }
var torMode by remember { mutableStateOf(Amber.instance.settings.torMode) }
var disconnectTorDialog by remember { mutableStateOf(false) }
val proxyPort = remember { mutableStateOf(Amber.instance.settings.proxyPort.toString()) }
val scope = rememberCoroutineScope()
@@ -178,10 +179,10 @@ fun SettingsScreen(
.padding(vertical = 8.dp),
) {
IconRow(
title = if (checked) {
stringResource(R.string.disconnect_from_your_orbot_setup)
} else {
stringResource(R.string.connect_via_tor_short)
title = when (torMode) {
TorMode.BUILTIN -> stringResource(R.string.disconnect_builtin_tor)
TorMode.ORBOT -> stringResource(R.string.disconnect_from_your_orbot_setup)
TorMode.DISABLED -> stringResource(R.string.connect_via_tor_short)
},
icon = R.drawable.ic_tor,
tint = MaterialTheme.colorScheme.onBackground,
@@ -189,7 +190,7 @@ fun SettingsScreen(
navController.navigate(Route.TorSettings.route)
},
onClick = {
if (checked) {
if (torMode != TorMode.DISABLED) {
disconnectTorDialog = true
} else {
navController.navigate(Route.TorSettings.route)
@@ -369,9 +370,12 @@ fun SettingsScreen(
TextButton(
onClick = {
disconnectTorDialog = false
checked = false
torMode = TorMode.DISABLED
scope.launch(Dispatchers.IO) {
LocalPreferences.updateProxy(context, false, proxyPort.value.toInt())
if (Amber.instance.settings.torMode == TorMode.BUILTIN) {
com.greenart7c3.nostrsigner.service.TorManager.stop()
}
LocalPreferences.updateTorMode(context, TorMode.DISABLED)
Amber.instance.checkForNewRelaysAndUpdateAllFilters()
}
},
@@ -29,6 +29,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
@@ -39,12 +40,14 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import com.greenart7c3.nostrsigner.Amber
import com.greenart7c3.nostrsigner.R
import com.greenart7c3.nostrsigner.models.TorMode
import com.greenart7c3.nostrsigner.ui.components.AmberButton
import com.greenart7c3.nostrsigner.ui.theme.RichTextDefaults
import com.halilibo.richtext.commonmark.CommonMarkdownParseOptions
@@ -57,6 +60,7 @@ import kotlinx.coroutines.CancellationException
fun ConnectOrbotScreen(
modifier: Modifier,
onPost: (Int) -> Unit,
onBuiltinTor: () -> Unit,
onError: (String) -> Unit,
) {
val portNumber = remember { mutableStateOf(Amber.instance.settings.proxyPort.toString()) }
@@ -64,6 +68,46 @@ fun ConnectOrbotScreen(
Column(
modifier = modifier,
) {
Text(
text = stringResource(R.string.builtin_tor_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.builtin_tor_description),
style = MaterialTheme.typography.bodyMedium,
)
Spacer(modifier = Modifier.height(8.dp))
AmberButton(
modifier = Modifier.fillMaxWidth(),
onClick = onBuiltinTor,
text = if (Amber.instance.settings.torMode == TorMode.BUILTIN) {
stringResource(R.string.builtin_tor_active)
} else {
stringResource(R.string.use_builtin_tor)
},
enabled = Amber.instance.settings.torMode != TorMode.BUILTIN,
)
Spacer(modifier = Modifier.height(24.dp))
HorizontalDivider()
Spacer(modifier = Modifier.height(24.dp))
Text(
text = stringResource(R.string.orbot_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
Spacer(modifier = Modifier.height(8.dp))
val myMarkDownStyle =
RichTextDefaults.copy(
stringStyle = RichTextDefaults.stringStyle?.copy(
+8 -2
View File
@@ -109,9 +109,15 @@
<string name="invalid_port_number">Invalid port number</string>
<string name="use_orbot">Use Orbot</string>
<string name="disconnect_from_your_orbot_setup">Disconnect Tor/Orbot</string>
<string name="connect_via_tor_short">Tor/Orbot setup</string>
<string name="do_you_really_want_to_disable_tor_title">Disconnect from your Orbot/Tor?</string>
<string name="connect_via_tor_short">Tor setup</string>
<string name="do_you_really_want_to_disable_tor_title">Disconnect from Tor?</string>
<string name="do_you_really_want_to_disable_tor_text">Your data will be immediately transferred in the regular network</string>
<string name="builtin_tor_title">Built-in Tor</string>
<string name="builtin_tor_description">Run Tor directly inside the app. No external app required.</string>
<string name="use_builtin_tor">Use Built-in Tor</string>
<string name="builtin_tor_active">Built-in Tor Active</string>
<string name="disconnect_builtin_tor">Disconnect Built-in Tor</string>
<string name="orbot_title">Orbot (External App)</string>
<string name="yes">Yes</string>
<string name="no">No</string>
<string name="could_not_connect_to_tor">Could not connect to Tor</string>
@@ -0,0 +1,24 @@
package com.greenart7c3.nostrsigner.service
import android.content.Context
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
object TorManager {
private val _socksPort = MutableStateFlow(0)
val socksPort: StateFlow<Int> = _socksPort.asStateFlow()
private val _isRunning = MutableStateFlow(false)
val isRunning: StateFlow<Boolean> = _isRunning.asStateFlow()
@Suppress("UNUSED_PARAMETER")
fun start(context: Context, scope: CoroutineScope) {
// No-op in offline flavor
}
fun stop() {
// No-op in offline flavor
}
}
+1
View File
@@ -23,3 +23,4 @@ kotlin.code.style=official
android.nonTransitiveRClass=true
android.enableR8.fullMode=true
org.gradle.configuration-cache=true
android.bundle.enableUncompressedNativeLibs=false
+4
View File
@@ -31,6 +31,8 @@ collections = "0.4.0"
pagingCommon = "3.4.1"
mockk = "1.14.9"
coroutinesTest = "1.10.2"
kmpTor = "2.6.0"
kmpTorResource = "408.19.0"
[libraries]
datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" }
@@ -81,6 +83,8 @@ paging-compose = { group = "androidx.paging", name = "paging-compose", version.r
paging-runtime = { group = "androidx.paging", name = "paging-runtime", version.ref = "pagingCommon" }
mockk = { module = "io.mockk:mockk", version.ref = "mockk" }
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutinesTest" }
kmptor-runtime = { module = "io.matthewnelson.kmp-tor:runtime", version.ref = "kmpTor" }
kmptor-resource-exec = { module = "io.matthewnelson.kmp-tor:resource-exec-tor", version.ref = "kmpTorResource" }
[plugins]
androidLibrary = { id = "com.android.library", version.ref = "agp" }