diff --git a/.claude/skills/android-expert/SKILL.md b/.claude/skills/android-expert/SKILL.md
index 8275b4274..b24c48fd2 100644
--- a/.claude/skills/android-expert/SKILL.md
+++ b/.claude/skills/android-expert/SKILL.md
@@ -745,8 +745,8 @@ android {
applicationId = "com.vitorpamplona.amethyst"
minSdk = 26 // Android 8.0 (Oreo)
targetSdk = 36 // Android 15
- versionCode = 441
- versionName = "1.07.5"
+ versionCode = 442
+ versionName = "1.08.0"
vectorDrawables {
useSupportLibrary = true
diff --git a/.claude/skills/quartz-integration/SKILL.md b/.claude/skills/quartz-integration/SKILL.md
index b78218622..90f533702 100644
--- a/.claude/skills/quartz-integration/SKILL.md
+++ b/.claude/skills/quartz-integration/SKILL.md
@@ -7,7 +7,7 @@ description: Integration guide for using the Quartz Nostr KMP library in externa
Reference for integrating `com.vitorpamplona.quartz:quartz` into external Nostr KMP projects.
-**Published artifact**: `com.vitorpamplona.quartz:quartz:1.07.5` (Maven Central)
+**Published artifact**: `com.vitorpamplona.quartz:quartz:1.08.0` (Maven Central)
**Targets**: JVM 21+, Android (minSdk 21+), iOS (XCFramework `quartz-kmpKit`)
**License**: MIT
@@ -19,7 +19,7 @@ Reference for integrating `com.vitorpamplona.quartz:quartz` into external Nostr
```toml
[versions]
-quartz = "1.07.5"
+quartz = "1.08.0"
[libraries]
quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" }
@@ -41,7 +41,7 @@ kotlin {
```kotlin
dependencies {
- implementation("com.vitorpamplona.quartz:quartz:1.07.5")
+ implementation("com.vitorpamplona.quartz:quartz:1.08.0")
}
```
diff --git a/.claude/skills/quartz-integration/references/gradle-setup.md b/.claude/skills/quartz-integration/references/gradle-setup.md
index ac7797549..59293256c 100644
--- a/.claude/skills/quartz-integration/references/gradle-setup.md
+++ b/.claude/skills/quartz-integration/references/gradle-setup.md
@@ -3,7 +3,7 @@
## Current version
```
-com.vitorpamplona.quartz:quartz:1.07.5
+com.vitorpamplona.quartz:quartz:1.08.0
```
Check latest: https://central.sonatype.com/artifact/com.vitorpamplona.quartz/quartz
@@ -16,7 +16,7 @@ Check latest: https://central.sonatype.com/artifact/com.vitorpamplona.quartz/qua
```toml
[versions]
-quartz = "1.07.5"
+quartz = "1.08.0"
[libraries]
quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" }
@@ -55,7 +55,7 @@ kotlin {
```kotlin
// build.gradle.kts (app module)
dependencies {
- implementation("com.vitorpamplona.quartz:quartz:1.07.5")
+ implementation("com.vitorpamplona.quartz:quartz:1.08.0")
}
```
@@ -70,7 +70,7 @@ plugins {
}
dependencies {
- implementation("com.vitorpamplona.quartz:quartz:1.07.5")
+ implementation("com.vitorpamplona.quartz:quartz:1.08.0")
// JNA needed for libsodium (NIP-44) on JVM
implementation("net.java.dev.jna:jna:5.18.1")
}
diff --git a/.claude/skills/quartz-kmp.md b/.claude/skills/quartz-kmp.md
index 695c234f8..c8e10823f 100644
--- a/.claude/skills/quartz-kmp.md
+++ b/.claude/skills/quartz-kmp.md
@@ -17,7 +17,7 @@ The Quartz library was successfully converted from Android-only to full KMP supp
## Current artifact
```
-com.vitorpamplona.quartz:quartz:1.07.5
+com.vitorpamplona.quartz:quartz:1.08.0
```
See `.claude/skills/quartz-integration/SKILL.md` for full integration guide.
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4c460ab9e..86437fff6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,9 @@
+
+# [Release v1.08.0: Arti](https://github.com/vitorpamplona/amethyst/releases/tag/v1.08.0) - 2026-04-01
+
+- Migrates C Tor to Arti Tor (hopefully no more random crashes)
+- Fixes highlight of users when composing and tagging
+
# [Release v1.07.5: Image upload fix](https://github.com/vitorpamplona/amethyst/releases/tag/v1.07.5) - 2026-03-31
diff --git a/amethyst/build.gradle b/amethyst/build.gradle
index 53bef0cc8..57630fc71 100644
--- a/amethyst/build.gradle
+++ b/amethyst/build.gradle
@@ -54,9 +54,9 @@ android {
applicationId = "com.vitorpamplona.amethyst"
minSdk = libs.versions.android.minSdk.get().toInteger()
targetSdk = libs.versions.android.targetSdk.get().toInteger()
- versionCode = 441
- versionName = generateVersionName("1.07.5")
- buildConfigField "String", "RELEASE_NOTES_ID", "\"12cd4bce977ed53502cf121ecba89a190ab02685333c8f230bac35b04f920eeb\""
+ versionCode = 442
+ versionName = generateVersionName("1.08.0")
+ buildConfigField "String", "RELEASE_NOTES_ID", "\"be99e8c8d4df0f54b44eb6c96976ccb38baeea0192436a1c6fc8bc5e930da6b0\""
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -349,9 +349,6 @@ dependencies {
implementation libs.vico.charts.compose
implementation libs.vico.charts.m3
- // GeoHash
- implementation libs.drfonfon.geohash
-
// Waveform visualizer
implementation libs.audiowaveform
@@ -375,9 +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.tor.android
- implementation libs.jtorctl
-
testImplementation libs.junit
testImplementation libs.mockk
testImplementation libs.kotlinx.coroutines.test
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/AroundMeExpander.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/AroundMeExpander.kt
index 3b09904d6..dd5325011 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/AroundMeExpander.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/AroundMeExpander.kt
@@ -20,7 +20,7 @@
*/
package com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe
-import com.fonfon.kgeohash.GeoHash
+import com.vitorpamplona.amethyst.service.location.GeoHash
fun compute50kmLine(geoHash: GeoHash): List {
val hashes = mutableListOf()
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/GeoHash.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/GeoHash.kt
new file mode 100644
index 000000000..4741b104a
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/GeoHash.kt
@@ -0,0 +1,268 @@
+/*
+ * 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.service.location
+
+import android.location.Location
+
+/**
+ * GeoHash encoding/decoding based on Gustavo Niemeyer's algorithm (2008).
+ *
+ * Bits are interleaved: even-indexed bits (0, 2, 4, ...) = longitude,
+ * odd-indexed bits (1, 3, 5, ...) = latitude.
+ */
+class GeoHash private constructor(
+ // Left-aligned: MSB is bit 0 of the hash
+ private val bits: Long,
+ private val significantBits: Int,
+ val centerLat: Double,
+ val centerLon: Double,
+) {
+ companion object {
+ const val BASE32 = "0123456789bcdefghjkmnpqrstuvwxyz"
+ private const val BITS_PER_CHAR = 5
+ const val MAX_CHAR_PRECISION = 12
+ private const val MAX_BITS = 64
+ private const val LAT_MAX = 90.0
+ private const val LON_MAX = 180.0
+
+ // ASCII → base32 index; -1 for invalid characters
+ private val CHAR_INDEX =
+ IntArray(128) { -1 }.also {
+ BASE32.forEachIndexed { i, c -> it[c.code] = i }
+ }
+
+ /** Encode [lat]/[lon] into a GeoHash with [charsCount] character precision. */
+ fun encode(
+ lat: Double,
+ lon: Double,
+ charsCount: Int,
+ ): GeoHash {
+ require(charsCount in 1..MAX_CHAR_PRECISION)
+ val targetBits = charsCount * BITS_PER_CHAR
+ val latRange = doubleArrayOf(-LAT_MAX, LAT_MAX)
+ val lonRange = doubleArrayOf(-LON_MAX, LON_MAX)
+ var bits = 0L
+ var isEven = true
+
+ repeat(targetBits) {
+ if (isEven) {
+ val mid = (lonRange[0] + lonRange[1]) * 0.5
+ if (lon >= mid) {
+ bits = (bits shl 1) or 1L
+ lonRange[0] = mid
+ } else {
+ bits = bits shl 1
+ lonRange[1] = mid
+ }
+ } else {
+ val mid = (latRange[0] + latRange[1]) * 0.5
+ if (lat >= mid) {
+ bits = (bits shl 1) or 1L
+ latRange[0] = mid
+ } else {
+ bits = bits shl 1
+ latRange[1] = mid
+ }
+ }
+ isEven = !isEven
+ }
+
+ return GeoHash(
+ bits = bits shl (MAX_BITS - targetBits),
+ significantBits = targetBits,
+ centerLat = (latRange[0] + latRange[1]) * 0.5,
+ centerLon = (lonRange[0] + lonRange[1]) * 0.5,
+ )
+ }
+
+ /** Decode a GeoHash string. Returns null if the string is invalid. */
+ fun decode(hash: String): GeoHash? {
+ if (hash.isEmpty() || hash.length > MAX_CHAR_PRECISION) return null
+ val latRange = doubleArrayOf(-LAT_MAX, LAT_MAX)
+ val lonRange = doubleArrayOf(-LON_MAX, LON_MAX)
+ var bits = 0L
+ var isEven = true
+
+ for (c in hash) {
+ val code = c.code
+ if (code >= 128) return null
+ val index = CHAR_INDEX[code]
+ if (index < 0) return null
+
+ for (j in 4 downTo 0) {
+ val bitSet = (index ushr j) and 1 == 1
+ if (isEven) {
+ val mid = (lonRange[0] + lonRange[1]) * 0.5
+ if (bitSet) {
+ bits = (bits shl 1) or 1L
+ lonRange[0] = mid
+ } else {
+ bits = bits shl 1
+ lonRange[1] = mid
+ }
+ } else {
+ val mid = (latRange[0] + latRange[1]) * 0.5
+ if (bitSet) {
+ bits = (bits shl 1) or 1L
+ latRange[0] = mid
+ } else {
+ bits = bits shl 1
+ latRange[1] = mid
+ }
+ }
+ isEven = !isEven
+ }
+ }
+
+ val sigBits = hash.length * BITS_PER_CHAR
+ return GeoHash(
+ bits = bits shl (MAX_BITS - sigBits),
+ significantBits = sigBits,
+ centerLat = (latRange[0] + latRange[1]) * 0.5,
+ centerLon = (lonRange[0] + lonRange[1]) * 0.5,
+ )
+ }
+
+ /**
+ * Reconstruct a GeoHash by re-interleaving separated lat/lon bit arrays.
+ * Used internally to compute cardinal neighbors.
+ */
+ private fun fromBitArrays(
+ latBits: Long,
+ numLat: Int,
+ lonBits: Long,
+ numLon: Int,
+ ): GeoHash {
+ // Left-align both arrays
+ var lb = latBits shl (MAX_BITS - numLat)
+ var lob = lonBits shl (MAX_BITS - numLon)
+ val total = numLat + numLon
+
+ val latRange = doubleArrayOf(-LAT_MAX, LAT_MAX)
+ val lonRange = doubleArrayOf(-LON_MAX, LON_MAX)
+ var newBits = 0L
+ // isEven=false → lon first (even-indexed positions = lon in GeoHash)
+ var isEven = false
+
+ repeat(total) {
+ if (isEven) {
+ val bitSet = lb < 0L
+ lb = lb shl 1
+ val mid = (latRange[0] + latRange[1]) * 0.5
+ if (bitSet) {
+ newBits = (newBits shl 1) or 1L
+ latRange[0] = mid
+ } else {
+ newBits = newBits shl 1
+ latRange[1] = mid
+ }
+ } else {
+ val bitSet = lob < 0L
+ lob = lob shl 1
+ val mid = (lonRange[0] + lonRange[1]) * 0.5
+ if (bitSet) {
+ newBits = (newBits shl 1) or 1L
+ lonRange[0] = mid
+ } else {
+ newBits = newBits shl 1
+ lonRange[1] = mid
+ }
+ }
+ isEven = !isEven
+ }
+
+ return GeoHash(
+ newBits shl (MAX_BITS - total),
+ total,
+ (latRange[0] + latRange[1]) * 0.5,
+ (lonRange[0] + lonRange[1]) * 0.5,
+ )
+ }
+ }
+
+ // Longitude gets the extra bit when significantBits is odd (lon is encoded first)
+ private val numLat = significantBits / 2
+ private val numLon = significantBits / 2 + significantBits % 2
+
+ /**
+ * Extracts every other bit from [source] (positions 0, 2, 4, …) into a right-aligned value.
+ * Used to separate the interleaved lat/lon bit streams.
+ */
+ private fun extractEvery2ndBit(
+ source: Long,
+ count: Int,
+ ): Long {
+ var src = source
+ var result = 0L
+ repeat(count) {
+ result = result shl 1
+ if (src < 0L) result = result or 1L
+ src = src shl 2
+ }
+ return result
+ }
+
+ /** Keep only the lowest [n] bits of [value], wrapping neighbors at poles/antimeridian. */
+ private fun mask(
+ value: Long,
+ n: Int,
+ ): Long = value and (-1L ushr (MAX_BITS - n))
+
+ // Lat bits occupy odd positions (1, 3, 5, …); shift left 1 to align them to even positions.
+ private val latBits get() = extractEvery2ndBit(bits shl 1, numLat)
+
+ // Lon bits already occupy even positions (0, 2, 4, …).
+ private val lonBits get() = extractEvery2ndBit(bits, numLon)
+
+ val northernNeighbour: GeoHash get() = fromBitArrays(mask(latBits + 1L, numLat), numLat, lonBits, numLon)
+ val southernNeighbour: GeoHash get() = fromBitArrays(mask(latBits - 1L, numLat), numLat, lonBits, numLon)
+ val easternNeighbour: GeoHash get() = fromBitArrays(latBits, numLat, mask(lonBits + 1L, numLon), numLon)
+ val westernNeighbour: GeoHash get() = fromBitArrays(latBits, numLat, mask(lonBits - 1L, numLon), numLon)
+
+ fun toLocation(): Location =
+ Location("").also {
+ it.latitude = centerLat
+ it.longitude = centerLon
+ }
+
+ override fun toString(): String {
+ val charCount = significantBits / BITS_PER_CHAR
+ val sb = StringBuilder(charCount)
+ var b = bits
+ repeat(charCount) {
+ sb.append(BASE32[(b ushr 59).toInt() and 0x1f])
+ b = b shl BITS_PER_CHAR
+ }
+ return sb.toString()
+ }
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is GeoHash) return false
+ return bits == other.bits && significantBits == other.significantBits
+ }
+
+ override fun hashCode(): Int = 31 * bits.hashCode() + significantBits
+}
+
+fun Location.toGeoHash(charsCount: Int = GeoHash.MAX_CHAR_PRECISION): GeoHash = GeoHash.encode(latitude, longitude, charsCount)
+
+fun String.toGeoHash(): GeoHash? = GeoHash.decode(this)
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationState.kt
index d3a8d15d8..46b719ecc 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationState.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationState.kt
@@ -21,8 +21,6 @@
package com.vitorpamplona.amethyst.service.location
import android.content.Context
-import com.fonfon.kgeohash.GeoHash
-import com.fonfon.kgeohash.toGeoHash
import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeohashPrecision
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt
index 970f5e80d..567b4ac44 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt
@@ -60,6 +60,7 @@ import kotlin.coroutines.cancellation.CancellationException
private const val TAG = "EventNotificationConsumer"
private const val ACCOUNT_QUERY_PARAM = "?account="
+private const val SCROLL_TO_QUERY_PARAM = "&scrollTo="
class EventNotificationConsumer(
private val applicationContext: Context,
@@ -455,7 +456,8 @@ class EventNotificationConsumer(
"notifications$ACCOUNT_QUERY_PARAM" +
account.signer.pubKey
.hexToByteArray()
- .toNpub()
+ .toNpub() +
+ SCROLL_TO_QUERY_PARAM + event.id
Log.d(TAG) { "Notify ${event.id} $content $title $noteUri" }
@@ -490,7 +492,8 @@ class EventNotificationConsumer(
"notifications$ACCOUNT_QUERY_PARAM" +
account.signer.pubKey
.hexToByteArray()
- .toNpub()
+ .toNpub() +
+ SCROLL_TO_QUERY_PARAM + event.id
Log.d(TAG) { "Notify ${event.id} $title $noteUri" }
@@ -568,7 +571,8 @@ class EventNotificationConsumer(
"notifications$ACCOUNT_QUERY_PARAM" +
account.signer.pubKey
.hexToByteArray()
- .toNpub()
+ .toNpub() +
+ SCROLL_TO_QUERY_PARAM + event.id
notificationManager()
.sendReactionNotification(
@@ -600,7 +604,8 @@ class EventNotificationConsumer(
"notifications$ACCOUNT_QUERY_PARAM" +
account.signer.pubKey
.hexToByteArray()
- .toNpub()
+ .toNpub() +
+ SCROLL_TO_QUERY_PARAM + event.id
notificationManager()
.sendChessNotification(
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/RelayProxyClientConnector.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/RelayProxyClientConnector.kt
index 220987e65..911ddfa9f 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/RelayProxyClientConnector.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/RelayProxyClientConnector.kt
@@ -40,7 +40,6 @@ import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
-import net.freehaven.tor.control.TorControlCommands
import okhttp3.OkHttpClient
class RelayProxyClientConnector(
@@ -79,24 +78,13 @@ class RelayProxyClientConnector(
client.disconnect()
}
if (it.torStatus is TorServiceStatus.Active) {
- try {
- it.torStatus.torControlConnection?.signal(TorControlCommands.SIGNAL_DORMANT)
- Log.d("ManageRelayServices", "Pausing Tor Activity")
- } catch (e: Exception) {
- Log.e("ManageRelayServices") { "Failed to signal Tor dormant: ${e.message}" }
- }
+ Log.d("ManageRelayServices", "Connectivity off, Tor idle")
}
} else if (it.connectivity is ConnectivityStatus.Active && !client.isActive()) {
Log.d("ManageRelayServices", "Connectivity On: Resuming Relay Services")
if (it.torStatus is TorServiceStatus.Active) {
- try {
- it.torStatus.torControlConnection?.signal(TorControlCommands.SIGNAL_ACTIVE)
- it.torStatus.torControlConnection?.signal(TorControlCommands.SIGNAL_NEWNYM)
- Log.d("ManageRelayServices", "Resuming Tor Activity with new nym")
- } catch (e: Exception) {
- Log.e("ManageRelayServices") { "Failed to signal Tor active: ${e.message}" }
- }
+ Log.d("ManageRelayServices", "Connectivity resumed, Tor active")
}
// only calls this if the client is not active. Otherwise goes to the else below
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt
index ebf820d76..9b4f19c9d 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt
@@ -33,6 +33,7 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.lang.LanguageTranslatorService
import com.vitorpamplona.amethyst.service.playback.composable.DEFAULT_MUTED_SETTING
import com.vitorpamplona.amethyst.service.playback.pip.BackgroundMedia
+import com.vitorpamplona.amethyst.ui.navigation.findParameterValue
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.screen.AccountScreen
@@ -129,7 +130,8 @@ fun uriToRoute(
account: Account,
): Route? {
if (isNotificationRoute(uri)) {
- return Route.Notification
+ val scrollTo = runCatching { java.net.URI(uri.removePrefix("nostr:")).findParameterValue("scrollTo") }.getOrNull()
+ return Route.Notification(scrollToEventId = scrollTo)
}
if (isHashtagRoute(uri)) {
return Route.Hashtag(uri.removePrefix("nostr:").removePrefix("hashtag?id=").lowercase())
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagOutputTransformation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagOutputTransformation.kt
index 3b6149d98..9d5dff684 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagOutputTransformation.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagOutputTransformation.kt
@@ -41,6 +41,11 @@ class UrlUserTagOutputTransformation(
val mentionRegex = Regex("(?:@|nostr:)(?:npub1[a-z0-9]{58}|nprofile1[a-z0-9]+)")
val matches = mentionRegex.findAll(text).toList().reversed()
+ // Phase 1: Replace all mentions (reverse order keeps indices valid for replace).
+ // Collect replacement info because addStyle must be called after all text mutations.
+ // (originalStart, originalMatchLength, displayNameLength)
+ val replacements = mutableListOf>()
+
for (match in matches) {
try {
val bech32 =
@@ -52,18 +57,22 @@ class UrlUserTagOutputTransformation(
val displayName = "@${user.toBestDisplayName()}"
replace(match.range.first, match.range.last + 1, displayName)
-
- // Apply color styling to the replaced display name
- addStyle(
- SpanStyle(color = color, textDecoration = TextDecoration.None),
- match.range.first,
- match.range.first + displayName.length,
- )
+ replacements.add(Triple(match.range.first, match.range.last + 1 - match.range.first, displayName.length))
} catch (e: Exception) {
if (e is CancellationException) throw e
}
}
+ // Phase 2: Apply styles after all text mutations are finalized.
+ // Iterate in forward document order, tracking cumulative shift from prior replacements.
+ val style = SpanStyle(color = color, textDecoration = TextDecoration.None)
+ var cumulativeShift = 0
+ for ((originalStart, originalLen, newLen) in replacements.reversed()) {
+ val adjustedStart = originalStart + cumulativeShift
+ addStyle(style, adjustedStart, adjustedStart + newLen)
+ cumulativeShift += newLen - originalLen
+ }
+
// Highlight URLs in remaining text
highlightUrls(color)
}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt
index 5d888706d..a7827efc0 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt
@@ -184,7 +184,7 @@ fun BuildNavigation(
composable { MessagesScreen(accountViewModel, nav) }
composable { VideoScreen(accountViewModel, nav) }
composable { DiscoverScreen(accountViewModel, nav) }
- composable { NotificationScreen(accountViewModel, nav) }
+ composableArgs { NotificationScreen(it.scrollToEventId, accountViewModel, nav) }
composableFromEnd { PollsScreen(accountViewModel, nav) }
composableFromEnd { PicturesScreen(accountViewModel, nav) }
composableFromEnd { ShortsScreen(accountViewModel, nav) }
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/BottomBarRoutes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/BottomBarRoutes.kt
index 037e7b57e..134400932 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/BottomBarRoutes.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/BottomBarRoutes.kt
@@ -44,5 +44,5 @@ val bottomNavigationItems =
BottomBarRoute(Route.Message, R.drawable.ic_dm, R.string.route_messages),
BottomBarRoute(Route.Video, R.drawable.ic_video, R.string.route_video),
BottomBarRoute(Route.Discover, R.drawable.ic_sensors, R.string.route_discover),
- BottomBarRoute(Route.Notification, R.drawable.ic_notifications, R.string.route_notifications),
+ BottomBarRoute(Route.Notification(), R.drawable.ic_notifications, R.string.route_notifications),
)
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt
index 8d0bdb879..8f69aaaf5 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt
@@ -39,7 +39,9 @@ sealed class Route {
@Serializable object Discover : Route()
- @Serializable object Notification : Route()
+ @Serializable data class Notification(
+ val scrollToEventId: String? = null,
+ ) : Route()
@Serializable object Polls : Route()
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LoadCityName.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LoadCityName.kt
index a07c33dd0..4420368b0 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LoadCityName.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LoadCityName.kt
@@ -27,8 +27,8 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
-import com.fonfon.kgeohash.toGeoHash
import com.vitorpamplona.amethyst.service.location.CachedReversedGeoLocations
+import com.vitorpamplona.amethyst.service.location.toGeoHash
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt
index 7b7de3288..7a740b2af 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt
@@ -404,7 +404,7 @@ class AccountViewModel(
mapOf(
Route.Home to homeHasNewItemsFlow,
Route.Message to messagesHasNewItemsFlow,
- Route.Notification to notificationHasNewItemsFlow,
+ Route.Notification() to notificationHasNewItemsFlow,
)
fun isWriteable(): Boolean = account.isWriteable()
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedState.kt
index 6d923eaf4..a4fab1dab 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedState.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedState.kt
@@ -104,3 +104,37 @@ class MessageSetCard(
override fun id() = note.idHex
}
+
+/**
+ * Checks if this card contains a specific event ID.
+ * Used for scrolling to a notification from a push notification intent.
+ */
+fun Card.containsEventId(eventId: String): Boolean =
+ when (this) {
+ is NoteCard -> {
+ note.idHex == eventId
+ }
+
+ is BadgeCard -> {
+ note.idHex == eventId
+ }
+
+ is MessageSetCard -> {
+ note.idHex == eventId
+ }
+
+ is ZapUserSetCard -> {
+ zapEvents.any { it.response.idHex == eventId || it.request.idHex == eventId }
+ }
+
+ is MultiSetCard -> {
+ note.idHex == eventId ||
+ zapEvents.any { it.response.idHex == eventId || it.request.idHex == eventId } ||
+ likeEvents.any { it.idHex == eventId } ||
+ boostEvents.any { it.idHex == eventId }
+ }
+
+ else -> {
+ false
+ }
+ }
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt
index 316e97775..ff434728f 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt
@@ -20,8 +20,10 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications
+import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -38,8 +40,12 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -66,6 +72,7 @@ import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.imageModifier
+import kotlinx.coroutines.delay
@Composable
fun RenderCardFeed(
@@ -75,6 +82,7 @@ fun RenderCardFeed(
listState: LazyListState,
nav: INav,
routeForLastRead: String,
+ scrollToEventId: String? = null,
) {
val feedState by feedContent.feedContent.collectAsStateWithLifecycle()
@@ -101,6 +109,7 @@ fun RenderCardFeed(
routeForLastRead = routeForLastRead,
accountViewModel = accountViewModel,
nav = nav,
+ scrollToEventId = scrollToEventId,
)
}
@@ -133,10 +142,29 @@ private fun FeedLoaded(
routeForLastRead: String,
accountViewModel: AccountViewModel,
nav: INav,
+ scrollToEventId: String? = null,
) {
val items by loaded.feed.collectAsStateWithLifecycle()
val openPolls by polls.flow.collectAsStateWithLifecycle()
+ // Track which card is highlighted (will auto-clear after animation)
+ var highlightedCardId by remember { mutableStateOf(null) }
+
+ // Scroll to the card containing the target event ID
+ if (scrollToEventId != null) {
+ LaunchedEffect(scrollToEventId, items) {
+ val position = items.list.indexOfFirst { it.containsEventId(scrollToEventId) }
+ if (position >= 0) {
+ // +1 offset for the donation card header item
+ val scrollIndex = position + 1 + openPolls.size
+ listState.animateScrollToItem(scrollIndex)
+ highlightedCardId = items.list[position].id()
+ delay(2000)
+ highlightedCardId = null
+ }
+ }
+ }
+
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = FeedPadding,
@@ -182,7 +210,14 @@ private fun FeedLoaded(
key = { _, item -> item.id() },
contentType = { _, item -> item.javaClass.simpleName },
) { _, item ->
- Row(Modifier.fillMaxWidth().animateItem()) {
+ val isHighlighted = highlightedCardId == item.id()
+ val highlightColor by animateColorAsState(
+ targetValue = if (isHighlighted) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) else Color.Transparent,
+ animationSpec = tween(durationMillis = if (isHighlighted) 300 else 1000),
+ label = "highlightAnimation",
+ )
+
+ Row(Modifier.fillMaxWidth().background(highlightColor).animateItem()) {
logTime(
debugMessage = { "CardFeedView $item" },
) {
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationScreen.kt
index 12238097b..41d1f4183 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationScreen.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationScreen.kt
@@ -42,6 +42,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun NotificationScreen(
+ scrollToEventId: String? = null,
accountViewModel: AccountViewModel,
nav: INav,
) {
@@ -50,6 +51,7 @@ fun NotificationScreen(
notifSummaryState = accountViewModel.feedStates.notificationSummary,
notifPolls = accountViewModel.feedStates.notificationsOpenPolls,
sharedPrefs = accountViewModel.settings.uiSettingsFlow,
+ scrollToEventId = scrollToEventId,
accountViewModel = accountViewModel,
nav = nav,
)
@@ -61,6 +63,7 @@ fun NotificationScreen(
notifSummaryState: NotificationSummaryState,
notifPolls: OpenPollsState,
sharedPrefs: UiSettingsFlow,
+ scrollToEventId: String? = null,
accountViewModel: AccountViewModel,
nav: INav,
) {
@@ -79,8 +82,8 @@ fun NotificationScreen(
}
},
bottomBar = {
- AppBottomBar(Route.Notification, accountViewModel) { route ->
- if (route == Route.Notification) {
+ AppBottomBar(Route.Notification(), accountViewModel) { route ->
+ if (route is Route.Notification) {
notifFeedContentState.invalidateDataAndSendToTop(true)
} else {
nav.newStack(route)
@@ -98,7 +101,7 @@ fun NotificationScreen(
WatchScrollToTop(notifFeedContentState, listState)
- RenderCardFeed(notifFeedContentState, notifPolls, accountViewModel, listState, nav, "Notification")
+ RenderCardFeed(notifFeedContentState, notifPolls, accountViewModel, listState, nav, "Notification", scrollToEventId)
}
}
}
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 e156287cd..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
@@ -58,18 +58,21 @@ class TorManager(
}.transformLatest { (torType, externalSocksPort) ->
when (torType) {
TorType.INTERNAL -> {
+ service.start()
emitAll(service.status)
}
TorType.OFF -> {
+ service.stop()
emit(TorServiceStatus.Off)
}
TorType.EXTERNAL -> {
+ service.stop()
if (externalSocksPort > 0) {
emit(TorServiceStatus.Active(externalSocksPort))
} else {
- emitAll(service.status)
+ emit(TorServiceStatus.Off)
}
}
}
@@ -95,5 +98,5 @@ class TorManager(
fun isSocksReady() = status.value is TorServiceStatus.Active
- fun socksPort(): Int = (status.value as? TorServiceStatus.Active)?.port ?: 9050
+ fun socksPort(): Int = (status.value as? TorServiceStatus.Active)?.port ?: 19050
}
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 013339885..25d31fea1 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
@@ -20,90 +20,111 @@
*/
package com.vitorpamplona.amethyst.ui.tor
-import android.content.ComponentName
import android.content.Context
-import android.content.Context.BIND_AUTO_CREATE
-import android.content.Intent
-import android.content.ServiceConnection
-import android.os.IBinder
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.channels.awaitClose
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.flow.callbackFlow
-import kotlinx.coroutines.flow.flowOn
-import kotlinx.coroutines.launch
-import org.torproject.jni.TorService
-import org.torproject.jni.TorService.LocalBinder
+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 SOCKS_PORT_POLL_INTERVAL_MS = 100L
+private const val DEFAULT_SOCKS_PORT = 19050
+/**
+ * Manages the Arti Tor client via custom JNI bindings.
+ *
+ * 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.
+ *
+ * All JNI calls (including System.loadLibrary) run on [Dispatchers.IO]
+ * to avoid blocking the main thread.
+ */
class TorService(
val context: Context,
) {
- val status =
- callbackFlow {
- Log.d("TorService", "Binding Tor Service")
- trySend(TorServiceStatus.Connecting)
+ private val socksPort = DEFAULT_SOCKS_PORT
+ private val initialized = AtomicBoolean(false)
+ private val proxyRunning = AtomicBoolean(false)
- val currentIntent = Intent(context, TorService::class.java)
- val serviceConnection: ServiceConnection =
- object : ServiceConnection {
- override fun onServiceConnected(
- name: ComponentName,
- service: IBinder,
- ) {
- launch(Dispatchers.IO) {
- try {
- // moved torService to a local variable, since we only need it once
- val torService = (service as LocalBinder).service
+ private val _status = MutableStateFlow(TorServiceStatus.Off)
+ val status: StateFlow = _status.asStateFlow()
- while (torService.socksPort < 0) {
- delay(SOCKS_PORT_POLL_INTERVAL_MS)
- }
+ /**
+ * Initialize the TorClient (once) and start the SOCKS proxy.
+ * Must be called from a coroutine on [Dispatchers.IO].
+ */
+ suspend fun start() {
+ if (proxyRunning.get()) {
+ if (_status.value is TorServiceStatus.Active) return
+ _status.value = TorServiceStatus.Connecting
+ return
+ }
- val active = TorServiceStatus.Active(torService.socksPort)
- active.torControlConnection = torService.torControlConnection
+ _status.value = TorServiceStatus.Connecting
- trySend(active)
- Log.d("TorService") { "Tor Service Connected ${torService.socksPort}" }
- } catch (e: Exception) {
- Log.e("TorService") { "Tor service connection failed: ${e.message}" }
- trySend(TorServiceStatus.Off)
- }
+ withContext(Dispatchers.IO) {
+ // Initialize TorClient once — this bootstraps the Tor network.
+ // setLogCallback and initialize are the first ArtiNative calls,
+ // which triggers System.loadLibrary on this IO thread.
+ if (initialized.compareAndSet(false, true)) {
+ ArtiNative.setLogCallback { text ->
+ Log.d("TorService") {
+ val newLine = text.indexOf('\n')
+ if (newLine > 1) {
+ "Arti: ${text.substring(0, newLine)}"
+ } else {
+ "Arti: $text"
}
}
- override fun onServiceDisconnected(name: ComponentName) {
- Log.d("TorService", "Tor Service Disconnected")
- trySend(TorServiceStatus.Off)
+ when {
+ text.contains("Sufficiently bootstrapped", ignoreCase = true) -> {
+ _status.value = TorServiceStatus.Active(socksPort)
+ Log.d("TorService") { "Arti SOCKS proxy active on port $socksPort" }
+ }
}
}
- try {
- context.bindService(
- currentIntent,
- serviceConnection,
- BIND_AUTO_CREATE,
- )
- } catch (e: Exception) {
- Log.e("TorService") { "Failed to bind Tor Service: ${e.message}" }
- trySend(TorServiceStatus.Off)
+ 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
+ }
}
- awaitClose {
- Log.d("TorService", "Stopping Tor Service")
- try {
- context.unbindService(serviceConnection)
- } catch (e: Exception) {
- Log.d("TorService") { "Failed to unbind Tor Service: ${e.message}" }
- }
- try {
- context.stopService(currentIntent)
- } catch (e: Exception) {
- Log.d("TorService") { "Failed to stop Tor Service: ${e.message}" }
- }
- trySend(TorServiceStatus.Off)
+ // 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
}
- }.flowOn(Dispatchers.IO)
+
+ 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/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorServiceStatus.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorServiceStatus.kt
index 2dd7f31f1..6eec45809 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorServiceStatus.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorServiceStatus.kt
@@ -20,15 +20,10 @@
*/
package com.vitorpamplona.amethyst.ui.tor
-import net.freehaven.tor.control.TorControlConnection
-
sealed class TorServiceStatus {
data class Active(
val port: Int,
- ) : TorServiceStatus() {
- // If internal, it has control.
- var torControlConnection: TorControlConnection? = null
- }
+ ) : TorServiceStatus()
object Off : TorServiceStatus()
diff --git a/amethyst/src/main/jniLibs/arm64-v8a/libarti_android.so b/amethyst/src/main/jniLibs/arm64-v8a/libarti_android.so
new file mode 100755
index 000000000..8fc5ef333
Binary files /dev/null and b/amethyst/src/main/jniLibs/arm64-v8a/libarti_android.so differ
diff --git a/amethyst/src/main/jniLibs/x86_64/libarti_android.so b/amethyst/src/main/jniLibs/x86_64/libarti_android.so
new file mode 100755
index 000000000..3ee9f24e9
Binary files /dev/null and b/amethyst/src/main/jniLibs/x86_64/libarti_android.so differ
diff --git a/amethyst/src/main/res/values-hu-rHU/strings.xml b/amethyst/src/main/res/values-hu-rHU/strings.xml
index 6589c546a..b257c6c33 100644
--- a/amethyst/src/main/res/values-hu-rHU/strings.xml
+++ b/amethyst/src/main/res/values-hu-rHU/strings.xml
@@ -380,8 +380,15 @@
Könyvjelző
Alapértelmezett könyvjelzők
Az alapértelmezett könyvjelzők, amelyeket sok kliens támogat
+ Régi könyvjelzők
+ Saját régi könyvjelzők
+ Összes átköltöztetése az új könyvjelzőkbe
+ A könyvjelzők átköltöztetése sikeresen befejeződött
Piszkozatok
Szavazások
+ Képek
+ Rövidek
+ Videók
Privát könyvjelzők
Nyilvános könyvjelzők
Hozzáadás a privát könyvjelzőkhöz
@@ -655,6 +662,16 @@
Megjelölés olvasottként
Új üzenetek
Új zap-ek
+ Reakciók
+ Értesítés, amikor valaki reagál az egyik bejegyzésre
+ %1$s reagált az Ön bejegyzésére
+ neki: %1$s
+ Új reakciók
+ Sakk
+ Értesítés a sakkjátékkal kapcsolatos eseményekre
+ %1$s elfogadta az Ön sakk-kihívását
+ %1$s lépett - Ön következik
+ Sakkfrissítések
Értesítés:
Csatlakozás a beszélgetéshez
Felhasználó- vagy csoport-azonosító
@@ -1095,6 +1112,7 @@
Értesítések
Globális
Rövidek
+ Képek
Sakk
Pénztárca
Egyenleg
@@ -1475,12 +1493,14 @@
Hangsáv
Jelvénydíjak
Jelvények meghatározása
+ Elfogadott kitűzőlista
Profiljelvények
Letiltott átjátszók
Blossom kiszolgálók
Blossom-hitelesítés
Közvetítési átjátszók
Könyvjelzőlista
+ Régi könyvjelzőlista
Napi időpont
Naptár
Találkozó
diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml
index 16f317faa..6479b319b 100644
--- a/amethyst/src/main/res/values-pl-rPL/strings.xml
+++ b/amethyst/src/main/res/values-pl-rPL/strings.xml
@@ -1490,6 +1490,7 @@
Ścieżka audio
Odznaki
Definicje odznak
+ Zestaw odznak „Przyjęty”
Odznaki profilowe
Zablokowane Transmitery
Serwery Blossom
@@ -1693,6 +1694,7 @@
wydarzeń
wydarzenia od Ciebie
wydarzenia dla Ciebie
+ wydarzenia podlegające wyszukiwaniu
DMs
profile
lista transmiterów wysyłających
diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml
index 389d7b3bd..627c6e821 100644
--- a/amethyst/src/main/res/values-zh-rCN/strings.xml
+++ b/amethyst/src/main/res/values-zh-rCN/strings.xml
@@ -380,8 +380,15 @@
书签
默认书签
许多客户端支持默认书签
+ 旧书签
+ 您的旧书签
+ 移动全部到新书签
+ 书签迁移成功
草稿
投票
+ 图片
+ 短视频
+ 视频
私人书签
公开书签
添加到私人书签
@@ -655,6 +662,11 @@
标记为已读
新信息
新打闪
+ 回应
+ 当有人回应您的帖子做出时通知您
+ %1$s 回应了您的帖子
+ 针对 %1$s
+ 新回应
国际象棋
提醒您有关国际象棋游戏的事件
%1$s 接受了您的国际象棋挑战
@@ -1100,6 +1112,7 @@
通知
全球
短篇
+ 图片
国际象棋
钱包
余额
@@ -1480,12 +1493,14 @@
音轨
徽章奖励
徽章定义
+ 接受的徽章集
个人资料徽章
中继黑名单
Blossom 服务器
Blossom 认证
广播中继
书签列表
+ 旧书签列表
日预约
日历
预约
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index f1be67e66..60485652c 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -7,7 +7,6 @@ agp = "9.1.0"
android-compileSdk = "36"
android-minSdk = "26"
android-targetSdk = "36"
-androidKotlinGeohash = "b481c6a64e"
androidxJunit = "1.3.0"
appcompat = "1.7.1"
audiowaveform = "1.1.2"
@@ -25,7 +24,6 @@ fragmentKtx = "1.8.9"
gms = "4.4.4"
jacksonModuleKotlin = "2.21.2"
javaKeyring = "1.0.4"
-jtorctl = "0.4.5.7"
junit = "4.13.2"
kchesslib = "1.0.5"
kotlin = "2.3.20"
@@ -52,7 +50,6 @@ slf4j = "2.0.17"
spotless = "8.4.0"
streamWebrtcAndroid = "1.3.8"
tarsosdsp = "2.5"
-torAndroid = "0.4.9.5.1"
translate = "17.0.3"
jetbrainsCompose = "1.10.3"
unifiedpush = "3.0.10"
@@ -127,7 +124,6 @@ commons-imaging = { group = "org.apache.commons", name = "commons-imaging", vers
slf4j-nop = { module = "org.slf4j:slf4j-nop", version.ref = "slf4j" }
vlcj = { group = "uk.co.caprica", name = "vlcj", version.ref = "vlcj" }
dev-whyoleg-cryptography-provider-apple-optimal = { module = "dev.whyoleg.cryptography:cryptography-provider-optimal", version.ref = "devWhyolegCryptography" }
-drfonfon-geohash = { group = "com.github.drfonfon", name = "android-kotlin-geohash", version.ref = "androidKotlinGeohash" }
firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" }
firebase-messaging = { group = "com.google.firebase", name = "firebase-messaging" }
jetbrains-compose-components-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "jetbrainsCompose" }
@@ -142,7 +138,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" }
-jtorctl = { module = "info.guardianproject:jtorctl", version.ref = "jtorctl" }
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" }
@@ -165,7 +160,6 @@ secp256k1-kmp-jni-android = { group = "fr.acinq.secp256k1", name = "secp256k1-km
secp256k1-kmp-jni-jvm = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-jvm", version.ref = "secp256k1KmpJniAndroid" }
stream-webrtc-android = { group = "io.getstream", name = "stream-webrtc-android", version.ref = "streamWebrtcAndroid" }
tarsosdsp = { group = "be.tarsos.dsp", name = "core", version.ref = "tarsosdsp" }
-tor-android = { module = "info.guardianproject:tor-android", version.ref = "torAndroid" }
unifiedpush = { group = "com.github.UnifiedPush", name = "android-connector", version.ref = "unifiedpush" }
vico-charts-compose = { group = "com.patrykandpatrick.vico", name = "compose", version.ref = "vico-charts-compose" }
vico-charts-m3 = { group = "com.patrykandpatrick.vico", name = "compose-m3", version.ref = "vico-charts-compose" }
diff --git a/quartz/build.gradle.kts b/quartz/build.gradle.kts
index 4bd747d10..7d1961933 100644
--- a/quartz/build.gradle.kts
+++ b/quartz/build.gradle.kts
@@ -315,7 +315,7 @@ mavenPublishing {
coordinates(
groupId = "com.vitorpamplona.quartz",
artifactId = "quartz",
- version = "1.07.5",
+ version = "1.08.0",
)
// Configure publishing to Maven Central
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..c0095959b
--- /dev/null
+++ b/tools/arti-build/ARTI_VERSION
@@ -0,0 +1 @@
+arti-v2.2.0
diff --git a/tools/arti-build/Cargo.toml b/tools/arti-build/Cargo.toml
new file mode 100644
index 000000000..dccb35ba1
--- /dev/null
+++ b/tools/arti-build/Cargo.toml
@@ -0,0 +1,29 @@
+[package]
+name = "arti-android"
+version = "2.2.0"
+edition = "2021"
+
+[lib]
+crate-type = ["cdylib"]
+
+[workspace]
+
+[dependencies]
+arti-client = { version = "0.41", default-features = false, features = [
+ "tokio",
+ "rustls",
+ "compression",
+ "onion-service-client",
+ "static-sqlite",
+] }
+tor-rtcompat = { version = "0.41", default-features = false, features = ["tokio", "rustls"] }
+jni = "0.21"
+tokio = { version = "1", features = ["rt-multi-thread", "net", "io-util", "time", "macros"] }
+anyhow = "1"
+
+[profile.release]
+opt-level = "z"
+lto = true
+codegen-units = 1
+strip = true
+panic = "abort"
diff --git a/tools/arti-build/README.md b/tools/arti-build/README.md
new file mode 100644
index 000000000..4fce5231a
--- /dev/null
+++ b/tools/arti-build/README.md
@@ -0,0 +1,209 @@
+# Arti Android Build Tools
+
+Custom-built [Arti](https://gitlab.torproject.org/tpo/core/arti) (Tor in Rust) native libraries
+for Amethyst Android. This replaces the Guardian Project's `arti-mobile-ex` AAR with a minimal
+JNI wrapper built directly from Arti source.
+
+## Why custom build?
+
+| | Guardian Project AAR | Custom build |
+|---|---|---|
+| **Size** | ~140MB | ~11MB |
+| **16KB pages** | No | Yes (NDK 25+) |
+| **Stop/restart** | Broken (state file lock) | Works (TorClient persists, only SOCKS proxy stops) |
+| **Version** | Behind | Pinned to latest (currently 1.9.0) |
+
+## Quick start
+
+Pre-built `.so` files should be committed to `amethyst/src/main/jniLibs/`. You only need to
+rebuild if you want to verify binaries, update the Arti version, or modify the JNI wrapper.
+
+## Prerequisites
+
+1. **Rust toolchain**
+ ```bash
+ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
+ ```
+
+2. **Android targets**
+ ```bash
+ rustup target add aarch64-linux-android x86_64-linux-android
+ ```
+
+3. **cargo-ndk**
+ ```bash
+ cargo install cargo-ndk
+ ```
+
+4. **Android NDK 25+** (required for 16KB page size support)
+ ```bash
+ # Via Android Studio: SDK Manager → SDK Tools → NDK (Side by side)
+ # Or via command line:
+ sdkmanager "ndk;27.0.12077973"
+
+ # Set environment variable
+ export ANDROID_NDK_HOME="$HOME/Android/Sdk/ndk/27.0.12077973"
+ ```
+
+## Building
+
+```bash
+cd tools/arti-build
+
+# Build for all targets (arm64 + x86_64)
+./build-arti.sh
+
+# Build arm64 only (for release APKs)
+./build-arti.sh --release
+
+# Clean rebuild from scratch
+./build-arti.sh --clean
+```
+
+The script will:
+1. Clone official Arti source from `gitlab.torproject.org`
+2. Check out the version pinned in `ARTI_VERSION`
+3. Copy the JNI wrapper into the source tree
+4. Compile with `cargo-ndk` for each target architecture
+5. Output `.so` files to `amethyst/src/main/jniLibs/{arm64-v8a,x86_64}/`
+6. Verify JNI symbols are exported correctly
+
+## Output
+
+```
+amethyst/src/main/jniLibs/
+├── arm64-v8a/
+│ └── libarti_android.so (~5-6 MB)
+└── x86_64/
+ └── libarti_android.so (~6-7 MB, emulator support)
+```
+
+## Verifying 16KB page alignment
+
+Google Play requires 16KB page-aligned native libraries. Verify with:
+
+```bash
+readelf -l amethyst/src/main/jniLibs/arm64-v8a/libarti_android.so | grep LOAD
+```
+
+The first LOAD segment alignment should be `0x4000` (16384 bytes).
+
+## Directory structure
+
+```
+tools/arti-build/
+├── README.md # This file
+├── ARTI_VERSION # Pinned Arti git tag (e.g., arti-v1.9.0)
+├── Cargo.toml # Rust dependencies and build profile
+├── build-arti.sh # Build script
+├── src/
+│ └── lib.rs # JNI bridge (Rust → Kotlin)
+└── .arti-source/ # [gitignored] Cloned Arti repository
+```
+
+## Updating Arti version
+
+1. Check available versions:
+ ```bash
+ git ls-remote --tags https://gitlab.torproject.org/tpo/core/arti.git | grep 'arti-v' | tail -10
+ ```
+
+2. Update the version file:
+ ```bash
+ echo "arti-v1.10.0" > ARTI_VERSION
+ ```
+
+3. Update crate versions in `Cargo.toml` to match the new release.
+ Check the crate versions at:
+ ```
+ https://gitlab.torproject.org/tpo/core/arti/-/raw/arti-v1.10.0/crates/arti-client/Cargo.toml
+ ```
+
+4. Rebuild and test:
+ ```bash
+ ./build-arti.sh --clean
+ ```
+
+## Architecture: JNI bridge
+
+The Rust wrapper (`src/lib.rs`) exposes these JNI functions to Kotlin:
+
+| JNI function | Kotlin | Purpose |
+|---|---|---|
+| `initialize(dataDir)` | `ArtiNative.initialize()` | Create TorClient, bootstrap Tor network |
+| `startSocksProxy(port)` | `ArtiNative.startSocksProxy()` | Bind SOCKS5 listener on localhost |
+| `stopSocksProxy()` | `ArtiNative.stopSocksProxy()` | Abort listener, release port |
+| `getVersion()` | `ArtiNative.getVersion()` | Return Arti version string |
+| `setLogCallback(cb)` | `ArtiNative.setLogCallback()` | Register log callback |
+
+### Key design decisions
+
+- **TorClient is created once** via `initialize()` and persists for the app's lifetime.
+ Its state file lock is tied to the object's lifetime and released only on GC/process exit.
+- **`stopSocksProxy()` only stops the TCP listener** — it does NOT destroy the TorClient.
+ This allows clean stop/start cycles without state file lock conflicts.
+- **SOCKS5 is implemented in Rust** using `tokio::net::TcpListener`, not delegated to Arti's
+ built-in proxy. This gives us full control over the listener lifecycle.
+- **Bidirectional forwarding** uses `tokio::io::copy` with `tokio::select!` for efficiency.
+
+## Cargo.toml features
+
+Default features are disabled (`default-features = false`) to minimize binary size.
+
+| Feature | Purpose | Why included |
+|---|---|---|
+| `tokio` | Async runtime | Required by our SOCKS proxy |
+| `rustls` | TLS via pure Rust | No OpenSSL dependency, smaller binary |
+| `compression` | zstd/deflate relay traffic | Reduces bandwidth on Tor circuits |
+| `onion-service-client` | Access .onion addresses | Amethyst routes .onion relay connections through Tor |
+| `static-sqlite` | Bundled SQLite | Android native code can't use system SQLite |
+
+**Not included:**
+
+| Feature | Why excluded |
+|---|---|
+| `native-tls` | Using `rustls` instead (smaller, no system dependency) |
+| `bridge-client` | Amethyst doesn't expose bridge configuration in UI yet. Add back if needed. |
+| `pt-client` | Pluggable transports — same reason as bridges |
+| `onion-service-service` | We only connect to .onion, we don't host them |
+
+### Release profile
+
+```toml
+[profile.release]
+opt-level = "z" # Optimize for size
+lto = true # Link-time optimization
+codegen-units = 1 # Single codegen unit (smaller binary)
+strip = true # Strip debug symbols
+panic = "abort" # No unwinding (smaller binary)
+```
+
+## Troubleshooting
+
+### `cargo-ndk` not found
+```bash
+cargo install cargo-ndk
+```
+
+### NDK not found
+```bash
+export ANDROID_NDK_HOME="$HOME/Android/Sdk/ndk/"
+```
+
+### Rust targets not installed
+```bash
+rustup target add aarch64-linux-android x86_64-linux-android
+```
+
+### Build fails with dependency errors
+Try a clean build:
+```bash
+./build-arti.sh --clean
+```
+
+### JNI symbols missing after build
+The build script verifies symbols automatically. If verification fails, check that
+`src/lib.rs` function names match the Kotlin package path:
+```
+Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_
+```
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..20a7a43d1
--- /dev/null
+++ b/tools/arti-build/src/lib.rs
@@ -0,0 +1,386 @@
+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