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>>> = Mutex::new(None); +static TOKIO_RUNTIME: Mutex> = Mutex::new(None); +static JAVA_VM: Mutex> = Mutex::new(None); +static LOG_CALLBACK: Mutex> = Mutex::new(None); +static SOCKS_TASK: Mutex>> = Mutex::new(None); +static INIT_ONCE: Once = Once::new(); + +// ============================================================================ +// Logging +// ============================================================================ + +fn send_log_to_java(message: String) { + let vm_opt = JAVA_VM.lock().unwrap(); + let callback_opt = LOG_CALLBACK.lock().unwrap(); + + if let (Some(vm), Some(callback)) = (vm_opt.as_ref(), callback_opt.as_ref()) { + if let Ok(mut env) = vm.attach_current_thread() { + if let Ok(jmessage) = env.new_string(&message) { + let _ = env.call_method( + callback.as_obj(), + "onLogLine", + "(Ljava/lang/String;)V", + &[(&jmessage).into()] + ); + } + } + } +} + +macro_rules! log_info { + ($($arg:tt)*) => {{ + let msg = format!($($arg)*); + send_log_to_java(msg); + }}; +} + +macro_rules! log_error { + ($($arg:tt)*) => {{ + let msg = format!("ERROR: {}", format!($($arg)*)); + send_log_to_java(msg); + }}; +} + +// ============================================================================ +// JNI Functions — package: com.vitorpamplona.amethyst.ui.tor +// ============================================================================ + +#[no_mangle] +pub extern "C" fn Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_getVersion( + env: JNIEnv, + _class: JClass, +) -> jstring { + if JAVA_VM.lock().unwrap().is_none() { + if let Ok(vm) = env.get_java_vm() { + *JAVA_VM.lock().unwrap() = Some(vm); + } + } + + let version = format!("Arti {} (custom build with rustls)", env!("CARGO_PKG_VERSION")); + let output = env.new_string(version).expect("Couldn't create java string!"); + output.into_raw() +} + +#[no_mangle] +pub extern "C" fn Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_setLogCallback( + env: JNIEnv, + _class: JClass, + callback: JObject, +) { + if JAVA_VM.lock().unwrap().is_none() { + if let Ok(vm) = env.get_java_vm() { + *JAVA_VM.lock().unwrap() = Some(vm); + } + } + + if let Ok(global_ref) = env.new_global_ref(callback) { + *LOG_CALLBACK.lock().unwrap() = Some(global_ref); + log_info!("Log callback registered"); + } +} + +/// Initialize Arti runtime and bootstrap the TorClient. +/// The TorClient is created once and reused for the app's lifetime. +#[no_mangle] +pub extern "C" fn Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_initialize( + mut env: JNIEnv, + _class: JClass, + data_dir: JString, +) -> jint { + if JAVA_VM.lock().unwrap().is_none() { + if let Ok(vm) = env.get_java_vm() { + *JAVA_VM.lock().unwrap() = Some(vm); + } + } + + // Already initialized — skip + if ARTI_CLIENT.lock().unwrap().is_some() { + log_info!("Arti already initialized, reusing existing client"); + return 0; + } + + let data_dir_str: String = match env.get_string(&data_dir) { + Ok(s) => s.into(), + Err(e) => { + log_error!("Failed to convert data_dir: {:?}", e); + return -1; + } + }; + + log_info!("Initializing Arti with data directory: {}", data_dir_str); + + INIT_ONCE.call_once(|| { + match tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + { + Ok(rt) => { + log_info!("Tokio runtime created successfully"); + *TOKIO_RUNTIME.lock().unwrap() = Some(rt); + } + Err(e) => { + log_error!("Failed to create Tokio runtime: {:?}", e); + } + } + }); + + let runtime_guard = TOKIO_RUNTIME.lock().unwrap(); + let runtime = match runtime_guard.as_ref() { + Some(rt) => rt, + None => { + log_error!("Tokio runtime not initialized"); + return -2; + } + }; + + let data_path = PathBuf::from(data_dir_str); + let cache_dir = data_path.join("cache"); + let state_dir = data_path.join("state"); + + std::fs::create_dir_all(&cache_dir).ok(); + std::fs::create_dir_all(&state_dir).ok(); + + let result: Result<()> = runtime.block_on(async { + log_info!("Creating Arti client..."); + + let config = TorClientConfigBuilder::from_directories(state_dir, cache_dir) + .build()?; + + let client = TorClient::create_bootstrapped(config).await?; + + log_info!("Arti client created and bootstrapped"); + + *ARTI_CLIENT.lock().unwrap() = Some(Arc::new(client)); + + Ok(()) + }); + + match result { + Ok(_) => { + log_info!("Arti initialized successfully"); + 0 + } + Err(e) => { + log_error!("Failed to initialize Arti: {:?}", e); + -3 + } + } +} + +/// Start the SOCKS5 proxy on the specified port. +/// Can be called multiple times — stops any existing listener first. +#[no_mangle] +pub extern "C" fn Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_startSocksProxy( + _env: JNIEnv, + _class: JClass, + port: jint, +) -> jint { + log_info!("Starting SOCKS proxy on port {}", port); + + // Stop any existing SOCKS server first + if let Some(handle) = SOCKS_TASK.lock().unwrap().take() { + log_info!("Aborting previous SOCKS server task"); + handle.abort(); + } + + let client_guard = ARTI_CLIENT.lock().unwrap(); + let client = match client_guard.as_ref() { + Some(c) => Arc::clone(c), + None => { + log_error!("Arti client not initialized — call initialize() first"); + return -1; + } + }; + drop(client_guard); + + let runtime_guard = TOKIO_RUNTIME.lock().unwrap(); + let runtime = match runtime_guard.as_ref() { + Some(rt) => rt, + None => { + log_error!("Tokio runtime not initialized"); + return -2; + } + }; + + let addr = format!("127.0.0.1:{}", port); + + let bind_result = runtime.block_on(async { + tokio::net::TcpListener::bind(&addr).await + }); + + let listener = match bind_result { + Ok(l) => { + log_info!("SOCKS proxy bound to {}", addr); + l + } + Err(e) => { + log_error!("Failed to bind SOCKS proxy to {}: {:?}", addr, e); + return -3; + } + }; + + let handle = runtime.spawn(async move { + log_info!("Sufficiently bootstrapped; system SOCKS now functional"); + + loop { + match listener.accept().await { + Ok((stream, _peer_addr)) => { + let client_clone = Arc::clone(&client); + tokio::spawn(async move { + if let Err(e) = handle_socks_connection(stream, client_clone).await { + log_error!("SOCKS connection error: {:?}", e); + } + }); + } + Err(e) => { + log_error!("Failed to accept SOCKS connection: {:?}", e); + break; + } + } + } + }); + + *SOCKS_TASK.lock().unwrap() = Some(handle); + log_info!("SOCKS proxy started on port {}", port); + 0 +} + +/// Handle a single SOCKS5 connection through Tor. +async fn handle_socks_connection( + mut stream: tokio::net::TcpStream, + client: Arc>, +) -> Result<()> { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let mut buf = [0u8; 512]; + + // SOCKS5 handshake: read version + methods + let n = stream.read(&mut buf).await?; + if n < 2 { + return Err(anyhow::anyhow!("Invalid SOCKS handshake")); + } + + // No auth required + stream.write_all(&[0x05, 0x00]).await?; + + // Read request + let n = stream.read(&mut buf).await?; + if n < 10 { + return Err(anyhow::anyhow!("Invalid SOCKS request")); + } + + let version = buf[0]; + let cmd = buf[1]; + let atyp = buf[3]; + + if version != 0x05 { + return Err(anyhow::anyhow!("Unsupported SOCKS version: {}", version)); + } + + if cmd != 0x01 { + stream.write_all(&[0x05, 0x07, 0x00, 0x01, 0, 0, 0, 0, 0, 0]).await?; + return Err(anyhow::anyhow!("Unsupported SOCKS command: {}", cmd)); + } + + let (target_host, target_port) = match atyp { + 0x01 => { + let ip = format!("{}.{}.{}.{}", buf[4], buf[5], buf[6], buf[7]); + let port = u16::from_be_bytes([buf[8], buf[9]]); + (ip, port) + } + 0x03 => { + let len = buf[4] as usize; + if n < 5 + len + 2 { + return Err(anyhow::anyhow!("Invalid domain name length")); + } + let domain = String::from_utf8_lossy(&buf[5..5 + len]).to_string(); + let port = u16::from_be_bytes([buf[5 + len], buf[5 + len + 1]]); + (domain, port) + } + 0x04 => { + if n < 22 { + stream.write_all(&[0x05, 0x01, 0x00, 0x01, 0, 0, 0, 0, 0, 0]).await?; + return Err(anyhow::anyhow!("Truncated IPv6 request")); + } + let ip = format!( + "{:02x}{:02x}:{:02x}{:02x}:{:02x}{:02x}:{:02x}{:02x}:{:02x}{:02x}:{:02x}{:02x}:{:02x}{:02x}:{:02x}{:02x}", + buf[4], buf[5], buf[6], buf[7], buf[8], buf[9], buf[10], buf[11], + buf[12], buf[13], buf[14], buf[15], buf[16], buf[17], buf[18], buf[19] + ); + let port = u16::from_be_bytes([buf[20], buf[21]]); + (ip, port) + } + _ => { + stream.write_all(&[0x05, 0x08, 0x00, 0x01, 0, 0, 0, 0, 0, 0]).await?; + return Err(anyhow::anyhow!("Unsupported address type: {}", atyp)); + } + }; + + let tor_stream = match client.connect((target_host.as_str(), target_port)).await { + Ok(s) => s, + Err(e) => { + log_error!("Failed to connect through Tor to {}:{}: {:?}", target_host, target_port, e); + stream.write_all(&[0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0]).await?; + return Err(e.into()); + } + }; + + // SOCKS5 success + stream.write_all(&[0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0]).await?; + + // Bidirectional forwarding + let (mut client_read, mut client_write) = stream.split(); + let (mut tor_read, mut tor_write) = tor_stream.split(); + + tokio::select! { + r = tokio::io::copy(&mut client_read, &mut tor_write) => { + if let Err(ref e) = r { log_error!("Client->Tor error: {:?}", e); } + } + r = tokio::io::copy(&mut tor_read, &mut client_write) => { + if let Err(ref e) = r { log_error!("Tor->Client error: {:?}", e); } + } + }; + + Ok(()) +} + +/// Stop the SOCKS proxy listener. The TorClient stays alive. +#[no_mangle] +pub extern "C" fn Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_stopSocksProxy( + _env: JNIEnv, + _class: JClass, +) -> jint { + log_info!("Stopping SOCKS proxy..."); + + if let Some(handle) = SOCKS_TASK.lock().unwrap().take() { + handle.abort(); + } + + if let Some(rt) = TOKIO_RUNTIME.lock().unwrap().as_ref() { + rt.block_on(async { + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + }); + } + + // NOTE: TorClient is NOT destroyed — it persists for reuse. + + log_info!("SOCKS proxy stopped"); + 0 +} \ No newline at end of file