Merge branch 'claude/add-webrtc-calls-4kBSR' of https://github.com/vitorpamplona/amethyst into claude/add-webrtc-calls-4kBSR

# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/WebRtcCallSession.kt
#	quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/NIP-AC.md
This commit is contained in:
Vitor Pamplona
2026-04-01 19:51:43 -04:00
38 changed files with 1474 additions and 137 deletions
+2 -2
View File
@@ -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
+3 -3
View File
@@ -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")
}
```
@@ -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")
}
+1 -1
View File
@@ -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.
+6
View File
@@ -1,3 +1,9 @@
<a id="v1.08.0"></a>
# [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
<a id="v1.07.5"></a>
# [Release v1.07.5: Image upload fix](https://github.com/vitorpamplona/amethyst/releases/tag/v1.07.5) - 2026-03-31
+3 -9
View File
@@ -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
@@ -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<String> {
val hashes = mutableListOf<String>()
@@ -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)
@@ -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
@@ -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(
@@ -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
@@ -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())
@@ -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<Triple<Int, Int, Int>>()
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)
}
@@ -184,7 +184,7 @@ fun BuildNavigation(
composable<Route.Message> { MessagesScreen(accountViewModel, nav) }
composable<Route.Video> { VideoScreen(accountViewModel, nav) }
composable<Route.Discover> { DiscoverScreen(accountViewModel, nav) }
composable<Route.Notification> { NotificationScreen(accountViewModel, nav) }
composableArgs<Route.Notification> { NotificationScreen(it.scrollToEventId, accountViewModel, nav) }
composableFromEnd<Route.Polls> { PollsScreen(accountViewModel, nav) }
composableFromEnd<Route.Pictures> { PicturesScreen(accountViewModel, nav) }
composableFromEnd<Route.Shorts> { ShortsScreen(accountViewModel, nav) }
@@ -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),
)
@@ -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()
@@ -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
@@ -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()
@@ -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
}
}
@@ -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<String?>(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" },
) {
@@ -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)
}
}
}
@@ -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)
}
@@ -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
}
@@ -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>(TorServiceStatus.Off)
val status: StateFlow<TorServiceStatus> = _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"
}
}
when {
text.contains("Sufficiently bootstrapped", ignoreCase = true) -> {
_status.value = TorServiceStatus.Active(socksPort)
Log.d("TorService") { "Arti SOCKS proxy active on port $socksPort" }
}
}
}
override fun onServiceDisconnected(name: ComponentName) {
Log.d("TorService", "Tor Service Disconnected")
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
}
}
try {
context.bindService(
currentIntent,
serviceConnection,
BIND_AUTO_CREATE,
)
} catch (e: Exception) {
Log.e("TorService") { "Failed to bind 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
}
awaitClose {
Log.d("TorService", "Stopping Tor Service")
try {
context.unbindService(serviceConnection)
} catch (e: Exception) {
Log.d("TorService") { "Failed to unbind Tor Service: ${e.message}" }
proxyRunning.set(true)
}
try {
context.stopService(currentIntent)
} catch (e: Exception) {
Log.d("TorService") { "Failed to stop Tor Service: ${e.message}" }
}
trySend(TorServiceStatus.Off)
/**
* 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
}
}.flowOn(Dispatchers.IO)
}
@@ -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()
Binary file not shown.
Binary file not shown.
@@ -380,8 +380,15 @@
<string name="bookmarks">Könyvjelző</string>
<string name="bookmarks_title">Alapértelmezett könyvjelzők</string>
<string name="bookmarks_explainer">Az alapértelmezett könyvjelzők, amelyeket sok kliens támogat</string>
<string name="old_bookmarks_title">Régi könyvjelzők</string>
<string name="old_bookmarks_explainer">Saját régi könyvjelzők</string>
<string name="migrate_bookmarks_button">Összes átköltöztetése az új könyvjelzőkbe</string>
<string name="migrate_bookmarks_success">A könyvjelzők átköltöztetése sikeresen befejeződött</string>
<string name="drafts">Piszkozatok</string>
<string name="polls">Szavazások</string>
<string name="pictures">Képek</string>
<string name="shorts">Rövidek</string>
<string name="longs">Videók</string>
<string name="private_bookmarks">Privát könyvjelzők</string>
<string name="public_bookmarks">Nyilvános könyvjelzők</string>
<string name="add_to_private_bookmarks">Hozzáadás a privát könyvjelzőkhöz</string>
@@ -655,6 +662,16 @@
<string name="app_notification_mark_read_label">Megjelölés olvasottként</string>
<string name="app_notification_dms_summary">Új üzenetek</string>
<string name="app_notification_zaps_summary">Új zap-ek</string>
<string name="app_notification_reactions_channel_name">Reakciók</string>
<string name="app_notification_reactions_channel_description">Értesítés, amikor valaki reagál az egyik bejegyzésre</string>
<string name="app_notification_reactions_channel_message">%1$s reagált az Ön bejegyzésére</string>
<string name="app_notification_reactions_channel_message_for">neki: %1$s</string>
<string name="app_notification_reactions_summary">Új reakciók</string>
<string name="app_notification_chess_channel_name">Sakk</string>
<string name="app_notification_chess_channel_description">Értesítés a sakkjátékkal kapcsolatos eseményekre </string>
<string name="app_notification_chess_challenge_accepted">%1$s elfogadta az Ön sakk-kihívását</string>
<string name="app_notification_chess_your_turn">%1$s lépett - Ön következik</string>
<string name="app_notification_chess_summary">Sakkfrissítések</string>
<string name="reply_notify">Értesítés: </string>
<string name="channel_list_join_conversation">Csatlakozás a beszélgetéshez</string>
<string name="channel_list_user_or_group_id">Felhasználó- vagy csoport-azonosító</string>
@@ -1095,6 +1112,7 @@
<string name="route_notifications">Értesítések</string>
<string name="route_global">Globális</string>
<string name="route_video">Rövidek</string>
<string name="route_pictures">Képek</string>
<string name="route_chess">Sakk</string>
<string name="wallet">Pénztárca</string>
<string name="wallet_balance">Egyenleg</string>
@@ -1475,12 +1493,14 @@
<string name="kind_audio_track">Hangsáv</string>
<string name="kind_badge_awards">Jelvénydíjak</string>
<string name="kind_badge_definitions">Jelvények meghatározása</string>
<string name="kind_accepted_badge_set">Elfogadott kitűzőlista</string>
<string name="kind_profile_badges">Profiljelvények</string>
<string name="kind_blocked_relays">Letiltott átjátszók</string>
<string name="kind_blossom_servers">Blossom kiszolgálók</string>
<string name="kind_blossom_auth">Blossom-hitelesítés</string>
<string name="kind_broadcast_relays">Közvetítési átjátszók</string>
<string name="kind_bookmark_list">Könyvjelzőlista</string>
<string name="kind_old_bookmark_list">Régi könyvjelzőlista</string>
<string name="kind_day_appointment">Napi időpont</string>
<string name="kind_calendar">Naptár</string>
<string name="kind_appointment">Találkozó</string>
@@ -1490,6 +1490,7 @@
<string name="kind_audio_track">Ścieżka audio</string>
<string name="kind_badge_awards">Odznaki</string>
<string name="kind_badge_definitions">Definicje odznak</string>
<string name="kind_accepted_badge_set">Zestaw odznak „Przyjęty”</string>
<string name="kind_profile_badges">Odznaki profilowe</string>
<string name="kind_blocked_relays">Zablokowane Transmitery</string>
<string name="kind_blossom_servers">Serwery Blossom</string>
@@ -1693,6 +1694,7 @@
<string name="events">wydarzeń</string>
<string name="events_from_you">wydarzenia od Ciebie</string>
<string name="events_to_you">wydarzenia dla Ciebie</string>
<string name="searchable_events">wydarzenia podlegające wyszukiwaniu</string>
<string name="dms">DMs</string>
<string name="profiles">profile</string>
<string name="relay_settings_lower2">lista transmiterów wysyłających</string>
@@ -380,8 +380,15 @@
<string name="bookmarks">书签</string>
<string name="bookmarks_title">默认书签</string>
<string name="bookmarks_explainer">许多客户端支持默认书签</string>
<string name="old_bookmarks_title">旧书签</string>
<string name="old_bookmarks_explainer">您的旧书签</string>
<string name="migrate_bookmarks_button">移动全部到新书签</string>
<string name="migrate_bookmarks_success">书签迁移成功</string>
<string name="drafts">草稿</string>
<string name="polls">投票</string>
<string name="pictures">图片</string>
<string name="shorts">短视频</string>
<string name="longs">视频</string>
<string name="private_bookmarks">私人书签</string>
<string name="public_bookmarks">公开书签</string>
<string name="add_to_private_bookmarks">添加到私人书签</string>
@@ -655,6 +662,11 @@
<string name="app_notification_mark_read_label">标记为已读</string>
<string name="app_notification_dms_summary">新信息</string>
<string name="app_notification_zaps_summary">新打闪</string>
<string name="app_notification_reactions_channel_name">回应</string>
<string name="app_notification_reactions_channel_description">当有人回应您的帖子做出时通知您</string>
<string name="app_notification_reactions_channel_message">%1$s 回应了您的帖子</string>
<string name="app_notification_reactions_channel_message_for">针对 %1$s</string>
<string name="app_notification_reactions_summary">新回应</string>
<string name="app_notification_chess_channel_name">国际象棋</string>
<string name="app_notification_chess_channel_description">提醒您有关国际象棋游戏的事件</string>
<string name="app_notification_chess_challenge_accepted">%1$s 接受了您的国际象棋挑战</string>
@@ -1100,6 +1112,7 @@
<string name="route_notifications">通知</string>
<string name="route_global">全球</string>
<string name="route_video">短篇</string>
<string name="route_pictures">图片</string>
<string name="route_chess">国际象棋</string>
<string name="wallet">钱包</string>
<string name="wallet_balance">余额</string>
@@ -1480,12 +1493,14 @@
<string name="kind_audio_track">音轨</string>
<string name="kind_badge_awards">徽章奖励</string>
<string name="kind_badge_definitions">徽章定义</string>
<string name="kind_accepted_badge_set">接受的徽章集</string>
<string name="kind_profile_badges">个人资料徽章</string>
<string name="kind_blocked_relays">中继黑名单</string>
<string name="kind_blossom_servers">Blossom 服务器</string>
<string name="kind_blossom_auth">Blossom 认证</string>
<string name="kind_broadcast_relays">广播中继</string>
<string name="kind_bookmark_list">书签列表</string>
<string name="kind_old_bookmark_list">旧书签列表</string>
<string name="kind_day_appointment">日预约</string>
<string name="kind_calendar">日历</string>
<string name="kind_appointment">预约</string>
-6
View File
@@ -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" }
+1 -1
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
.arti-source/
target/
+1
View File
@@ -0,0 +1 @@
arti-v2.2.0
+29
View File
@@ -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"
+209
View File
@@ -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/<version>"
```
### 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_<methodName>
```
+248
View File
@@ -0,0 +1,248 @@
#!/usr/bin/env bash
#
# Build Arti native libraries for Android from source.
#
# Prerequisites:
# - Rust toolchain: rustup, cargo
# - Android targets: rustup target add aarch64-linux-android x86_64-linux-android
# - cargo-ndk: cargo install cargo-ndk
# - Android NDK 25+ (for 16KB page size support)
#
# Usage:
# ./build-arti.sh # Build for all targets (arm64 + x86_64)
# ./build-arti.sh --release # Build arm64 only (for release)
# ./build-arti.sh --clean # Clean and rebuild
#
set -euo pipefail
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
ARTI_SOURCE_DIR="$SCRIPT_DIR/.arti-source"
ARTI_VERSION=$(cat "$SCRIPT_DIR/ARTI_VERSION" | tr -d '[:space:]')
OUTPUT_DIR="$PROJECT_ROOT/amethyst/src/main/jniLibs"
LIB_NAME="libarti_android.so"
MIN_SDK_VERSION=26
# Default targets
TARGETS=("aarch64-linux-android" "x86_64-linux-android")
RELEASE_ONLY=false
CLEAN=false
# Parse arguments
for arg in "$@"; do
case $arg in
--release) RELEASE_ONLY=true; TARGETS=("aarch64-linux-android") ;;
--clean) CLEAN=true ;;
--help) echo "Usage: $0 [--release] [--clean] [--help]"; exit 0 ;;
esac
done
print_header() { echo -e "\n${BLUE}=== $1 ===${NC}"; }
print_success() { echo -e "${GREEN}$1${NC}"; }
print_error() { echo -e "${RED}$1${NC}"; }
print_info() { echo -e "${YELLOW}$1${NC}"; }
# ============================================================================
# Prerequisites
# ============================================================================
check_prerequisites() {
print_header "Checking prerequisites"
command -v git >/dev/null 2>&1 || { print_error "git not found"; exit 1; }
command -v rustup >/dev/null 2>&1 || { print_error "rustup not found"; exit 1; }
command -v cargo >/dev/null 2>&1 || { print_error "cargo not found"; exit 1; }
command -v cargo-ndk >/dev/null 2>&1 || { print_error "cargo-ndk not found. Install: cargo install cargo-ndk"; exit 1; }
if [ -z "${ANDROID_NDK_HOME:-}" ]; then
# Try common locations
for candidate in \
"$HOME/Android/Sdk/ndk/"*/ \
"$HOME/Library/Android/sdk/ndk/"*/ \
"/usr/local/lib/android/sdk/ndk/"*/; do
if [ -d "$candidate" ]; then
export ANDROID_NDK_HOME="${candidate%/}"
break
fi
done
fi
if [ -z "${ANDROID_NDK_HOME:-}" ]; then
print_error "ANDROID_NDK_HOME not set and NDK not found in common locations"
exit 1
fi
print_success "NDK: $ANDROID_NDK_HOME"
for target in "${TARGETS[@]}"; do
if ! rustup target list --installed | grep -q "$target"; then
print_info "Adding Rust target: $target"
rustup target add "$target"
fi
print_success "Target: $target"
done
}
# ============================================================================
# Source Management
# ============================================================================
clone_or_update_arti() {
print_header "Setting up Arti source ($ARTI_VERSION)"
if [ "$CLEAN" = true ] && [ -d "$ARTI_SOURCE_DIR" ]; then
print_info "Cleaning existing source"
rm -rf "$ARTI_SOURCE_DIR"
fi
if [ ! -d "$ARTI_SOURCE_DIR" ]; then
print_info "Cloning Arti repository..."
git clone --depth 1 --branch "$ARTI_VERSION" \
https://gitlab.torproject.org/tpo/core/arti.git \
"$ARTI_SOURCE_DIR"
else
print_info "Updating existing clone to $ARTI_VERSION"
cd "$ARTI_SOURCE_DIR"
git fetch --depth 1 origin tag "$ARTI_VERSION"
git checkout "$ARTI_VERSION"
cd "$SCRIPT_DIR"
fi
print_success "Arti source ready at $ARTI_SOURCE_DIR"
}
# ============================================================================
# Wrapper Setup
# ============================================================================
setup_wrapper() {
print_header "Setting up JNI wrapper"
local wrapper_dir="$ARTI_SOURCE_DIR/arti-android-wrapper"
mkdir -p "$wrapper_dir/src"
cp "$SCRIPT_DIR/Cargo.toml" "$wrapper_dir/Cargo.toml"
cp "$SCRIPT_DIR/src/lib.rs" "$wrapper_dir/src/lib.rs"
# Patch Cargo.toml to use local arti-client from the source tree
# instead of pulling from crates.io
cd "$wrapper_dir"
# Add path overrides for the local arti source
cat >> Cargo.toml << 'PATCH'
[patch.crates-io]
arti-client = { path = "../crates/arti-client" }
tor-rtcompat = { path = "../crates/tor-rtcompat" }
PATCH
cd "$SCRIPT_DIR"
print_success "JNI wrapper configured"
}
# ============================================================================
# Build
# ============================================================================
build_for_target() {
local target="$1"
print_header "Building for $target"
local arch_dir
case "$target" in
aarch64-linux-android) arch_dir="arm64-v8a" ;;
x86_64-linux-android) arch_dir="x86_64" ;;
armv7-linux-androideabi) arch_dir="armeabi-v7a" ;;
i686-linux-android) arch_dir="x86" ;;
esac
local out_dir="$OUTPUT_DIR/$arch_dir"
mkdir -p "$out_dir"
cargo ndk \
-t "$target" \
--platform "$MIN_SDK_VERSION" \
-o "$OUTPUT_DIR" \
build --release \
--manifest-path "$ARTI_SOURCE_DIR/arti-android-wrapper/Cargo.toml"
if [ -f "$out_dir/$LIB_NAME" ]; then
local size=$(du -h "$out_dir/$LIB_NAME" | cut -f1)
print_success "Built $arch_dir/$LIB_NAME ($size)"
else
print_error "Build failed — $out_dir/$LIB_NAME not found"
exit 1
fi
}
# ============================================================================
# Verification
# ============================================================================
verify_jni_symbols() {
print_header "Verifying JNI symbols"
local expected_symbols=(
"Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_getVersion"
"Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_setLogCallback"
"Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_initialize"
"Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_startSocksProxy"
"Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_stopSocksProxy"
)
for arch_dir in "$OUTPUT_DIR"/*/; do
local lib="$arch_dir$LIB_NAME"
[ -f "$lib" ] || continue
local arch=$(basename "$arch_dir")
local missing=0
for sym in "${expected_symbols[@]}"; do
if ! nm -D "$lib" 2>/dev/null | grep -q "$sym"; then
print_error "$arch: Missing symbol $sym"
missing=1
fi
done
if [ "$missing" -eq 0 ]; then
print_success "$arch: All JNI symbols present"
fi
done
}
# ============================================================================
# Main
# ============================================================================
main() {
echo -e "${BLUE}Arti Android Build — version $ARTI_VERSION${NC}"
check_prerequisites
clone_or_update_arti
setup_wrapper
for target in "${TARGETS[@]}"; do
build_for_target "$target"
done
verify_jni_symbols
print_header "Build complete"
echo ""
echo "Libraries written to: $OUTPUT_DIR"
echo ""
echo "Next steps:"
echo " 1. Verify 16KB page alignment: readelf -l <lib> | grep LOAD"
echo " 2. Build the app: ./gradlew :amethyst:assembleDebug"
echo " 3. Test on device"
echo ""
}
main "$@"
+386
View File
@@ -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<Option<Arc<TorClient<PreferredRuntime>>>> = Mutex::new(None);
static TOKIO_RUNTIME: Mutex<Option<tokio::runtime::Runtime>> = Mutex::new(None);
static JAVA_VM: Mutex<Option<JavaVM>> = Mutex::new(None);
static LOG_CALLBACK: Mutex<Option<GlobalRef>> = Mutex::new(None);
static SOCKS_TASK: Mutex<Option<tokio::task::JoinHandle<()>>> = Mutex::new(None);
static INIT_ONCE: Once = Once::new();
// ============================================================================
// Logging
// ============================================================================
fn send_log_to_java(message: String) {
let vm_opt = JAVA_VM.lock().unwrap();
let callback_opt = LOG_CALLBACK.lock().unwrap();
if let (Some(vm), Some(callback)) = (vm_opt.as_ref(), callback_opt.as_ref()) {
if let Ok(mut env) = vm.attach_current_thread() {
if let Ok(jmessage) = env.new_string(&message) {
let _ = env.call_method(
callback.as_obj(),
"onLogLine",
"(Ljava/lang/String;)V",
&[(&jmessage).into()]
);
}
}
}
}
macro_rules! log_info {
($($arg:tt)*) => {{
let msg = format!($($arg)*);
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<TorClient<PreferredRuntime>>,
) -> Result<()> {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let mut buf = [0u8; 512];
// SOCKS5 handshake: read version + methods
let n = stream.read(&mut buf).await?;
if n < 2 {
return Err(anyhow::anyhow!("Invalid SOCKS handshake"));
}
// No auth required
stream.write_all(&[0x05, 0x00]).await?;
// Read request
let n = stream.read(&mut buf).await?;
if n < 10 {
return Err(anyhow::anyhow!("Invalid SOCKS request"));
}
let version = buf[0];
let cmd = buf[1];
let atyp = buf[3];
if version != 0x05 {
return Err(anyhow::anyhow!("Unsupported SOCKS version: {}", version));
}
if cmd != 0x01 {
stream.write_all(&[0x05, 0x07, 0x00, 0x01, 0, 0, 0, 0, 0, 0]).await?;
return Err(anyhow::anyhow!("Unsupported SOCKS command: {}", cmd));
}
let (target_host, target_port) = match atyp {
0x01 => {
let ip = format!("{}.{}.{}.{}", buf[4], buf[5], buf[6], buf[7]);
let port = u16::from_be_bytes([buf[8], buf[9]]);
(ip, port)
}
0x03 => {
let len = buf[4] as usize;
if n < 5 + len + 2 {
return Err(anyhow::anyhow!("Invalid domain name length"));
}
let domain = String::from_utf8_lossy(&buf[5..5 + len]).to_string();
let port = u16::from_be_bytes([buf[5 + len], buf[5 + len + 1]]);
(domain, port)
}
0x04 => {
if n < 22 {
stream.write_all(&[0x05, 0x01, 0x00, 0x01, 0, 0, 0, 0, 0, 0]).await?;
return Err(anyhow::anyhow!("Truncated IPv6 request"));
}
let ip = format!(
"{:02x}{:02x}:{:02x}{:02x}:{:02x}{:02x}:{:02x}{:02x}:{:02x}{:02x}:{:02x}{:02x}:{:02x}{:02x}:{:02x}{:02x}",
buf[4], buf[5], buf[6], buf[7], buf[8], buf[9], buf[10], buf[11],
buf[12], buf[13], buf[14], buf[15], buf[16], buf[17], buf[18], buf[19]
);
let port = u16::from_be_bytes([buf[20], buf[21]]);
(ip, port)
}
_ => {
stream.write_all(&[0x05, 0x08, 0x00, 0x01, 0, 0, 0, 0, 0, 0]).await?;
return Err(anyhow::anyhow!("Unsupported address type: {}", atyp));
}
};
let tor_stream = match client.connect((target_host.as_str(), target_port)).await {
Ok(s) => s,
Err(e) => {
log_error!("Failed to connect through Tor to {}:{}: {:?}", target_host, target_port, e);
stream.write_all(&[0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0]).await?;
return Err(e.into());
}
};
// SOCKS5 success
stream.write_all(&[0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0]).await?;
// Bidirectional forwarding
let (mut client_read, mut client_write) = stream.split();
let (mut tor_read, mut tor_write) = tor_stream.split();
tokio::select! {
r = tokio::io::copy(&mut client_read, &mut tor_write) => {
if let Err(ref e) = r { log_error!("Client->Tor error: {:?}", e); }
}
r = tokio::io::copy(&mut tor_read, &mut client_write) => {
if let Err(ref e) = r { log_error!("Tor->Client error: {:?}", e); }
}
};
Ok(())
}
/// Stop the SOCKS proxy listener. The TorClient stays alive.
#[no_mangle]
pub extern "C" fn Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_stopSocksProxy(
_env: JNIEnv,
_class: JClass,
) -> jint {
log_info!("Stopping SOCKS proxy...");
if let Some(handle) = SOCKS_TASK.lock().unwrap().take() {
handle.abort();
}
if let Some(rt) = TOKIO_RUNTIME.lock().unwrap().as_ref() {
rt.block_on(async {
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
});
}
// NOTE: TorClient is NOT destroyed — it persists for reuse.
log_info!("SOCKS proxy stopped");
0
}