Compare commits

..
31 Commits
Author SHA1 Message Date
Vitor Pamplona 9587026abc v1.08.0 2026-04-01 15:45:46 -04:00
Vitor PamplonaandGitHub 6ee19506ad Merge pull request #2065 from vitorpamplona/claude/fix-addstyle-indexing-9t4Ew
Fix mention styling by deferring style application until after text mutations
2026-04-01 15:32:27 -04:00
Claude 09fb10220b fix: apply addStyle after all text mutations in OutputTransformation
TextFieldBuffer.addStyle() positions are not adjusted by subsequent
replace() calls. When multiple mentions had different-length display
names, styles for later mentions became misaligned. Split into two
phases: all replace() calls first, then all addStyle() calls with
cumulative-shift-corrected positions.

https://claude.ai/code/session_01SSsxsfJLbRiesBBhQFEVUd
2026-04-01 19:15:47 +00:00
Vitor PamplonaandGitHub b2be345979 Merge pull request #2064 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-04-01 14:05:21 -04:00
Crowdin Bot 6ed251f7b9 New Crowdin translations by GitHub Action 2026-04-01 18:03:07 +00:00
Vitor PamplonaandGitHub 391475e75e Merge pull request #2063 from vitorpamplona/claude/migrate-to-arti-tor-voQeh
Replace Android TorService with ArtiProxy for Tor connectivity
2026-04-01 14:01:05 -04:00
Claude 8e6d2164d7 fix: move ArtiNative initialization off main thread
System.loadLibrary("arti_android") runs when ArtiNative is first
accessed. Previously this happened in TorService.init (via
setLogCallback), which ran on main thread during AppModules creation.

Moved setLogCallback into start(), which runs on Dispatchers.IO.
Now all JNI calls — including the native library load — happen off
main thread.

https://claude.ai/code/session_01BApgDd5udqBzMqysSRMpZu
2026-04-01 17:56:22 +00:00
Vitor Pamplona 45f1d79410 Adding compiled libs and customizing the log message. 2026-04-01 13:50:36 -04:00
Claude bc24434f1c refactor: remove duplicate android_logger from native Arti wrapper
All log messages go through send_log_to_java() → Kotlin ArtiLogCallback
→ Log.d("TorService"), which already writes to logcat. The android_logger
module was a second FFI call to __android_log_write that duplicated
every line.

https://claude.ai/code/session_01BApgDd5udqBzMqysSRMpZu
2026-04-01 17:32:38 +00:00
Claude 2429e695ce chore: bump Arti to 2.2.0 (arti-client/tor-rtcompat 0.41)
All features and APIs verified present in 0.41:
- tokio, rustls, compression, onion-service-client, static-sqlite
- TorClient::create_bootstrapped, from_directories, connect()

https://claude.ai/code/session_01BApgDd5udqBzMqysSRMpZu
2026-04-01 17:18:25 +00:00
Claude 53ae87aa9d chore: trim unnecessary Arti features to reduce binary size
- Set default-features = false on arti-client and tor-rtcompat
- Removed bridge-client (UI doesn't expose bridge config yet)
- Narrowed tokio features from "full" to only what the SOCKS proxy
  needs: rt-multi-thread, net, io-util, time, macros

Kept: tokio, rustls, compression, onion-service-client, static-sqlite
(all required for Amethyst's .onion relay support)

https://claude.ai/code/session_01BApgDd5udqBzMqysSRMpZu
2026-04-01 17:12:35 +00:00
Claude 35bf16b63e docs: add README for Arti Android build tools
Covers prerequisites, build commands, output verification, version
updates, architecture decisions, Cargo features, and troubleshooting.

https://claude.ai/code/session_01BApgDd5udqBzMqysSRMpZu
2026-04-01 17:03:47 +00:00
Claude 035c570902 chore: bump Arti version to 1.9.0 (arti-client/tor-rtcompat 0.38)
https://claude.ai/code/session_01BApgDd5udqBzMqysSRMpZu
2026-04-01 16:55:03 +00:00
Claude e50ae0fb1e feat: replace arti-mobile-ex with custom-built Arti native library
The Guardian Project's arti-mobile-ex AAR has three problems:
1. No 16KB page-aligned binaries (required for Google Play)
2. ArtiProxy's stop()+start() causes state file lock conflicts
   (lock is tied to TorClient object lifetime, released only on GC)
3. ~140MB AAR size

Replace with a custom JNI bridge built from Arti source, following
BitChat's proven approach:

Build tooling (tools/arti-build/):
- build-arti.sh: Clones official Arti, compiles with cargo-ndk
  for ARM64 + x86_64, NDK 25+ for 16KB page alignment
- Cargo.toml: Minimal deps with size-optimized release profile
- src/lib.rs: Custom SOCKS5 proxy with proper lifecycle:
  - initialize() creates TorClient once (holds state lock forever)
  - startSocksProxy() binds port and accepts connections
  - stopSocksProxy() aborts listener only (TorClient stays alive)
  This cleanly separates "stop routing traffic" from "destroy client"

Kotlin side:
- ArtiNative.kt: JNI declarations + ArtiLogCallback interface
- TorService.kt: Uses ArtiNative directly, start() initializes +
  starts proxy, stop() only stops proxy (no lock issues)
- TorManager.kt: Restored stop() calls for OFF/EXTERNAL modes
  since our native stop is now safe

Removed: arti-mobile-ex dependency from build.gradle and version catalog

Native libraries must be built separately:
  cd tools/arti-build && ./build-arti.sh

https://claude.ai/code/session_01BApgDd5udqBzMqysSRMpZu
2026-04-01 16:37:44 +00:00
Vitor Pamplona abe1082121 Fixes the default port. 2026-04-01 11:03:35 -04:00
Claude 4f92878146 fix: never stop ArtiProxy — Arti's lock is tied to object lifetime
Arti's state file lock is released when the TorClient object is
garbage collected, not when stop() is called. Calling stop()+start()
on the same ArtiProxy creates a new internal TorClient that conflicts
with the old lock that hasn't been GC'd yet.

Solution: start ArtiProxy once, let it run for the process lifetime.
When the user turns Tor OFF or EXTERNAL, TorManager simply stops
emitting Active status — OkHttp stops routing through the proxy.
The idle proxy uses negligible resources and drops circuits when no
SOCKS connections are active.

This removes stop(), Mutex, NonCancellable, CompletableDeferred — all
the complexity that was trying to work around a fundamental Arti
design constraint.

https://claude.ai/code/session_01BApgDd5udqBzMqysSRMpZu
2026-04-01 13:29:22 +00:00
Claude 14f8bf1245 fix: create ArtiProxy once and reuse — never recreate
The root cause of file lock conflicts was creating new ArtiProxy
objects on each start. Even after stop() confirmed, build() could
race with OS-level lock release.

Now ArtiProxy is created once in the TorService constructor and
reused for the app's lifetime. start() and stop() just toggle it
on/off on the same instance. No more file lock conflicts.

https://claude.ai/code/session_01BApgDd5udqBzMqysSRMpZu
2026-04-01 13:25:28 +00:00
Claude 7cac95726b fix: wait for Arti to confirm stop before allowing restart
The fixed 2s delay was insufficient — Arti's native layer releases
file locks asynchronously. Now stop() waits for the actual
"state changed to Stopped" log confirmation via CompletableDeferred,
with a 10s timeout as safety net. This ensures the file lock is
truly released before start() creates a new ArtiProxy instance.

https://claude.ai/code/session_01BApgDd5udqBzMqysSRMpZu
2026-04-01 13:21:33 +00:00
Claude 388a02b60c fix: stop Arti when switching to EXTERNAL Tor mode
EXTERNAL means another Tor process (e.g., Orbot) is running on the
device. No reason to keep our internal Arti alive alongside it.

Also removed the fallback that started Arti when the external port
was invalid — if the user chose EXTERNAL with a bad port, Tor should
be off, not silently falling back to internal.

https://claude.ai/code/session_01BApgDd5udqBzMqysSRMpZu
2026-04-01 13:11:29 +00:00
Claude 1c33dde81a fix: restore service.stop() on explicit Tor OFF for safety
Users in restrictive countries need Tor circuits torn down when they
switch to OFF — an idle proxy still maintains detectable connections.

stop() is now called only on explicit OFF toggle (user action), not on
flow pause/resume (app lifecycle). This avoids file lock races on
resume while ensuring OFF truly disconnects from the Tor network.

https://claude.ai/code/session_01BApgDd5udqBzMqysSRMpZu
2026-04-01 13:09:22 +00:00
Claude dd3f90aee9 refactor: remove unused lastLogTime from TorService
Dead code from the earlier callbackFlow version that had a bootstrap
stall monitor. No longer needed with the MutableStateFlow design.

https://claude.ai/code/session_01BApgDd5udqBzMqysSRMpZu
2026-04-01 13:04:44 +00:00
Claude d94d8776eb fix: keep ArtiProxy alive across mode switches to avoid file lock conflicts
ArtiProxy holds an exclusive filesystem lock. Destroying it on OFF and
recreating on INTERNAL caused lock conflicts because the native layer
needs time to release the lock.

Instead, create ArtiProxy once and never destroy it. When Tor is OFF
or EXTERNAL, the proxy sits idle with no SOCKS connections — negligible
resource usage. This eliminates all file lock race conditions.

Also wrap start/stop in NonCancellable to prevent transformLatest
cancellation from leaking half-initialized proxy instances.

https://claude.ai/code/session_01BApgDd5udqBzMqysSRMpZu
2026-04-01 13:01:31 +00:00
Claude c83256bac2 fix: singleton ArtiProxy lifecycle to prevent state file lock conflicts
The callbackFlow-based TorService created a new ArtiProxy on every flow
collection. When the app paused and resumed, the old instance's file lock
wasn't released before the new one started, causing "Another process has
the lock on our state files" errors.

Redesigned TorService to own a single ArtiProxy with explicit start/stop:
- ArtiProxy is created once and reused across flow re-collections
- State exposed via MutableStateFlow instead of callbackFlow
- Mutex guards start/stop to prevent races
- TorManager calls service.start() and service.stop() explicitly when
  switching between INTERNAL/OFF/EXTERNAL modes
- stop() includes a 2s settle delay for native file lock release

https://claude.ai/code/session_01BApgDd5udqBzMqysSRMpZu
2026-04-01 12:44:24 +00:00
Claude 293fd39a68 fix: harden TorService with bootstrap timeout, thread safety, and error recovery
Address gaps found in code review:
- Use AtomicBoolean/AtomicLong for state accessed from native Arti
  log callback thread (was a data race)
- Add bootstrap timeout monitor (120s) that restarts Arti once if
  bootstrapping stalls, following BitChat's inactivity pattern
- Clean up failed ArtiProxy instances before port retry
- Detect "Another process has the lock" fatal error from Arti logs

https://claude.ai/code/session_01BApgDd5udqBzMqysSRMpZu
2026-04-01 00:23:52 +00:00
Vitor Pamplona cd11503b1e v1.07.5 2026-03-31 20:18:34 -04:00
Vitor Pamplona 9b2fbcbf56 Fixes image uploading crash 2026-03-31 20:17:14 -04:00
Vitor Pamplona 5cd83bbaa4 v1.07.4 2026-03-31 19:19:24 -04:00
Vitor PamplonaandGitHub 91dfda3480 Merge pull request #2061 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-31 19:13:59 -04:00
Crowdin Bot b1b2825dd6 New Crowdin translations by GitHub Action 2026-03-31 23:12:49 +00:00
Vitor Pamplona 48d90a8252 Fixes Wallet import encoding bug 2026-03-31 19:10:15 -04:00
Claude 11af50e0ad feat: migrate Tor implementation from tor-android to Arti (Rust)
Replace Guardian Project's tor-android (C Tor) and jtorctl with
arti-mobile-ex, a Rust-based Tor implementation. This eliminates
the Android Service binding complexity in favor of an in-process
ArtiProxy object.

Key changes:
- TorService: Replace ServiceConnection to org.torproject.jni.TorService
  with direct ArtiProxy.Builder/start/stop API. Bootstrap state detected
  via log parsing (following BitChat's pattern).
- TorServiceStatus: Remove TorControlConnection field (Arti doesn't
  support jtorctl control protocol).
- RelayProxyClientConnector: Remove DORMANT/ACTIVE/NEWNYM control
  signals. Arti manages its own circuit lifecycle internally.
- Dependencies: Replace tor-android + jtorctl with arti-mobile-ex 1.2.3.

TorManager's external API (status/activePortOrNull StateFlows) and all
downstream consumers (DualHttpClientManager, TorSettings, UI) are
unchanged.

https://claude.ai/code/session_01BApgDd5udqBzMqysSRMpZu
2026-03-31 22:00:48 +00:00
43 changed files with 1451 additions and 324 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 = 439
versionName = "1.07.3"
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.3` (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.3"
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.3")
implementation("com.vitorpamplona.quartz:quartz:1.08.0")
}
```
@@ -3,7 +3,7 @@
## Current version
```
com.vitorpamplona.quartz:quartz:1.07.3
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.3"
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.3")
implementation("com.vitorpamplona.quartz:quartz:1.08.0")
}
```
@@ -70,7 +70,7 @@ plugins {
}
dependencies {
implementation("com.vitorpamplona.quartz:quartz:1.07.3")
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.3
com.vitorpamplona.quartz:quartz:1.08.0
```
See `.claude/skills/quartz-integration/SKILL.md` for full integration guide.
+16
View File
@@ -1,3 +1,19 @@
<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
- Fixes Image uploads crashing the app
<a id="v1.07.4"></a>
# [Release v1.07.4: NWC fix](https://github.com/vitorpamplona/amethyst/releases/tag/v1.07.4) - 2026-03-31
- Fixes Nostr wallet connect receiving the secret.
<a id="v1.07.3"></a>
# [Release v1.07.3: GIF Keyboard](https://github.com/vitorpamplona/amethyst/releases/tag/v1.07.3) - 2026-03-31
+3 -6
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 = 439
versionName = generateVersionName("1.07.3")
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 {
@@ -372,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
@@ -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
@@ -195,7 +195,7 @@ fun uriToRoute(
if (isWalletConnectRoute(uri)) {
try {
val url = UriParser(uri)
val nip47Uri = url.getQueryParameter("value")
val nip47Uri = url.getQueryParameter("value")?.firstOrNull()
if (nip47Uri != null) {
Nip47WalletConnect.parse(nip47Uri)
return Route.Nip47NWCSetup(nip47Uri)
@@ -205,47 +205,50 @@ open class EditPostViewModel : ViewModel() {
)
if (results.allGood) {
results.successful.forEach { state ->
if (state.result is UploadOrchestrator.OrchestratorResult.NIP95Result) {
val nip95 =
myAccount.createNip95(
byteArray = state.result.bytes,
headerInfo = state.result.fileHeader,
alt = alt,
contentWarningReason = if (sensitiveContent) "" else null,
)
nip95attachments = nip95attachments + nip95
val note = nip95.let { it1 -> account.consumeNip95(it1.first, it1.second) }
val urls =
results.successful.mapNotNull { state ->
if (state.result is UploadOrchestrator.OrchestratorResult.NIP95Result) {
val nip95 =
myAccount.createNip95(
byteArray = state.result.bytes,
headerInfo = state.result.fileHeader,
alt = alt,
contentWarningReason = if (sensitiveContent) "" else null,
)
nip95attachments = nip95attachments + nip95
val note = nip95.let { it1 -> account.consumeNip95(it1.first, it1.second) }
note?.let {
message = message.insertUrlAtCursor("nostr:" + it.toNEvent())
note?.let {
"nostr:" + it.toNEvent()
}
} else if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
val iMeta =
IMetaTagBuilder(state.result.url)
.apply {
hash(state.result.fileHeader.hash)
size(state.result.fileHeader.size)
state.result.fileHeader.mimeType
?.let { mimeType(it) }
state.result.fileHeader.dim
?.let { dims(it) }
state.result.fileHeader.blurHash
?.let { blurhash(it.blurhash) }
state.result.magnet?.let { magnet(it) }
state.result.uploadedHash?.let { originalHash(it) }
alt?.let { alt(it) }
if (sensitiveContent) sensitiveContent("")
}.build()
iMetaAttachments = iMetaAttachments.filter { it.url != iMeta.url } + iMeta
state.result.url
} else {
null
}
urlPreview = findUrlInMessage()
} else if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
val iMeta =
IMetaTagBuilder(state.result.url)
.apply {
hash(state.result.fileHeader.hash)
size(state.result.fileHeader.size)
state.result.fileHeader.mimeType
?.let { mimeType(it) }
state.result.fileHeader.dim
?.let { dims(it) }
state.result.fileHeader.blurHash
?.let { blurhash(it.blurhash) }
state.result.magnet?.let { magnet(it) }
state.result.uploadedHash?.let { originalHash(it) }
alt?.let { alt(it) }
if (sensitiveContent) sensitiveContent("")
}.build()
iMetaAttachments = iMetaAttachments.filter { it.url != iMeta.url } + iMeta
message = message.insertUrlAtCursor(state.result.url)
urlPreview = findUrlInMessage()
}
}
message = message.insertUrlAtCursor(urls.joinToString(" "))
urlPreview = findUrlInMessage()
this@EditPostViewModel.multiOrchestrator = null
} else {
@@ -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)
}
@@ -521,41 +521,45 @@ open class CommentPostViewModel :
)
if (results.allGood) {
results.successful.forEach { state ->
if (state.result is UploadOrchestrator.OrchestratorResult.NIP95Result) {
val nip95 = account.createNip95(state.result.bytes, headerInfo = state.result.fileHeader, alt, contentWarningReason)
nip95attachments = nip95attachments + nip95
val note = nip95.let { it1 -> account.consumeNip95(it1.first, it1.second) }
val urls =
results.successful.mapNotNull { state ->
if (state.result is UploadOrchestrator.OrchestratorResult.NIP95Result) {
val nip95 = account.createNip95(state.result.bytes, headerInfo = state.result.fileHeader, alt, contentWarningReason)
nip95attachments = nip95attachments + nip95
val note = nip95.let { it1 -> account.consumeNip95(it1.first, it1.second) }
note?.let {
message.insertUrlAtCursor("nostr:" + it.toNEvent())
urlPreviews.update(message.text.toString())
note?.let {
"nostr:" + it.toNEvent()
}
} else if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
val iMeta =
IMetaTagBuilder(state.result.url)
.apply {
hash(state.result.fileHeader.hash)
size(state.result.fileHeader.size)
state.result.fileHeader.mimeType
?.let { mimeType(it) }
state.result.fileHeader.dim
?.let { dims(it) }
state.result.fileHeader.blurHash
?.let { blurhash(it.blurhash) }
state.result.magnet?.let { magnet(it) }
state.result.uploadedHash?.let { originalHash(it) }
alt?.let { alt(it) }
contentWarningReason?.let { sensitiveContent(contentWarningReason) }
}.build()
iMetaAttachments.replace(iMeta.url, iMeta)
state.result.url
} else {
null
}
} else if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
val iMeta =
IMetaTagBuilder(state.result.url)
.apply {
hash(state.result.fileHeader.hash)
size(state.result.fileHeader.size)
state.result.fileHeader.mimeType
?.let { mimeType(it) }
state.result.fileHeader.dim
?.let { dims(it) }
state.result.fileHeader.blurHash
?.let { blurhash(it.blurhash) }
state.result.magnet?.let { magnet(it) }
state.result.uploadedHash?.let { originalHash(it) }
alt?.let { alt(it) }
contentWarningReason?.let { sensitiveContent(contentWarningReason) }
}.build()
iMetaAttachments.replace(iMeta.url, iMeta)
message.insertUrlAtCursor(state.result.url)
urlPreviews.update(message.text.toString())
}
}
message.insertUrlAtCursor(urls.joinToString(" "))
urlPreviews.update(message.text.toString())
multiOrchestrator = null
} else {
@@ -360,24 +360,25 @@ open class ChannelNewMessageViewModel :
)
if (results.allGood) {
results.successful.forEach { upload ->
if (upload.result is UploadOrchestrator.OrchestratorResult.NIP95Result) {
val nip95 = account.createNip95(upload.result.bytes, headerInfo = upload.result.fileHeader, uploadState.caption, uploadState.contentWarningReason)
nip95attachments = nip95attachments + nip95
val note = nip95.let { it1 -> account.consumeNip95(it1.first, it1.second) }
val urls =
results.successful.mapNotNull { upload ->
if (upload.result is UploadOrchestrator.OrchestratorResult.NIP95Result) {
val nip95 = account.createNip95(upload.result.bytes, headerInfo = upload.result.fileHeader, uploadState.caption, uploadState.contentWarningReason)
nip95attachments = nip95attachments + nip95
val note = nip95.let { it1 -> account.consumeNip95(it1.first, it1.second) }
note?.let {
message.insertUrlAtCursor(it.toNostrUri())
note?.toNostrUri()
} else if (upload.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
iMetaAttachments.add(upload.result, uploadState.caption, uploadState.contentWarningReason)
upload.result.url
} else {
null
}
urlPreview = findUrlInMessage()
} else if (upload.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
iMetaAttachments.add(upload.result, uploadState.caption, uploadState.contentWarningReason)
message.insertUrlAtCursor(upload.result.url)
urlPreview = findUrlInMessage()
}
}
message.insertUrlAtCursor(urls.joinToString(" "))
urlPreview = findUrlInMessage()
uploadState.reset()
onceUploaded()
@@ -519,31 +519,36 @@ class LongFormPostViewModel :
)
if (results.allGood) {
results.successful.forEach { state ->
if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
val iMeta =
IMetaTagBuilder(state.result.url)
.apply {
hash(state.result.fileHeader.hash)
size(state.result.fileHeader.size)
state.result.fileHeader.mimeType
?.let { mimeType(it) }
state.result.fileHeader.dim
?.let { dims(it) }
state.result.fileHeader.blurHash
?.let { blurhash(it.blurhash) }
state.result.magnet?.let { magnet(it) }
state.result.uploadedHash?.let { originalHash(it) }
alt?.let { alt(it) }
contentWarningReason?.let { sensitiveContent(contentWarningReason) }
}.build()
val urls =
results.successful.mapNotNull { state ->
if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
val iMeta =
IMetaTagBuilder(state.result.url)
.apply {
hash(state.result.fileHeader.hash)
size(state.result.fileHeader.size)
state.result.fileHeader.mimeType
?.let { mimeType(it) }
state.result.fileHeader.dim
?.let { dims(it) }
state.result.fileHeader.blurHash
?.let { blurhash(it.blurhash) }
state.result.magnet?.let { magnet(it) }
state.result.uploadedHash?.let { originalHash(it) }
alt?.let { alt(it) }
contentWarningReason?.let { sensitiveContent(contentWarningReason) }
}.build()
iMetaAttachments.replace(iMeta.url, iMeta)
iMetaAttachments.replace(iMeta.url, iMeta)
val markdownImage = "![${alt ?: ""}](${state.result.url})"
message.insertUrlAtCursor(markdownImage)
val markdownImage = "![${alt ?: ""}](${state.result.url})"
markdownImage
} else {
null
}
}
}
message.insertUrlAtCursor(urls.joinToString(" "))
multiOrchestrator = null
} else {
@@ -421,30 +421,35 @@ open class NewProductViewModel :
)
if (results.allGood) {
results.successful.forEach {
if (it.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
if (it.result.fileHeader.mimeType
?.startsWith("image") == true
) {
productImages = productImages +
ProductImageMeta(
it.result.url,
it.result.fileHeader.mimeType,
it.result.fileHeader.blurHash
?.blurhash,
it.result.fileHeader.dim,
alt,
it.result.fileHeader.hash,
it.result.fileHeader.size,
)
} else {
iMetaDescription.add(it.result, alt, contentWarningReason)
val urls =
results.successful.mapNotNull {
if (it.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
if (it.result.fileHeader.mimeType
?.startsWith("image") == true
) {
productImages = productImages +
ProductImageMeta(
it.result.url,
it.result.fileHeader.mimeType,
it.result.fileHeader.blurHash
?.blurhash,
it.result.fileHeader.dim,
alt,
it.result.fileHeader.hash,
it.result.fileHeader.size,
)
} else {
iMetaDescription.add(it.result, alt, contentWarningReason)
message.insertUrlAtCursor(it.result.url)
urlPreviews.update(message.text.toString())
it.result.url
}
} else {
null
}
}
}
message.insertUrlAtCursor(urls.joinToString(" "))
urlPreviews.update(message.text.toString())
multiOrchestrator = null
} else {
@@ -996,41 +996,45 @@ open class ShortNotePostViewModel :
)
if (results.allGood) {
results.successful.forEach { state ->
if (state.result is UploadOrchestrator.OrchestratorResult.NIP95Result) {
val nip95 = account.createNip95(state.result.bytes, headerInfo = state.result.fileHeader, alt, contentWarningReason)
nip95attachments = nip95attachments + nip95
val note = nip95.let { it1 -> account.consumeNip95(it1.first, it1.second) }
val urls =
results.successful.mapNotNull { state ->
if (state.result is UploadOrchestrator.OrchestratorResult.NIP95Result) {
val nip95 = account.createNip95(state.result.bytes, headerInfo = state.result.fileHeader, alt, contentWarningReason)
nip95attachments = nip95attachments + nip95
val note = nip95.let { it1 -> account.consumeNip95(it1.first, it1.second) }
note?.let {
message.insertUrlAtCursor("nostr:" + it.toNEvent())
urlPreviews.update(message.text.toString())
note?.let {
"nostr:" + it.toNEvent()
}
} else if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
val iMeta =
IMetaTagBuilder(state.result.url)
.apply {
hash(state.result.fileHeader.hash)
size(state.result.fileHeader.size)
state.result.fileHeader.mimeType
?.let { mimeType(it) }
state.result.fileHeader.dim
?.let { dims(it) }
state.result.fileHeader.blurHash
?.let { blurhash(it.blurhash) }
state.result.magnet?.let { magnet(it) }
state.result.uploadedHash?.let { originalHash(it) }
alt?.let { alt(it) }
contentWarningReason?.let { sensitiveContent(contentWarningReason) }
}.build()
iMetaAttachments.replace(iMeta.url, iMeta)
state.result.url
} else {
null
}
} else if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
val iMeta =
IMetaTagBuilder(state.result.url)
.apply {
hash(state.result.fileHeader.hash)
size(state.result.fileHeader.size)
state.result.fileHeader.mimeType
?.let { mimeType(it) }
state.result.fileHeader.dim
?.let { dims(it) }
state.result.fileHeader.blurHash
?.let { blurhash(it.blurhash) }
state.result.magnet?.let { magnet(it) }
state.result.uploadedHash?.let { originalHash(it) }
alt?.let { alt(it) }
contentWarningReason?.let { sensitiveContent(contentWarningReason) }
}.build()
iMetaAttachments.replace(iMeta.url, iMeta)
message.insertUrlAtCursor(state.result.url)
urlPreviews.update(message.text.toString())
}
}
message.insertUrlAtCursor(urls.joinToString(" "))
urlPreviews.update(message.text.toString())
multiOrchestrator = null
} else {
@@ -464,41 +464,45 @@ class NewPublicMessageViewModel :
)
if (results.allGood) {
results.successful.forEach { state ->
if (state.result is UploadOrchestrator.OrchestratorResult.NIP95Result) {
val nip95 = account.createNip95(state.result.bytes, headerInfo = state.result.fileHeader, alt, contentWarningReason)
nip95attachments = nip95attachments + nip95
val note = nip95.let { it1 -> account.consumeNip95(it1.first, it1.second) }
val urls =
results.successful.mapNotNull { state ->
if (state.result is UploadOrchestrator.OrchestratorResult.NIP95Result) {
val nip95 = account.createNip95(state.result.bytes, headerInfo = state.result.fileHeader, alt, contentWarningReason)
nip95attachments = nip95attachments + nip95
val note = nip95.let { it1 -> account.consumeNip95(it1.first, it1.second) }
note?.let {
message.insertUrlAtCursor("nostr:" + it.toNEvent())
urlPreviews.update(message.text.toString())
note?.let {
"nostr:" + it.toNEvent()
}
} else if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
val iMeta =
IMetaTagBuilder(state.result.url)
.apply {
hash(state.result.fileHeader.hash)
size(state.result.fileHeader.size)
state.result.fileHeader.mimeType
?.let { mimeType(it) }
state.result.fileHeader.dim
?.let { dims(it) }
state.result.fileHeader.blurHash
?.let { blurhash(it.blurhash) }
state.result.magnet?.let { magnet(it) }
state.result.uploadedHash?.let { originalHash(it) }
alt?.let { alt(it) }
contentWarningReason?.let { sensitiveContent(contentWarningReason) }
}.build()
iMetaAttachments.replace(iMeta.url, iMeta)
state.result.url
} else {
null
}
} else if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
val iMeta =
IMetaTagBuilder(state.result.url)
.apply {
hash(state.result.fileHeader.hash)
size(state.result.fileHeader.size)
state.result.fileHeader.mimeType
?.let { mimeType(it) }
state.result.fileHeader.dim
?.let { dims(it) }
state.result.fileHeader.blurHash
?.let { blurhash(it.blurhash) }
state.result.magnet?.let { magnet(it) }
state.result.uploadedHash?.let { originalHash(it) }
alt?.let { alt(it) }
contentWarningReason?.let { sensitiveContent(contentWarningReason) }
}.build()
iMetaAttachments.replace(iMeta.url, iMeta)
message.insertUrlAtCursor(state.result.url)
urlPreviews.update(message.text.toString())
}
}
message.insertUrlAtCursor(urls.joinToString(" "))
urlPreviews.update(message.text.toString())
multiOrchestrator = null
} else {
@@ -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"
}
}
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
}
}
@@ -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.
@@ -378,8 +378,15 @@
<string name="bookmarks">Záložky</string>
<string name="bookmarks_title">Výchozí záložky</string>
<string name="bookmarks_explainer">Vaše výchozí záložky, které mnoho klientů podporuje</string>
<string name="old_bookmarks_title">Staré záložky</string>
<string name="old_bookmarks_explainer">Vaše staré záložky</string>
<string name="migrate_bookmarks_button">Přesunout vše do nových záložek</string>
<string name="migrate_bookmarks_success">Záložky úspěšně přesunuty</string>
<string name="drafts">Koncepty</string>
<string name="polls">Ankety</string>
<string name="pictures">Obrázky</string>
<string name="shorts">Krátká videa</string>
<string name="longs">Videa</string>
<string name="private_bookmarks">Soukromé záložky</string>
<string name="public_bookmarks">Veřejné záložky</string>
<string name="add_to_private_bookmarks">Přidat do soukromých záložek</string>
@@ -651,6 +658,16 @@
<string name="app_notification_mark_read_label">Označit jako přečtené</string>
<string name="app_notification_dms_summary">Nové zprávy</string>
<string name="app_notification_zaps_summary">Nové zapsy</string>
<string name="app_notification_reactions_channel_name">Reakce</string>
<string name="app_notification_reactions_channel_description">Upozorní vás, když někdo reaguje na váš příspěvek</string>
<string name="app_notification_reactions_channel_message">%1$s reagoval/a na váš příspěvek</string>
<string name="app_notification_reactions_channel_message_for">na %1$s</string>
<string name="app_notification_reactions_summary">Nové reakce</string>
<string name="app_notification_chess_channel_name">Šachy</string>
<string name="app_notification_chess_channel_description">Upozorní vás na události šachových her</string>
<string name="app_notification_chess_challenge_accepted">%1$s přijal/a vaši šachovou výzvu</string>
<string name="app_notification_chess_your_turn">%1$s táhl/a — jste na tahu</string>
<string name="app_notification_chess_summary">Aktualizace šachů</string>
<string name="reply_notify">Upozornit: </string>
<string name="channel_list_join_conversation">Připojit se ke konverzaci</string>
<string name="channel_list_user_or_group_id">ID uživatele nebo skupiny</string>
@@ -1090,6 +1107,7 @@
<string name="route_notifications">Upozornění</string>
<string name="route_global">Globální</string>
<string name="route_video">Krátké</string>
<string name="route_pictures">Obrázky</string>
<string name="route_chess">Šachy</string>
<string name="wallet">Peněženka</string>
<string name="wallet_balance">Zůstatek</string>
@@ -1470,12 +1488,14 @@
<string name="kind_audio_track">Audio stopa</string>
<string name="kind_badge_awards">Ocenění odznaků</string>
<string name="kind_badge_definitions">Definice odznaků</string>
<string name="kind_accepted_badge_set">Přijatá sada odznaků</string>
<string name="kind_profile_badges">Profilové odznaky</string>
<string name="kind_blocked_relays">Blokované relaye</string>
<string name="kind_blossom_servers">Blossom servery</string>
<string name="kind_blossom_auth">Blossom ověření</string>
<string name="kind_broadcast_relays">Broadcast relaye</string>
<string name="kind_bookmark_list">Seznam záložek</string>
<string name="kind_old_bookmark_list">Starý seznam záložek</string>
<string name="kind_day_appointment">Denní schůzka</string>
<string name="kind_calendar">Kalendář</string>
<string name="kind_appointment">Schůzka</string>
@@ -384,8 +384,14 @@ anz der Bedingungen ist erforderlich</string>
<string name="bookmarks">Lesezeichen</string>
<string name="bookmarks_title">Standard-Lesezeichen</string>
<string name="bookmarks_explainer">Deine Standard-Lesezeichen, die viele Clients unterstützen</string>
<string name="old_bookmarks_title">Alte Lesezeichen</string>
<string name="old_bookmarks_explainer">Deine alten Lesezeichen</string>
<string name="migrate_bookmarks_button">Alle in neue Lesezeichen verschieben</string>
<string name="migrate_bookmarks_success">Lesezeichen erfolgreich migriert</string>
<string name="drafts">Entwürfe</string>
<string name="polls">Umfragen</string>
<string name="pictures">Bilder</string>
<string name="shorts">Kurzvideos</string>
<string name="private_bookmarks">Private Lesezeichen</string>
<string name="public_bookmarks">Öffentliche Lesezeichen</string>
<string name="add_to_private_bookmarks">Zu den privaten Lesezeichen hinzufügen</string>
@@ -656,6 +662,16 @@ anz der Bedingungen ist erforderlich</string>
<string name="app_notification_mark_read_label">Als gelesen markieren</string>
<string name="app_notification_dms_summary">Neue Nachrichten</string>
<string name="app_notification_zaps_summary">Neue Zaps</string>
<string name="app_notification_reactions_channel_name">Reaktionen</string>
<string name="app_notification_reactions_channel_description">Benachrichtigt dich, wenn jemand auf deinen Beitrag reagiert</string>
<string name="app_notification_reactions_channel_message">%1$s hat auf deinen Beitrag reagiert</string>
<string name="app_notification_reactions_channel_message_for">für %1$s</string>
<string name="app_notification_reactions_summary">Neue Reaktionen</string>
<string name="app_notification_chess_channel_name">Schach</string>
<string name="app_notification_chess_channel_description">Benachrichtigt dich über Schachspiel-Ereignisse</string>
<string name="app_notification_chess_challenge_accepted">%1$s hat deine Schachherausforderung angenommen</string>
<string name="app_notification_chess_your_turn">%1$s hat gezogen — du bist dran</string>
<string name="app_notification_chess_summary">Schach-Updates</string>
<string name="reply_notify">Benachrichtigen: </string>
<string name="channel_list_join_conversation">Unterhaltung beitreten</string>
<string name="channel_list_user_or_group_id">Benutzer- oder Gruppen-ID</string>
@@ -1095,6 +1111,7 @@ anz der Bedingungen ist erforderlich</string>
<string name="route_notifications">Benachrichtigungen</string>
<string name="route_global">Global</string>
<string name="route_video">Kurzfilme</string>
<string name="route_pictures">Bilder</string>
<string name="route_chess">Schach</string>
<string name="wallet">Wallet</string>
<string name="wallet_balance">Guthaben</string>
@@ -1475,12 +1492,14 @@ anz der Bedingungen ist erforderlich</string>
<string name="kind_audio_track">Audiospur</string>
<string name="kind_badge_awards">Abzeichen-Auszeichnungen</string>
<string name="kind_badge_definitions">Abzeichen-Definitionen</string>
<string name="kind_accepted_badge_set">Akzeptiertes Abzeichen-Set</string>
<string name="kind_profile_badges">Profil-Abzeichen</string>
<string name="kind_blocked_relays">Blockierte Relays</string>
<string name="kind_blossom_servers">Blossom-Server</string>
<string name="kind_blossom_auth">Blossom-Authentifizierung</string>
<string name="kind_broadcast_relays">Broadcast-Relays</string>
<string name="kind_bookmark_list">Lesezeichenliste</string>
<string name="kind_old_bookmark_list">Alte Lesezeichenliste</string>
<string name="kind_day_appointment">Tagestermin</string>
<string name="kind_calendar">Kalender</string>
<string name="kind_appointment">Termin</string>
@@ -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>
@@ -378,8 +378,15 @@
<string name="bookmarks">Itens Salvos</string>
<string name="bookmarks_title">Marcadores padrão</string>
<string name="bookmarks_explainer">Seus marcadores padrão que muitos clientes suportam</string>
<string name="old_bookmarks_title">Favoritos Antigos</string>
<string name="old_bookmarks_explainer">Seus favoritos antigos</string>
<string name="migrate_bookmarks_button">Mover Tudo para Novos Favoritos</string>
<string name="migrate_bookmarks_success">Favoritos migrados com sucesso</string>
<string name="drafts">Rascunhos</string>
<string name="polls">Enquetes</string>
<string name="pictures">Imagens</string>
<string name="shorts">Curtas</string>
<string name="longs">Vídeos</string>
<string name="private_bookmarks">Itens Salvos Privados</string>
<string name="public_bookmarks">Itens Salvos Públicos</string>
<string name="add_to_private_bookmarks">Adicionar aos Itens Salvos Privados</string>
@@ -651,6 +658,16 @@
<string name="app_notification_mark_read_label">Marcar como lida</string>
<string name="app_notification_dms_summary">Novas mensagens</string>
<string name="app_notification_zaps_summary">Novos zaps</string>
<string name="app_notification_reactions_channel_name">Reações</string>
<string name="app_notification_reactions_channel_description">Notifica você quando alguém reage à sua publicação</string>
<string name="app_notification_reactions_channel_message">%1$s reagiu à sua publicação</string>
<string name="app_notification_reactions_channel_message_for">para %1$s</string>
<string name="app_notification_reactions_summary">Novas reações</string>
<string name="app_notification_chess_channel_name">Xadrez</string>
<string name="app_notification_chess_channel_description">Notifica você sobre eventos de jogos de xadrez</string>
<string name="app_notification_chess_challenge_accepted">%1$s aceitou seu desafio de xadrez</string>
<string name="app_notification_chess_your_turn">%1$s jogou — a sua vez</string>
<string name="app_notification_chess_summary">Atualizações de xadrez</string>
<string name="reply_notify">Notificar: </string>
<string name="channel_list_join_conversation">Entrar na conversa</string>
<string name="channel_list_user_or_group_id">ID do usuário ou grupo</string>
@@ -1090,6 +1107,7 @@
<string name="route_notifications">Notificações</string>
<string name="route_global">Global</string>
<string name="route_video">Vídeos Curtos</string>
<string name="route_pictures">Imagens</string>
<string name="route_chess">Xadrez</string>
<string name="wallet">Carteira</string>
<string name="wallet_balance">Saldo</string>
@@ -1470,12 +1488,14 @@
<string name="kind_audio_track">Faixa de Áudio</string>
<string name="kind_badge_awards">Concessões de Emblemas</string>
<string name="kind_badge_definitions">Definições de Emblemas</string>
<string name="kind_accepted_badge_set">Conjunto de Distintivos Aceito</string>
<string name="kind_profile_badges">Emblemas do Perfil</string>
<string name="kind_blocked_relays">Relays Bloqueados</string>
<string name="kind_blossom_servers">Servidores Blossom</string>
<string name="kind_blossom_auth">Autenticação Blossom</string>
<string name="kind_broadcast_relays">Relays de Broadcast</string>
<string name="kind_bookmark_list">Lista de Favoritos</string>
<string name="kind_old_bookmark_list">Lista de Favoritos Antiga</string>
<string name="kind_day_appointment">Compromisso do Dia</string>
<string name="kind_calendar">Calendário</string>
<string name="kind_appointment">Compromisso</string>
@@ -1505,6 +1505,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="kind_audio_track">Zvočni zapis</string>
<string name="kind_badge_awards">Nagradne značke</string>
<string name="kind_badge_definitions">Definicija značke</string>
<string name="kind_accepted_badge_set">Sprejet nabor značk</string>
<string name="kind_profile_badges">Značke profila</string>
<string name="kind_blocked_relays">Blokirani releji</string>
<string name="kind_blossom_servers">Blossom strežniki</string>
@@ -378,8 +378,15 @@
<string name="bookmarks">Bokmärken</string>
<string name="bookmarks_title">Standardbokmärken</string>
<string name="bookmarks_explainer">Dina standardbokmärken som många klienter stödjer</string>
<string name="old_bookmarks_title">Gamla bokmärken</string>
<string name="old_bookmarks_explainer">Dina gamla bokmärken</string>
<string name="migrate_bookmarks_button">Flytta allt till nya bokmärken</string>
<string name="migrate_bookmarks_success">Bokmärken migrerade</string>
<string name="drafts">Utkast</string>
<string name="polls">Omröstningar</string>
<string name="pictures">Bilder</string>
<string name="shorts">Kortfilmer</string>
<string name="longs">Videor</string>
<string name="private_bookmarks">Privata Bokmärken</string>
<string name="public_bookmarks">Publika Bokmärken</string>
<string name="add_to_private_bookmarks">Lägg till i Privata Bokmärken</string>
@@ -650,6 +657,16 @@
<string name="app_notification_mark_read_label">Markera som läst</string>
<string name="app_notification_dms_summary">Nya meddelanden</string>
<string name="app_notification_zaps_summary">Nya zaps</string>
<string name="app_notification_reactions_channel_name">Reaktioner</string>
<string name="app_notification_reactions_channel_description">Meddelar dig när någon reagerar på ditt inlägg</string>
<string name="app_notification_reactions_channel_message">%1$s reagerade på ditt inlägg</string>
<string name="app_notification_reactions_channel_message_for">för %1$s</string>
<string name="app_notification_reactions_summary">Nya reaktioner</string>
<string name="app_notification_chess_channel_name">Schack</string>
<string name="app_notification_chess_channel_description">Meddelar dig om schackspelshändelser</string>
<string name="app_notification_chess_challenge_accepted">%1$s accepterade din schackutmaning</string>
<string name="app_notification_chess_your_turn">%1$s drog — din tur</string>
<string name="app_notification_chess_summary">Schackuppdateringar</string>
<string name="reply_notify">Meddela: </string>
<string name="channel_list_join_conversation">Gå med i konversation</string>
<string name="channel_list_user_or_group_id">Användare eller grupp ID</string>
@@ -1089,6 +1106,7 @@
<string name="route_notifications">Aviseringar</string>
<string name="route_global">Globalt</string>
<string name="route_video">Kortfilmer</string>
<string name="route_pictures">Bilder</string>
<string name="route_chess">Schack</string>
<string name="wallet">Plånbok</string>
<string name="wallet_balance">Saldo</string>
@@ -1469,12 +1487,14 @@
<string name="kind_audio_track">Ljudspår</string>
<string name="kind_badge_awards">Utmärkelser</string>
<string name="kind_badge_definitions">Utmärkelsedefinitioner</string>
<string name="kind_accepted_badge_set">Accepterad emblemuppsättning</string>
<string name="kind_profile_badges">Profilutmärkelser</string>
<string name="kind_blocked_relays">Blockerade relayer</string>
<string name="kind_blossom_servers">Blossom-servrar</string>
<string name="kind_blossom_auth">Blossom-autentisering</string>
<string name="kind_broadcast_relays">Broadcast-relayer</string>
<string name="kind_bookmark_list">Bokmärkeslista</string>
<string name="kind_old_bookmark_list">Gammal bokmärkeslista</string>
<string name="kind_day_appointment">Dagsmöte</string>
<string name="kind_calendar">Kalender</string>
<string name="kind_appointment">Möte</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>
-4
View File
@@ -25,7 +25,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"
@@ -51,7 +50,6 @@ securityCryptoKtx = "1.1.0"
slf4j = "2.0.17"
spotless = "8.4.0"
tarsosdsp = "2.5"
torAndroid = "0.4.9.5.1"
translate = "17.0.3"
jetbrainsCompose = "1.10.3"
unifiedpush = "3.0.10"
@@ -141,7 +139,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" }
@@ -163,7 +160,6 @@ secp256k1-kmp-common = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp", v
secp256k1-kmp-jni-android = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-android", version.ref = "secp256k1KmpJniAndroid" }
secp256k1-kmp-jni-jvm = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-jvm", version.ref = "secp256k1KmpJniAndroid" }
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.3",
version = "1.08.0",
)
// Configure publishing to Maven Central
@@ -45,9 +45,15 @@ actual class UriParser actual constructor(
return queryItems.mapNotNull { (it as? NSURLQueryItem)?.name }.toSet()
}
actual fun getQueryParameter(param: String): String? {
actual fun getQueryParameter(param: String): List<String>? {
val queryItems = nsUrlComponents.queryItems ?: return null
return (queryItems.firstOrNull { (it as? NSURLQueryItem)?.name == param } as? NSURLQueryItem)?.value
return queryItems.mapNotNull {
if ((it as? NSURLQueryItem)?.name == param) {
it.value
} else {
null
}
}
}
val fragments: Map<String, String> by lazy {
@@ -63,10 +63,10 @@ class Nip47WalletConnect {
throw IllegalArgumentException("Hostname is not a valid Nostr Pubkey")
}
val relay = url.getQueryParameter("relay") ?: throw IllegalArgumentException("Relay cannot be null")
val relay = url.getQueryParameter("relay")?.firstOrNull() ?: throw IllegalArgumentException("Relay cannot be null")
val relayNorm = RelayUrlNormalizer.normalizeOrNull(relay) ?: throw IllegalArgumentException("Invalid relay Url")
val secret = url.getQueryParameter("secret")
val lud16 = url.getQueryParameter("lud16")
val secret = url.getQueryParameter("secret")?.firstOrNull()
val lud16 = url.getQueryParameter("lud16")?.firstOrNull()
return Nip47URINorm(pubkeyHex, relayNorm, secret, lud16)
}
@@ -33,7 +33,7 @@ expect class UriParser(
fun queryParameterNames(): Set<String>
fun getQueryParameter(param: String): String?
fun getQueryParameter(param: String): List<String>?
fun fragments(): Map<String, String>
}
@@ -22,22 +22,32 @@ package com.vitorpamplona.quartz.utils
import java.net.URI
import java.net.URLDecoder
import kotlin.getValue
actual class UriParser actual constructor(
uri: String,
) {
private val myUri = URI.create(uri)
private val queryParameters: Map<String, String> by lazy {
myUri.query?.ifBlank { null }?.let { query ->
query.split('&').associate { paramValue ->
private val queryParameters: Map<String, List<String>> by lazy {
myUri.rawQuery?.ifBlank { null }?.let { query ->
val params = mutableMapOf<String, MutableList<String>>()
query.split('&').forEach { paramValue ->
val parts = paramValue.split("=", limit = 2)
val currentValue =
params.getOrPut(parts[0]) {
mutableListOf()
}
if (parts.size == 2) {
parts[0] to URLDecoder.decode(parts[1], "UTF-8")
currentValue.add(URLDecoder.decode(parts[1], "UTF-8"))
} else {
parts[0] to "" // Handle parameters without a value
currentValue.add("")
}
}
params
} ?: emptyMap()
}
@@ -67,7 +77,7 @@ actual class UriParser actual constructor(
actual fun queryParameterNames(): Set<String> = queryParameters.keys
actual fun getQueryParameter(param: String): String? = queryParameters[param]
actual fun getQueryParameter(param: String): List<String>? = queryParameters[param]
actual fun fragments(): Map<String, String> = fragments
}
@@ -125,20 +125,20 @@ actual class UriParser actual constructor(
val query = parsedQuery ?: return emptySet()
return query
.split('&')
.mapNotNull { param ->
.map { param ->
val eqIndex = param.indexOf('=')
if (eqIndex >= 0) param.substring(0, eqIndex) else param
}.toSet()
}
actual fun getQueryParameter(param: String): String? {
actual fun getQueryParameter(param: String): List<String>? {
val query = parsedQuery ?: return null
return query
.split('&')
.firstOrNull { part ->
.filter { part ->
val eqIndex = part.indexOf('=')
if (eqIndex >= 0) part.substring(0, eqIndex) == param else part == param
}?.let { part ->
}.map { part ->
val eqIndex = part.indexOf('=')
if (eqIndex >= 0) part.substring(eqIndex + 1) else ""
}
+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
}