Merge branch 'claude/add-webrtc-calls-4kBSR' of https://github.com/vitorpamplona/amethyst into claude/add-webrtc-calls-4kBSR
# Conflicts: # amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/WebRtcCallSession.kt # quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/NIP-AC.md
This commit is contained in:
@@ -745,8 +745,8 @@ android {
|
|||||||
applicationId = "com.vitorpamplona.amethyst"
|
applicationId = "com.vitorpamplona.amethyst"
|
||||||
minSdk = 26 // Android 8.0 (Oreo)
|
minSdk = 26 // Android 8.0 (Oreo)
|
||||||
targetSdk = 36 // Android 15
|
targetSdk = 36 // Android 15
|
||||||
versionCode = 441
|
versionCode = 442
|
||||||
versionName = "1.07.5"
|
versionName = "1.08.0"
|
||||||
|
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
useSupportLibrary = true
|
useSupportLibrary = true
|
||||||
|
|||||||
@@ -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.
|
Reference for integrating `com.vitorpamplona.quartz:quartz` into external Nostr KMP projects.
|
||||||
|
|
||||||
**Published artifact**: `com.vitorpamplona.quartz:quartz:1.07.5` (Maven Central)
|
**Published artifact**: `com.vitorpamplona.quartz:quartz:1.08.0` (Maven Central)
|
||||||
**Targets**: JVM 21+, Android (minSdk 21+), iOS (XCFramework `quartz-kmpKit`)
|
**Targets**: JVM 21+, Android (minSdk 21+), iOS (XCFramework `quartz-kmpKit`)
|
||||||
**License**: MIT
|
**License**: MIT
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ Reference for integrating `com.vitorpamplona.quartz:quartz` into external Nostr
|
|||||||
|
|
||||||
```toml
|
```toml
|
||||||
[versions]
|
[versions]
|
||||||
quartz = "1.07.5"
|
quartz = "1.08.0"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" }
|
quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" }
|
||||||
@@ -41,7 +41,7 @@ kotlin {
|
|||||||
|
|
||||||
```kotlin
|
```kotlin
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation("com.vitorpamplona.quartz:quartz:1.07.5")
|
implementation("com.vitorpamplona.quartz:quartz:1.08.0")
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
## Current version
|
## Current version
|
||||||
|
|
||||||
```
|
```
|
||||||
com.vitorpamplona.quartz:quartz:1.07.5
|
com.vitorpamplona.quartz:quartz:1.08.0
|
||||||
```
|
```
|
||||||
|
|
||||||
Check latest: https://central.sonatype.com/artifact/com.vitorpamplona.quartz/quartz
|
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
|
```toml
|
||||||
[versions]
|
[versions]
|
||||||
quartz = "1.07.5"
|
quartz = "1.08.0"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" }
|
quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" }
|
||||||
@@ -55,7 +55,7 @@ kotlin {
|
|||||||
```kotlin
|
```kotlin
|
||||||
// build.gradle.kts (app module)
|
// build.gradle.kts (app module)
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation("com.vitorpamplona.quartz:quartz:1.07.5")
|
implementation("com.vitorpamplona.quartz:quartz:1.08.0")
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -70,7 +70,7 @@ plugins {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation("com.vitorpamplona.quartz:quartz:1.07.5")
|
implementation("com.vitorpamplona.quartz:quartz:1.08.0")
|
||||||
// JNA needed for libsodium (NIP-44) on JVM
|
// JNA needed for libsodium (NIP-44) on JVM
|
||||||
implementation("net.java.dev.jna:jna:5.18.1")
|
implementation("net.java.dev.jna:jna:5.18.1")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ The Quartz library was successfully converted from Android-only to full KMP supp
|
|||||||
## Current artifact
|
## Current artifact
|
||||||
|
|
||||||
```
|
```
|
||||||
com.vitorpamplona.quartz:quartz:1.07.5
|
com.vitorpamplona.quartz:quartz:1.08.0
|
||||||
```
|
```
|
||||||
|
|
||||||
See `.claude/skills/quartz-integration/SKILL.md` for full integration guide.
|
See `.claude/skills/quartz-integration/SKILL.md` for full integration guide.
|
||||||
@@ -1,3 +1,9 @@
|
|||||||
|
<a id="v1.08.0"></a>
|
||||||
|
# [Release v1.08.0: Arti](https://github.com/vitorpamplona/amethyst/releases/tag/v1.08.0) - 2026-04-01
|
||||||
|
|
||||||
|
- Migrates C Tor to Arti Tor (hopefully no more random crashes)
|
||||||
|
- Fixes highlight of users when composing and tagging
|
||||||
|
|
||||||
<a id="v1.07.5"></a>
|
<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
|
# [Release v1.07.5: Image upload fix](https://github.com/vitorpamplona/amethyst/releases/tag/v1.07.5) - 2026-03-31
|
||||||
|
|
||||||
|
|||||||
@@ -54,9 +54,9 @@ android {
|
|||||||
applicationId = "com.vitorpamplona.amethyst"
|
applicationId = "com.vitorpamplona.amethyst"
|
||||||
minSdk = libs.versions.android.minSdk.get().toInteger()
|
minSdk = libs.versions.android.minSdk.get().toInteger()
|
||||||
targetSdk = libs.versions.android.targetSdk.get().toInteger()
|
targetSdk = libs.versions.android.targetSdk.get().toInteger()
|
||||||
versionCode = 441
|
versionCode = 442
|
||||||
versionName = generateVersionName("1.07.5")
|
versionName = generateVersionName("1.08.0")
|
||||||
buildConfigField "String", "RELEASE_NOTES_ID", "\"12cd4bce977ed53502cf121ecba89a190ab02685333c8f230bac35b04f920eeb\""
|
buildConfigField "String", "RELEASE_NOTES_ID", "\"be99e8c8d4df0f54b44eb6c96976ccb38baeea0192436a1c6fc8bc5e930da6b0\""
|
||||||
|
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
@@ -349,9 +349,6 @@ dependencies {
|
|||||||
implementation libs.vico.charts.compose
|
implementation libs.vico.charts.compose
|
||||||
implementation libs.vico.charts.m3
|
implementation libs.vico.charts.m3
|
||||||
|
|
||||||
// GeoHash
|
|
||||||
implementation libs.drfonfon.geohash
|
|
||||||
|
|
||||||
// Waveform visualizer
|
// Waveform visualizer
|
||||||
implementation libs.audiowaveform
|
implementation libs.audiowaveform
|
||||||
|
|
||||||
@@ -375,9 +372,6 @@ dependencies {
|
|||||||
// Kotlin serialization for the times where we need the Json tree and performance is not that important.
|
// Kotlin serialization for the times where we need the Json tree and performance is not that important.
|
||||||
implementation(libs.kotlinx.serialization.json)
|
implementation(libs.kotlinx.serialization.json)
|
||||||
|
|
||||||
implementation libs.tor.android
|
|
||||||
implementation libs.jtorctl
|
|
||||||
|
|
||||||
testImplementation libs.junit
|
testImplementation libs.junit
|
||||||
testImplementation libs.mockk
|
testImplementation libs.mockk
|
||||||
testImplementation libs.kotlinx.coroutines.test
|
testImplementation libs.kotlinx.coroutines.test
|
||||||
|
|||||||
+1
-1
@@ -20,7 +20,7 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe
|
package com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe
|
||||||
|
|
||||||
import com.fonfon.kgeohash.GeoHash
|
import com.vitorpamplona.amethyst.service.location.GeoHash
|
||||||
|
|
||||||
fun compute50kmLine(geoHash: GeoHash): List<String> {
|
fun compute50kmLine(geoHash: GeoHash): List<String> {
|
||||||
val hashes = mutableListOf<String>()
|
val hashes = mutableListOf<String>()
|
||||||
|
|||||||
@@ -0,0 +1,268 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.service.location
|
||||||
|
|
||||||
|
import android.location.Location
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GeoHash encoding/decoding based on Gustavo Niemeyer's algorithm (2008).
|
||||||
|
*
|
||||||
|
* Bits are interleaved: even-indexed bits (0, 2, 4, ...) = longitude,
|
||||||
|
* odd-indexed bits (1, 3, 5, ...) = latitude.
|
||||||
|
*/
|
||||||
|
class GeoHash private constructor(
|
||||||
|
// Left-aligned: MSB is bit 0 of the hash
|
||||||
|
private val bits: Long,
|
||||||
|
private val significantBits: Int,
|
||||||
|
val centerLat: Double,
|
||||||
|
val centerLon: Double,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
const val BASE32 = "0123456789bcdefghjkmnpqrstuvwxyz"
|
||||||
|
private const val BITS_PER_CHAR = 5
|
||||||
|
const val MAX_CHAR_PRECISION = 12
|
||||||
|
private const val MAX_BITS = 64
|
||||||
|
private const val LAT_MAX = 90.0
|
||||||
|
private const val LON_MAX = 180.0
|
||||||
|
|
||||||
|
// ASCII → base32 index; -1 for invalid characters
|
||||||
|
private val CHAR_INDEX =
|
||||||
|
IntArray(128) { -1 }.also {
|
||||||
|
BASE32.forEachIndexed { i, c -> it[c.code] = i }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Encode [lat]/[lon] into a GeoHash with [charsCount] character precision. */
|
||||||
|
fun encode(
|
||||||
|
lat: Double,
|
||||||
|
lon: Double,
|
||||||
|
charsCount: Int,
|
||||||
|
): GeoHash {
|
||||||
|
require(charsCount in 1..MAX_CHAR_PRECISION)
|
||||||
|
val targetBits = charsCount * BITS_PER_CHAR
|
||||||
|
val latRange = doubleArrayOf(-LAT_MAX, LAT_MAX)
|
||||||
|
val lonRange = doubleArrayOf(-LON_MAX, LON_MAX)
|
||||||
|
var bits = 0L
|
||||||
|
var isEven = true
|
||||||
|
|
||||||
|
repeat(targetBits) {
|
||||||
|
if (isEven) {
|
||||||
|
val mid = (lonRange[0] + lonRange[1]) * 0.5
|
||||||
|
if (lon >= mid) {
|
||||||
|
bits = (bits shl 1) or 1L
|
||||||
|
lonRange[0] = mid
|
||||||
|
} else {
|
||||||
|
bits = bits shl 1
|
||||||
|
lonRange[1] = mid
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val mid = (latRange[0] + latRange[1]) * 0.5
|
||||||
|
if (lat >= mid) {
|
||||||
|
bits = (bits shl 1) or 1L
|
||||||
|
latRange[0] = mid
|
||||||
|
} else {
|
||||||
|
bits = bits shl 1
|
||||||
|
latRange[1] = mid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
isEven = !isEven
|
||||||
|
}
|
||||||
|
|
||||||
|
return GeoHash(
|
||||||
|
bits = bits shl (MAX_BITS - targetBits),
|
||||||
|
significantBits = targetBits,
|
||||||
|
centerLat = (latRange[0] + latRange[1]) * 0.5,
|
||||||
|
centerLon = (lonRange[0] + lonRange[1]) * 0.5,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Decode a GeoHash string. Returns null if the string is invalid. */
|
||||||
|
fun decode(hash: String): GeoHash? {
|
||||||
|
if (hash.isEmpty() || hash.length > MAX_CHAR_PRECISION) return null
|
||||||
|
val latRange = doubleArrayOf(-LAT_MAX, LAT_MAX)
|
||||||
|
val lonRange = doubleArrayOf(-LON_MAX, LON_MAX)
|
||||||
|
var bits = 0L
|
||||||
|
var isEven = true
|
||||||
|
|
||||||
|
for (c in hash) {
|
||||||
|
val code = c.code
|
||||||
|
if (code >= 128) return null
|
||||||
|
val index = CHAR_INDEX[code]
|
||||||
|
if (index < 0) return null
|
||||||
|
|
||||||
|
for (j in 4 downTo 0) {
|
||||||
|
val bitSet = (index ushr j) and 1 == 1
|
||||||
|
if (isEven) {
|
||||||
|
val mid = (lonRange[0] + lonRange[1]) * 0.5
|
||||||
|
if (bitSet) {
|
||||||
|
bits = (bits shl 1) or 1L
|
||||||
|
lonRange[0] = mid
|
||||||
|
} else {
|
||||||
|
bits = bits shl 1
|
||||||
|
lonRange[1] = mid
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val mid = (latRange[0] + latRange[1]) * 0.5
|
||||||
|
if (bitSet) {
|
||||||
|
bits = (bits shl 1) or 1L
|
||||||
|
latRange[0] = mid
|
||||||
|
} else {
|
||||||
|
bits = bits shl 1
|
||||||
|
latRange[1] = mid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
isEven = !isEven
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val sigBits = hash.length * BITS_PER_CHAR
|
||||||
|
return GeoHash(
|
||||||
|
bits = bits shl (MAX_BITS - sigBits),
|
||||||
|
significantBits = sigBits,
|
||||||
|
centerLat = (latRange[0] + latRange[1]) * 0.5,
|
||||||
|
centerLon = (lonRange[0] + lonRange[1]) * 0.5,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconstruct a GeoHash by re-interleaving separated lat/lon bit arrays.
|
||||||
|
* Used internally to compute cardinal neighbors.
|
||||||
|
*/
|
||||||
|
private fun fromBitArrays(
|
||||||
|
latBits: Long,
|
||||||
|
numLat: Int,
|
||||||
|
lonBits: Long,
|
||||||
|
numLon: Int,
|
||||||
|
): GeoHash {
|
||||||
|
// Left-align both arrays
|
||||||
|
var lb = latBits shl (MAX_BITS - numLat)
|
||||||
|
var lob = lonBits shl (MAX_BITS - numLon)
|
||||||
|
val total = numLat + numLon
|
||||||
|
|
||||||
|
val latRange = doubleArrayOf(-LAT_MAX, LAT_MAX)
|
||||||
|
val lonRange = doubleArrayOf(-LON_MAX, LON_MAX)
|
||||||
|
var newBits = 0L
|
||||||
|
// isEven=false → lon first (even-indexed positions = lon in GeoHash)
|
||||||
|
var isEven = false
|
||||||
|
|
||||||
|
repeat(total) {
|
||||||
|
if (isEven) {
|
||||||
|
val bitSet = lb < 0L
|
||||||
|
lb = lb shl 1
|
||||||
|
val mid = (latRange[0] + latRange[1]) * 0.5
|
||||||
|
if (bitSet) {
|
||||||
|
newBits = (newBits shl 1) or 1L
|
||||||
|
latRange[0] = mid
|
||||||
|
} else {
|
||||||
|
newBits = newBits shl 1
|
||||||
|
latRange[1] = mid
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val bitSet = lob < 0L
|
||||||
|
lob = lob shl 1
|
||||||
|
val mid = (lonRange[0] + lonRange[1]) * 0.5
|
||||||
|
if (bitSet) {
|
||||||
|
newBits = (newBits shl 1) or 1L
|
||||||
|
lonRange[0] = mid
|
||||||
|
} else {
|
||||||
|
newBits = newBits shl 1
|
||||||
|
lonRange[1] = mid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
isEven = !isEven
|
||||||
|
}
|
||||||
|
|
||||||
|
return GeoHash(
|
||||||
|
newBits shl (MAX_BITS - total),
|
||||||
|
total,
|
||||||
|
(latRange[0] + latRange[1]) * 0.5,
|
||||||
|
(lonRange[0] + lonRange[1]) * 0.5,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Longitude gets the extra bit when significantBits is odd (lon is encoded first)
|
||||||
|
private val numLat = significantBits / 2
|
||||||
|
private val numLon = significantBits / 2 + significantBits % 2
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts every other bit from [source] (positions 0, 2, 4, …) into a right-aligned value.
|
||||||
|
* Used to separate the interleaved lat/lon bit streams.
|
||||||
|
*/
|
||||||
|
private fun extractEvery2ndBit(
|
||||||
|
source: Long,
|
||||||
|
count: Int,
|
||||||
|
): Long {
|
||||||
|
var src = source
|
||||||
|
var result = 0L
|
||||||
|
repeat(count) {
|
||||||
|
result = result shl 1
|
||||||
|
if (src < 0L) result = result or 1L
|
||||||
|
src = src shl 2
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keep only the lowest [n] bits of [value], wrapping neighbors at poles/antimeridian. */
|
||||||
|
private fun mask(
|
||||||
|
value: Long,
|
||||||
|
n: Int,
|
||||||
|
): Long = value and (-1L ushr (MAX_BITS - n))
|
||||||
|
|
||||||
|
// Lat bits occupy odd positions (1, 3, 5, …); shift left 1 to align them to even positions.
|
||||||
|
private val latBits get() = extractEvery2ndBit(bits shl 1, numLat)
|
||||||
|
|
||||||
|
// Lon bits already occupy even positions (0, 2, 4, …).
|
||||||
|
private val lonBits get() = extractEvery2ndBit(bits, numLon)
|
||||||
|
|
||||||
|
val northernNeighbour: GeoHash get() = fromBitArrays(mask(latBits + 1L, numLat), numLat, lonBits, numLon)
|
||||||
|
val southernNeighbour: GeoHash get() = fromBitArrays(mask(latBits - 1L, numLat), numLat, lonBits, numLon)
|
||||||
|
val easternNeighbour: GeoHash get() = fromBitArrays(latBits, numLat, mask(lonBits + 1L, numLon), numLon)
|
||||||
|
val westernNeighbour: GeoHash get() = fromBitArrays(latBits, numLat, mask(lonBits - 1L, numLon), numLon)
|
||||||
|
|
||||||
|
fun toLocation(): Location =
|
||||||
|
Location("").also {
|
||||||
|
it.latitude = centerLat
|
||||||
|
it.longitude = centerLon
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun toString(): String {
|
||||||
|
val charCount = significantBits / BITS_PER_CHAR
|
||||||
|
val sb = StringBuilder(charCount)
|
||||||
|
var b = bits
|
||||||
|
repeat(charCount) {
|
||||||
|
sb.append(BASE32[(b ushr 59).toInt() and 0x1f])
|
||||||
|
b = b shl BITS_PER_CHAR
|
||||||
|
}
|
||||||
|
return sb.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (this === other) return true
|
||||||
|
if (other !is GeoHash) return false
|
||||||
|
return bits == other.bits && significantBits == other.significantBits
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hashCode(): Int = 31 * bits.hashCode() + significantBits
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Location.toGeoHash(charsCount: Int = GeoHash.MAX_CHAR_PRECISION): GeoHash = GeoHash.encode(latitude, longitude, charsCount)
|
||||||
|
|
||||||
|
fun String.toGeoHash(): GeoHash? = GeoHash.decode(this)
|
||||||
@@ -21,8 +21,6 @@
|
|||||||
package com.vitorpamplona.amethyst.service.location
|
package com.vitorpamplona.amethyst.service.location
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import com.fonfon.kgeohash.GeoHash
|
|
||||||
import com.fonfon.kgeohash.toGeoHash
|
|
||||||
import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeohashPrecision
|
import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeohashPrecision
|
||||||
import com.vitorpamplona.quartz.utils.Log
|
import com.vitorpamplona.quartz.utils.Log
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
|||||||
+9
-4
@@ -60,6 +60,7 @@ import kotlin.coroutines.cancellation.CancellationException
|
|||||||
|
|
||||||
private const val TAG = "EventNotificationConsumer"
|
private const val TAG = "EventNotificationConsumer"
|
||||||
private const val ACCOUNT_QUERY_PARAM = "?account="
|
private const val ACCOUNT_QUERY_PARAM = "?account="
|
||||||
|
private const val SCROLL_TO_QUERY_PARAM = "&scrollTo="
|
||||||
|
|
||||||
class EventNotificationConsumer(
|
class EventNotificationConsumer(
|
||||||
private val applicationContext: Context,
|
private val applicationContext: Context,
|
||||||
@@ -455,7 +456,8 @@ class EventNotificationConsumer(
|
|||||||
"notifications$ACCOUNT_QUERY_PARAM" +
|
"notifications$ACCOUNT_QUERY_PARAM" +
|
||||||
account.signer.pubKey
|
account.signer.pubKey
|
||||||
.hexToByteArray()
|
.hexToByteArray()
|
||||||
.toNpub()
|
.toNpub() +
|
||||||
|
SCROLL_TO_QUERY_PARAM + event.id
|
||||||
|
|
||||||
Log.d(TAG) { "Notify ${event.id} $content $title $noteUri" }
|
Log.d(TAG) { "Notify ${event.id} $content $title $noteUri" }
|
||||||
|
|
||||||
@@ -490,7 +492,8 @@ class EventNotificationConsumer(
|
|||||||
"notifications$ACCOUNT_QUERY_PARAM" +
|
"notifications$ACCOUNT_QUERY_PARAM" +
|
||||||
account.signer.pubKey
|
account.signer.pubKey
|
||||||
.hexToByteArray()
|
.hexToByteArray()
|
||||||
.toNpub()
|
.toNpub() +
|
||||||
|
SCROLL_TO_QUERY_PARAM + event.id
|
||||||
|
|
||||||
Log.d(TAG) { "Notify ${event.id} $title $noteUri" }
|
Log.d(TAG) { "Notify ${event.id} $title $noteUri" }
|
||||||
|
|
||||||
@@ -568,7 +571,8 @@ class EventNotificationConsumer(
|
|||||||
"notifications$ACCOUNT_QUERY_PARAM" +
|
"notifications$ACCOUNT_QUERY_PARAM" +
|
||||||
account.signer.pubKey
|
account.signer.pubKey
|
||||||
.hexToByteArray()
|
.hexToByteArray()
|
||||||
.toNpub()
|
.toNpub() +
|
||||||
|
SCROLL_TO_QUERY_PARAM + event.id
|
||||||
|
|
||||||
notificationManager()
|
notificationManager()
|
||||||
.sendReactionNotification(
|
.sendReactionNotification(
|
||||||
@@ -600,7 +604,8 @@ class EventNotificationConsumer(
|
|||||||
"notifications$ACCOUNT_QUERY_PARAM" +
|
"notifications$ACCOUNT_QUERY_PARAM" +
|
||||||
account.signer.pubKey
|
account.signer.pubKey
|
||||||
.hexToByteArray()
|
.hexToByteArray()
|
||||||
.toNpub()
|
.toNpub() +
|
||||||
|
SCROLL_TO_QUERY_PARAM + event.id
|
||||||
|
|
||||||
notificationManager()
|
notificationManager()
|
||||||
.sendChessNotification(
|
.sendChessNotification(
|
||||||
|
|||||||
+2
-14
@@ -40,7 +40,6 @@ import kotlinx.coroutines.flow.onCompletion
|
|||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.flow.onStart
|
import kotlinx.coroutines.flow.onStart
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import net.freehaven.tor.control.TorControlCommands
|
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
|
|
||||||
class RelayProxyClientConnector(
|
class RelayProxyClientConnector(
|
||||||
@@ -79,24 +78,13 @@ class RelayProxyClientConnector(
|
|||||||
client.disconnect()
|
client.disconnect()
|
||||||
}
|
}
|
||||||
if (it.torStatus is TorServiceStatus.Active) {
|
if (it.torStatus is TorServiceStatus.Active) {
|
||||||
try {
|
Log.d("ManageRelayServices", "Connectivity off, Tor idle")
|
||||||
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}" }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else if (it.connectivity is ConnectivityStatus.Active && !client.isActive()) {
|
} else if (it.connectivity is ConnectivityStatus.Active && !client.isActive()) {
|
||||||
Log.d("ManageRelayServices", "Connectivity On: Resuming Relay Services")
|
Log.d("ManageRelayServices", "Connectivity On: Resuming Relay Services")
|
||||||
|
|
||||||
if (it.torStatus is TorServiceStatus.Active) {
|
if (it.torStatus is TorServiceStatus.Active) {
|
||||||
try {
|
Log.d("ManageRelayServices", "Connectivity resumed, Tor active")
|
||||||
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}" }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// only calls this if the client is not active. Otherwise goes to the else below
|
// only calls this if the client is not active. Otherwise goes to the else below
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import com.vitorpamplona.amethyst.model.LocalCache
|
|||||||
import com.vitorpamplona.amethyst.service.lang.LanguageTranslatorService
|
import com.vitorpamplona.amethyst.service.lang.LanguageTranslatorService
|
||||||
import com.vitorpamplona.amethyst.service.playback.composable.DEFAULT_MUTED_SETTING
|
import com.vitorpamplona.amethyst.service.playback.composable.DEFAULT_MUTED_SETTING
|
||||||
import com.vitorpamplona.amethyst.service.playback.pip.BackgroundMedia
|
import com.vitorpamplona.amethyst.service.playback.pip.BackgroundMedia
|
||||||
|
import com.vitorpamplona.amethyst.ui.navigation.findParameterValue
|
||||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||||
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
|
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
|
||||||
import com.vitorpamplona.amethyst.ui.screen.AccountScreen
|
import com.vitorpamplona.amethyst.ui.screen.AccountScreen
|
||||||
@@ -129,7 +130,8 @@ fun uriToRoute(
|
|||||||
account: Account,
|
account: Account,
|
||||||
): Route? {
|
): Route? {
|
||||||
if (isNotificationRoute(uri)) {
|
if (isNotificationRoute(uri)) {
|
||||||
return Route.Notification
|
val scrollTo = runCatching { java.net.URI(uri.removePrefix("nostr:")).findParameterValue("scrollTo") }.getOrNull()
|
||||||
|
return Route.Notification(scrollToEventId = scrollTo)
|
||||||
}
|
}
|
||||||
if (isHashtagRoute(uri)) {
|
if (isHashtagRoute(uri)) {
|
||||||
return Route.Hashtag(uri.removePrefix("nostr:").removePrefix("hashtag?id=").lowercase())
|
return Route.Hashtag(uri.removePrefix("nostr:").removePrefix("hashtag?id=").lowercase())
|
||||||
|
|||||||
+16
-7
@@ -41,6 +41,11 @@ class UrlUserTagOutputTransformation(
|
|||||||
val mentionRegex = Regex("(?:@|nostr:)(?:npub1[a-z0-9]{58}|nprofile1[a-z0-9]+)")
|
val mentionRegex = Regex("(?:@|nostr:)(?:npub1[a-z0-9]{58}|nprofile1[a-z0-9]+)")
|
||||||
val matches = mentionRegex.findAll(text).toList().reversed()
|
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) {
|
for (match in matches) {
|
||||||
try {
|
try {
|
||||||
val bech32 =
|
val bech32 =
|
||||||
@@ -52,18 +57,22 @@ class UrlUserTagOutputTransformation(
|
|||||||
val displayName = "@${user.toBestDisplayName()}"
|
val displayName = "@${user.toBestDisplayName()}"
|
||||||
|
|
||||||
replace(match.range.first, match.range.last + 1, displayName)
|
replace(match.range.first, match.range.last + 1, displayName)
|
||||||
|
replacements.add(Triple(match.range.first, match.range.last + 1 - match.range.first, displayName.length))
|
||||||
// Apply color styling to the replaced display name
|
|
||||||
addStyle(
|
|
||||||
SpanStyle(color = color, textDecoration = TextDecoration.None),
|
|
||||||
match.range.first,
|
|
||||||
match.range.first + displayName.length,
|
|
||||||
)
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
if (e is CancellationException) throw e
|
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
|
// Highlight URLs in remaining text
|
||||||
highlightUrls(color)
|
highlightUrls(color)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -184,7 +184,7 @@ fun BuildNavigation(
|
|||||||
composable<Route.Message> { MessagesScreen(accountViewModel, nav) }
|
composable<Route.Message> { MessagesScreen(accountViewModel, nav) }
|
||||||
composable<Route.Video> { VideoScreen(accountViewModel, nav) }
|
composable<Route.Video> { VideoScreen(accountViewModel, nav) }
|
||||||
composable<Route.Discover> { DiscoverScreen(accountViewModel, nav) }
|
composable<Route.Discover> { DiscoverScreen(accountViewModel, nav) }
|
||||||
composable<Route.Notification> { NotificationScreen(accountViewModel, nav) }
|
composableArgs<Route.Notification> { NotificationScreen(it.scrollToEventId, accountViewModel, nav) }
|
||||||
composableFromEnd<Route.Polls> { PollsScreen(accountViewModel, nav) }
|
composableFromEnd<Route.Polls> { PollsScreen(accountViewModel, nav) }
|
||||||
composableFromEnd<Route.Pictures> { PicturesScreen(accountViewModel, nav) }
|
composableFromEnd<Route.Pictures> { PicturesScreen(accountViewModel, nav) }
|
||||||
composableFromEnd<Route.Shorts> { ShortsScreen(accountViewModel, nav) }
|
composableFromEnd<Route.Shorts> { ShortsScreen(accountViewModel, nav) }
|
||||||
|
|||||||
+1
-1
@@ -44,5 +44,5 @@ val bottomNavigationItems =
|
|||||||
BottomBarRoute(Route.Message, R.drawable.ic_dm, R.string.route_messages),
|
BottomBarRoute(Route.Message, R.drawable.ic_dm, R.string.route_messages),
|
||||||
BottomBarRoute(Route.Video, R.drawable.ic_video, R.string.route_video),
|
BottomBarRoute(Route.Video, R.drawable.ic_video, R.string.route_video),
|
||||||
BottomBarRoute(Route.Discover, R.drawable.ic_sensors, R.string.route_discover),
|
BottomBarRoute(Route.Discover, R.drawable.ic_sensors, R.string.route_discover),
|
||||||
BottomBarRoute(Route.Notification, R.drawable.ic_notifications, R.string.route_notifications),
|
BottomBarRoute(Route.Notification(), R.drawable.ic_notifications, R.string.route_notifications),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -39,7 +39,9 @@ sealed class Route {
|
|||||||
|
|
||||||
@Serializable object Discover : Route()
|
@Serializable object Discover : Route()
|
||||||
|
|
||||||
@Serializable object Notification : Route()
|
@Serializable data class Notification(
|
||||||
|
val scrollToEventId: String? = null,
|
||||||
|
) : Route()
|
||||||
|
|
||||||
@Serializable object Polls : Route()
|
@Serializable object Polls : Route()
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -27,8 +27,8 @@ import androidx.compose.runtime.mutableStateOf
|
|||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import com.fonfon.kgeohash.toGeoHash
|
|
||||||
import com.vitorpamplona.amethyst.service.location.CachedReversedGeoLocations
|
import com.vitorpamplona.amethyst.service.location.CachedReversedGeoLocations
|
||||||
|
import com.vitorpamplona.amethyst.service.location.toGeoHash
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -404,7 +404,7 @@ class AccountViewModel(
|
|||||||
mapOf(
|
mapOf(
|
||||||
Route.Home to homeHasNewItemsFlow,
|
Route.Home to homeHasNewItemsFlow,
|
||||||
Route.Message to messagesHasNewItemsFlow,
|
Route.Message to messagesHasNewItemsFlow,
|
||||||
Route.Notification to notificationHasNewItemsFlow,
|
Route.Notification() to notificationHasNewItemsFlow,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun isWriteable(): Boolean = account.isWriteable()
|
fun isWriteable(): Boolean = account.isWriteable()
|
||||||
|
|||||||
+34
@@ -104,3 +104,37 @@ class MessageSetCard(
|
|||||||
|
|
||||||
override fun id() = note.idHex
|
override fun id() = note.idHex
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if this card contains a specific event ID.
|
||||||
|
* Used for scrolling to a notification from a push notification intent.
|
||||||
|
*/
|
||||||
|
fun Card.containsEventId(eventId: String): Boolean =
|
||||||
|
when (this) {
|
||||||
|
is NoteCard -> {
|
||||||
|
note.idHex == eventId
|
||||||
|
}
|
||||||
|
|
||||||
|
is BadgeCard -> {
|
||||||
|
note.idHex == eventId
|
||||||
|
}
|
||||||
|
|
||||||
|
is MessageSetCard -> {
|
||||||
|
note.idHex == eventId
|
||||||
|
}
|
||||||
|
|
||||||
|
is ZapUserSetCard -> {
|
||||||
|
zapEvents.any { it.response.idHex == eventId || it.request.idHex == eventId }
|
||||||
|
}
|
||||||
|
|
||||||
|
is MultiSetCard -> {
|
||||||
|
note.idHex == eventId ||
|
||||||
|
zapEvents.any { it.response.idHex == eventId || it.request.idHex == eventId } ||
|
||||||
|
likeEvents.any { it.idHex == eventId } ||
|
||||||
|
boostEvents.any { it.idHex == eventId }
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+36
-1
@@ -20,8 +20,10 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications
|
package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications
|
||||||
|
|
||||||
|
import androidx.compose.animation.animateColorAsState
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
@@ -38,8 +40,12 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.MutableState
|
import androidx.compose.runtime.MutableState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
@@ -66,6 +72,7 @@ import com.vitorpamplona.amethyst.ui.theme.FeedPadding
|
|||||||
import com.vitorpamplona.amethyst.ui.theme.Size10dp
|
import com.vitorpamplona.amethyst.ui.theme.Size10dp
|
||||||
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
||||||
import com.vitorpamplona.amethyst.ui.theme.imageModifier
|
import com.vitorpamplona.amethyst.ui.theme.imageModifier
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun RenderCardFeed(
|
fun RenderCardFeed(
|
||||||
@@ -75,6 +82,7 @@ fun RenderCardFeed(
|
|||||||
listState: LazyListState,
|
listState: LazyListState,
|
||||||
nav: INav,
|
nav: INav,
|
||||||
routeForLastRead: String,
|
routeForLastRead: String,
|
||||||
|
scrollToEventId: String? = null,
|
||||||
) {
|
) {
|
||||||
val feedState by feedContent.feedContent.collectAsStateWithLifecycle()
|
val feedState by feedContent.feedContent.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
@@ -101,6 +109,7 @@ fun RenderCardFeed(
|
|||||||
routeForLastRead = routeForLastRead,
|
routeForLastRead = routeForLastRead,
|
||||||
accountViewModel = accountViewModel,
|
accountViewModel = accountViewModel,
|
||||||
nav = nav,
|
nav = nav,
|
||||||
|
scrollToEventId = scrollToEventId,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,10 +142,29 @@ private fun FeedLoaded(
|
|||||||
routeForLastRead: String,
|
routeForLastRead: String,
|
||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
nav: INav,
|
nav: INav,
|
||||||
|
scrollToEventId: String? = null,
|
||||||
) {
|
) {
|
||||||
val items by loaded.feed.collectAsStateWithLifecycle()
|
val items by loaded.feed.collectAsStateWithLifecycle()
|
||||||
val openPolls by polls.flow.collectAsStateWithLifecycle()
|
val openPolls by polls.flow.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
// Track which card is highlighted (will auto-clear after animation)
|
||||||
|
var highlightedCardId by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
// Scroll to the card containing the target event ID
|
||||||
|
if (scrollToEventId != null) {
|
||||||
|
LaunchedEffect(scrollToEventId, items) {
|
||||||
|
val position = items.list.indexOfFirst { it.containsEventId(scrollToEventId) }
|
||||||
|
if (position >= 0) {
|
||||||
|
// +1 offset for the donation card header item
|
||||||
|
val scrollIndex = position + 1 + openPolls.size
|
||||||
|
listState.animateScrollToItem(scrollIndex)
|
||||||
|
highlightedCardId = items.list[position].id()
|
||||||
|
delay(2000)
|
||||||
|
highlightedCardId = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentPadding = FeedPadding,
|
contentPadding = FeedPadding,
|
||||||
@@ -182,7 +210,14 @@ private fun FeedLoaded(
|
|||||||
key = { _, item -> item.id() },
|
key = { _, item -> item.id() },
|
||||||
contentType = { _, item -> item.javaClass.simpleName },
|
contentType = { _, item -> item.javaClass.simpleName },
|
||||||
) { _, item ->
|
) { _, item ->
|
||||||
Row(Modifier.fillMaxWidth().animateItem()) {
|
val isHighlighted = highlightedCardId == item.id()
|
||||||
|
val highlightColor by animateColorAsState(
|
||||||
|
targetValue = if (isHighlighted) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) else Color.Transparent,
|
||||||
|
animationSpec = tween(durationMillis = if (isHighlighted) 300 else 1000),
|
||||||
|
label = "highlightAnimation",
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(Modifier.fillMaxWidth().background(highlightColor).animateItem()) {
|
||||||
logTime(
|
logTime(
|
||||||
debugMessage = { "CardFeedView $item" },
|
debugMessage = { "CardFeedView $item" },
|
||||||
) {
|
) {
|
||||||
|
|||||||
+6
-3
@@ -42,6 +42,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun NotificationScreen(
|
fun NotificationScreen(
|
||||||
|
scrollToEventId: String? = null,
|
||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
nav: INav,
|
nav: INav,
|
||||||
) {
|
) {
|
||||||
@@ -50,6 +51,7 @@ fun NotificationScreen(
|
|||||||
notifSummaryState = accountViewModel.feedStates.notificationSummary,
|
notifSummaryState = accountViewModel.feedStates.notificationSummary,
|
||||||
notifPolls = accountViewModel.feedStates.notificationsOpenPolls,
|
notifPolls = accountViewModel.feedStates.notificationsOpenPolls,
|
||||||
sharedPrefs = accountViewModel.settings.uiSettingsFlow,
|
sharedPrefs = accountViewModel.settings.uiSettingsFlow,
|
||||||
|
scrollToEventId = scrollToEventId,
|
||||||
accountViewModel = accountViewModel,
|
accountViewModel = accountViewModel,
|
||||||
nav = nav,
|
nav = nav,
|
||||||
)
|
)
|
||||||
@@ -61,6 +63,7 @@ fun NotificationScreen(
|
|||||||
notifSummaryState: NotificationSummaryState,
|
notifSummaryState: NotificationSummaryState,
|
||||||
notifPolls: OpenPollsState,
|
notifPolls: OpenPollsState,
|
||||||
sharedPrefs: UiSettingsFlow,
|
sharedPrefs: UiSettingsFlow,
|
||||||
|
scrollToEventId: String? = null,
|
||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
nav: INav,
|
nav: INav,
|
||||||
) {
|
) {
|
||||||
@@ -79,8 +82,8 @@ fun NotificationScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
bottomBar = {
|
bottomBar = {
|
||||||
AppBottomBar(Route.Notification, accountViewModel) { route ->
|
AppBottomBar(Route.Notification(), accountViewModel) { route ->
|
||||||
if (route == Route.Notification) {
|
if (route is Route.Notification) {
|
||||||
notifFeedContentState.invalidateDataAndSendToTop(true)
|
notifFeedContentState.invalidateDataAndSendToTop(true)
|
||||||
} else {
|
} else {
|
||||||
nav.newStack(route)
|
nav.newStack(route)
|
||||||
@@ -98,7 +101,7 @@ fun NotificationScreen(
|
|||||||
|
|
||||||
WatchScrollToTop(notifFeedContentState, listState)
|
WatchScrollToTop(notifFeedContentState, listState)
|
||||||
|
|
||||||
RenderCardFeed(notifFeedContentState, notifPolls, accountViewModel, listState, nav, "Notification")
|
RenderCardFeed(notifFeedContentState, notifPolls, accountViewModel, listState, nav, "Notification", scrollToEventId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.ui.tor
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JNI bridge to the custom-built Arti native library (libarti_android.so).
|
||||||
|
*
|
||||||
|
* The native TorClient is created once via [initialize] and persists for the
|
||||||
|
* app's lifetime — its state file lock is never released until the process exits.
|
||||||
|
*
|
||||||
|
* The SOCKS proxy can be started and stopped independently via [startSocksProxy]
|
||||||
|
* and [stopSocksProxy] without affecting the TorClient.
|
||||||
|
*/
|
||||||
|
object ArtiNative {
|
||||||
|
init {
|
||||||
|
System.loadLibrary("arti_android")
|
||||||
|
}
|
||||||
|
|
||||||
|
external fun getVersion(): String
|
||||||
|
|
||||||
|
external fun setLogCallback(callback: ArtiLogCallback)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the Arti runtime and bootstrap the Tor client.
|
||||||
|
* @param dataDir Path to the app's private data directory for Arti state/cache.
|
||||||
|
* @return 0 on success, negative on error.
|
||||||
|
*/
|
||||||
|
external fun initialize(dataDir: String): Int
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the SOCKS5 proxy on the given port.
|
||||||
|
* Can be called multiple times — stops any existing listener first.
|
||||||
|
* @return 0 on success, negative on error.
|
||||||
|
*/
|
||||||
|
external fun startSocksProxy(port: Int): Int
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the SOCKS5 proxy listener and release the port.
|
||||||
|
* The TorClient stays alive — no state file lock issues.
|
||||||
|
* @return 0 on success.
|
||||||
|
*/
|
||||||
|
external fun stopSocksProxy(): Int
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback interface for Arti log messages from the native layer.
|
||||||
|
*/
|
||||||
|
fun interface ArtiLogCallback {
|
||||||
|
fun onLogLine(line: String)
|
||||||
|
}
|
||||||
@@ -58,18 +58,21 @@ class TorManager(
|
|||||||
}.transformLatest { (torType, externalSocksPort) ->
|
}.transformLatest { (torType, externalSocksPort) ->
|
||||||
when (torType) {
|
when (torType) {
|
||||||
TorType.INTERNAL -> {
|
TorType.INTERNAL -> {
|
||||||
|
service.start()
|
||||||
emitAll(service.status)
|
emitAll(service.status)
|
||||||
}
|
}
|
||||||
|
|
||||||
TorType.OFF -> {
|
TorType.OFF -> {
|
||||||
|
service.stop()
|
||||||
emit(TorServiceStatus.Off)
|
emit(TorServiceStatus.Off)
|
||||||
}
|
}
|
||||||
|
|
||||||
TorType.EXTERNAL -> {
|
TorType.EXTERNAL -> {
|
||||||
|
service.stop()
|
||||||
if (externalSocksPort > 0) {
|
if (externalSocksPort > 0) {
|
||||||
emit(TorServiceStatus.Active(externalSocksPort))
|
emit(TorServiceStatus.Active(externalSocksPort))
|
||||||
} else {
|
} else {
|
||||||
emitAll(service.status)
|
emit(TorServiceStatus.Off)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -95,5 +98,5 @@ class TorManager(
|
|||||||
|
|
||||||
fun isSocksReady() = status.value is TorServiceStatus.Active
|
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
|
package com.vitorpamplona.amethyst.ui.tor
|
||||||
|
|
||||||
import android.content.ComponentName
|
|
||||||
import android.content.Context
|
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 com.vitorpamplona.quartz.utils.Log
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.channels.awaitClose
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.callbackFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.coroutines.launch
|
import java.io.File
|
||||||
import org.torproject.jni.TorService
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
import org.torproject.jni.TorService.LocalBinder
|
|
||||||
|
|
||||||
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(
|
class TorService(
|
||||||
val context: Context,
|
val context: Context,
|
||||||
) {
|
) {
|
||||||
val status =
|
private val socksPort = DEFAULT_SOCKS_PORT
|
||||||
callbackFlow {
|
private val initialized = AtomicBoolean(false)
|
||||||
Log.d("TorService", "Binding Tor Service")
|
private val proxyRunning = AtomicBoolean(false)
|
||||||
trySend(TorServiceStatus.Connecting)
|
|
||||||
|
|
||||||
val currentIntent = Intent(context, TorService::class.java)
|
private val _status = MutableStateFlow<TorServiceStatus>(TorServiceStatus.Off)
|
||||||
val serviceConnection: ServiceConnection =
|
val status: StateFlow<TorServiceStatus> = _status.asStateFlow()
|
||||||
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
|
|
||||||
|
|
||||||
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)
|
_status.value = TorServiceStatus.Connecting
|
||||||
active.torControlConnection = torService.torControlConnection
|
|
||||||
|
|
||||||
trySend(active)
|
withContext(Dispatchers.IO) {
|
||||||
Log.d("TorService") { "Tor Service Connected ${torService.socksPort}" }
|
// Initialize TorClient once — this bootstraps the Tor network.
|
||||||
} catch (e: Exception) {
|
// setLogCallback and initialize are the first ArtiNative calls,
|
||||||
Log.e("TorService") { "Tor service connection failed: ${e.message}" }
|
// which triggers System.loadLibrary on this IO thread.
|
||||||
trySend(TorServiceStatus.Off)
|
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) {
|
when {
|
||||||
Log.d("TorService", "Tor Service Disconnected")
|
text.contains("Sufficiently bootstrapped", ignoreCase = true) -> {
|
||||||
trySend(TorServiceStatus.Off)
|
_status.value = TorServiceStatus.Active(socksPort)
|
||||||
|
Log.d("TorService") { "Arti SOCKS proxy active on port $socksPort" }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
val dataDir = File(context.filesDir, "arti").absolutePath
|
||||||
context.bindService(
|
Log.d("TorService") { "Initializing Arti with data dir: $dataDir" }
|
||||||
currentIntent,
|
|
||||||
serviceConnection,
|
val initResult = ArtiNative.initialize(dataDir)
|
||||||
BIND_AUTO_CREATE,
|
if (initResult != 0) {
|
||||||
)
|
Log.e("TorService") { "Failed to initialize Arti: error $initResult" }
|
||||||
} catch (e: Exception) {
|
initialized.set(false)
|
||||||
Log.e("TorService") { "Failed to bind Tor Service: ${e.message}" }
|
_status.value = TorServiceStatus.Off
|
||||||
trySend(TorServiceStatus.Off)
|
return@withContext
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
awaitClose {
|
// Start the SOCKS proxy (can be called multiple times safely)
|
||||||
Log.d("TorService", "Stopping Tor Service")
|
val proxyResult = ArtiNative.startSocksProxy(socksPort)
|
||||||
try {
|
if (proxyResult != 0) {
|
||||||
context.unbindService(serviceConnection)
|
Log.e("TorService") { "Failed to start SOCKS proxy: error $proxyResult" }
|
||||||
} catch (e: Exception) {
|
_status.value = TorServiceStatus.Off
|
||||||
Log.d("TorService") { "Failed to unbind Tor Service: ${e.message}" }
|
return@withContext
|
||||||
}
|
|
||||||
try {
|
|
||||||
context.stopService(currentIntent)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.d("TorService") { "Failed to stop Tor Service: ${e.message}" }
|
|
||||||
}
|
|
||||||
trySend(TorServiceStatus.Off)
|
|
||||||
}
|
}
|
||||||
}.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
|
package com.vitorpamplona.amethyst.ui.tor
|
||||||
|
|
||||||
import net.freehaven.tor.control.TorControlConnection
|
|
||||||
|
|
||||||
sealed class TorServiceStatus {
|
sealed class TorServiceStatus {
|
||||||
data class Active(
|
data class Active(
|
||||||
val port: Int,
|
val port: Int,
|
||||||
) : TorServiceStatus() {
|
) : TorServiceStatus()
|
||||||
// If internal, it has control.
|
|
||||||
var torControlConnection: TorControlConnection? = null
|
|
||||||
}
|
|
||||||
|
|
||||||
object Off : TorServiceStatus()
|
object Off : TorServiceStatus()
|
||||||
|
|
||||||
|
|||||||
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -380,8 +380,15 @@
|
|||||||
<string name="bookmarks">Könyvjelző</string>
|
<string name="bookmarks">Könyvjelző</string>
|
||||||
<string name="bookmarks_title">Alapértelmezett könyvjelzők</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="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="drafts">Piszkozatok</string>
|
||||||
<string name="polls">Szavazások</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="private_bookmarks">Privát könyvjelzők</string>
|
||||||
<string name="public_bookmarks">Nyilvános 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>
|
<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_mark_read_label">Megjelölés olvasottként</string>
|
||||||
<string name="app_notification_dms_summary">Új üzenetek</string>
|
<string name="app_notification_dms_summary">Új üzenetek</string>
|
||||||
<string name="app_notification_zaps_summary">Új zap-ek</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="reply_notify">Értesítés: </string>
|
||||||
<string name="channel_list_join_conversation">Csatlakozás a beszélgetéshez</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>
|
<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_notifications">Értesítések</string>
|
||||||
<string name="route_global">Globális</string>
|
<string name="route_global">Globális</string>
|
||||||
<string name="route_video">Rövidek</string>
|
<string name="route_video">Rövidek</string>
|
||||||
|
<string name="route_pictures">Képek</string>
|
||||||
<string name="route_chess">Sakk</string>
|
<string name="route_chess">Sakk</string>
|
||||||
<string name="wallet">Pénztárca</string>
|
<string name="wallet">Pénztárca</string>
|
||||||
<string name="wallet_balance">Egyenleg</string>
|
<string name="wallet_balance">Egyenleg</string>
|
||||||
@@ -1475,12 +1493,14 @@
|
|||||||
<string name="kind_audio_track">Hangsáv</string>
|
<string name="kind_audio_track">Hangsáv</string>
|
||||||
<string name="kind_badge_awards">Jelvénydíjak</string>
|
<string name="kind_badge_awards">Jelvénydíjak</string>
|
||||||
<string name="kind_badge_definitions">Jelvények meghatározása</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_profile_badges">Profiljelvények</string>
|
||||||
<string name="kind_blocked_relays">Letiltott átjátszók</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_servers">Blossom kiszolgálók</string>
|
||||||
<string name="kind_blossom_auth">Blossom-hitelesítés</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_broadcast_relays">Közvetítési átjátszók</string>
|
||||||
<string name="kind_bookmark_list">Könyvjelzőlista</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_day_appointment">Napi időpont</string>
|
||||||
<string name="kind_calendar">Naptár</string>
|
<string name="kind_calendar">Naptár</string>
|
||||||
<string name="kind_appointment">Találkozó</string>
|
<string name="kind_appointment">Találkozó</string>
|
||||||
|
|||||||
@@ -1490,6 +1490,7 @@
|
|||||||
<string name="kind_audio_track">Ścieżka audio</string>
|
<string name="kind_audio_track">Ścieżka audio</string>
|
||||||
<string name="kind_badge_awards">Odznaki</string>
|
<string name="kind_badge_awards">Odznaki</string>
|
||||||
<string name="kind_badge_definitions">Definicje odznak</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_profile_badges">Odznaki profilowe</string>
|
||||||
<string name="kind_blocked_relays">Zablokowane Transmitery</string>
|
<string name="kind_blocked_relays">Zablokowane Transmitery</string>
|
||||||
<string name="kind_blossom_servers">Serwery Blossom</string>
|
<string name="kind_blossom_servers">Serwery Blossom</string>
|
||||||
@@ -1693,6 +1694,7 @@
|
|||||||
<string name="events">wydarzeń</string>
|
<string name="events">wydarzeń</string>
|
||||||
<string name="events_from_you">wydarzenia od Ciebie</string>
|
<string name="events_from_you">wydarzenia od Ciebie</string>
|
||||||
<string name="events_to_you">wydarzenia dla 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="dms">DMs</string>
|
||||||
<string name="profiles">profile</string>
|
<string name="profiles">profile</string>
|
||||||
<string name="relay_settings_lower2">lista transmiterów wysyłających</string>
|
<string name="relay_settings_lower2">lista transmiterów wysyłających</string>
|
||||||
|
|||||||
@@ -380,8 +380,15 @@
|
|||||||
<string name="bookmarks">书签</string>
|
<string name="bookmarks">书签</string>
|
||||||
<string name="bookmarks_title">默认书签</string>
|
<string name="bookmarks_title">默认书签</string>
|
||||||
<string name="bookmarks_explainer">许多客户端支持默认书签</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="drafts">草稿</string>
|
||||||
<string name="polls">投票</string>
|
<string name="polls">投票</string>
|
||||||
|
<string name="pictures">图片</string>
|
||||||
|
<string name="shorts">短视频</string>
|
||||||
|
<string name="longs">视频</string>
|
||||||
<string name="private_bookmarks">私人书签</string>
|
<string name="private_bookmarks">私人书签</string>
|
||||||
<string name="public_bookmarks">公开书签</string>
|
<string name="public_bookmarks">公开书签</string>
|
||||||
<string name="add_to_private_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_mark_read_label">标记为已读</string>
|
||||||
<string name="app_notification_dms_summary">新信息</string>
|
<string name="app_notification_dms_summary">新信息</string>
|
||||||
<string name="app_notification_zaps_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_name">国际象棋</string>
|
||||||
<string name="app_notification_chess_channel_description">提醒您有关国际象棋游戏的事件</string>
|
<string name="app_notification_chess_channel_description">提醒您有关国际象棋游戏的事件</string>
|
||||||
<string name="app_notification_chess_challenge_accepted">%1$s 接受了您的国际象棋挑战</string>
|
<string name="app_notification_chess_challenge_accepted">%1$s 接受了您的国际象棋挑战</string>
|
||||||
@@ -1100,6 +1112,7 @@
|
|||||||
<string name="route_notifications">通知</string>
|
<string name="route_notifications">通知</string>
|
||||||
<string name="route_global">全球</string>
|
<string name="route_global">全球</string>
|
||||||
<string name="route_video">短篇</string>
|
<string name="route_video">短篇</string>
|
||||||
|
<string name="route_pictures">图片</string>
|
||||||
<string name="route_chess">国际象棋</string>
|
<string name="route_chess">国际象棋</string>
|
||||||
<string name="wallet">钱包</string>
|
<string name="wallet">钱包</string>
|
||||||
<string name="wallet_balance">余额</string>
|
<string name="wallet_balance">余额</string>
|
||||||
@@ -1480,12 +1493,14 @@
|
|||||||
<string name="kind_audio_track">音轨</string>
|
<string name="kind_audio_track">音轨</string>
|
||||||
<string name="kind_badge_awards">徽章奖励</string>
|
<string name="kind_badge_awards">徽章奖励</string>
|
||||||
<string name="kind_badge_definitions">徽章定义</string>
|
<string name="kind_badge_definitions">徽章定义</string>
|
||||||
|
<string name="kind_accepted_badge_set">接受的徽章集</string>
|
||||||
<string name="kind_profile_badges">个人资料徽章</string>
|
<string name="kind_profile_badges">个人资料徽章</string>
|
||||||
<string name="kind_blocked_relays">中继黑名单</string>
|
<string name="kind_blocked_relays">中继黑名单</string>
|
||||||
<string name="kind_blossom_servers">Blossom 服务器</string>
|
<string name="kind_blossom_servers">Blossom 服务器</string>
|
||||||
<string name="kind_blossom_auth">Blossom 认证</string>
|
<string name="kind_blossom_auth">Blossom 认证</string>
|
||||||
<string name="kind_broadcast_relays">广播中继</string>
|
<string name="kind_broadcast_relays">广播中继</string>
|
||||||
<string name="kind_bookmark_list">书签列表</string>
|
<string name="kind_bookmark_list">书签列表</string>
|
||||||
|
<string name="kind_old_bookmark_list">旧书签列表</string>
|
||||||
<string name="kind_day_appointment">日预约</string>
|
<string name="kind_day_appointment">日预约</string>
|
||||||
<string name="kind_calendar">日历</string>
|
<string name="kind_calendar">日历</string>
|
||||||
<string name="kind_appointment">预约</string>
|
<string name="kind_appointment">预约</string>
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ agp = "9.1.0"
|
|||||||
android-compileSdk = "36"
|
android-compileSdk = "36"
|
||||||
android-minSdk = "26"
|
android-minSdk = "26"
|
||||||
android-targetSdk = "36"
|
android-targetSdk = "36"
|
||||||
androidKotlinGeohash = "b481c6a64e"
|
|
||||||
androidxJunit = "1.3.0"
|
androidxJunit = "1.3.0"
|
||||||
appcompat = "1.7.1"
|
appcompat = "1.7.1"
|
||||||
audiowaveform = "1.1.2"
|
audiowaveform = "1.1.2"
|
||||||
@@ -25,7 +24,6 @@ fragmentKtx = "1.8.9"
|
|||||||
gms = "4.4.4"
|
gms = "4.4.4"
|
||||||
jacksonModuleKotlin = "2.21.2"
|
jacksonModuleKotlin = "2.21.2"
|
||||||
javaKeyring = "1.0.4"
|
javaKeyring = "1.0.4"
|
||||||
jtorctl = "0.4.5.7"
|
|
||||||
junit = "4.13.2"
|
junit = "4.13.2"
|
||||||
kchesslib = "1.0.5"
|
kchesslib = "1.0.5"
|
||||||
kotlin = "2.3.20"
|
kotlin = "2.3.20"
|
||||||
@@ -52,7 +50,6 @@ slf4j = "2.0.17"
|
|||||||
spotless = "8.4.0"
|
spotless = "8.4.0"
|
||||||
streamWebrtcAndroid = "1.3.8"
|
streamWebrtcAndroid = "1.3.8"
|
||||||
tarsosdsp = "2.5"
|
tarsosdsp = "2.5"
|
||||||
torAndroid = "0.4.9.5.1"
|
|
||||||
translate = "17.0.3"
|
translate = "17.0.3"
|
||||||
jetbrainsCompose = "1.10.3"
|
jetbrainsCompose = "1.10.3"
|
||||||
unifiedpush = "3.0.10"
|
unifiedpush = "3.0.10"
|
||||||
@@ -127,7 +124,6 @@ commons-imaging = { group = "org.apache.commons", name = "commons-imaging", vers
|
|||||||
slf4j-nop = { module = "org.slf4j:slf4j-nop", version.ref = "slf4j" }
|
slf4j-nop = { module = "org.slf4j:slf4j-nop", version.ref = "slf4j" }
|
||||||
vlcj = { group = "uk.co.caprica", name = "vlcj", version.ref = "vlcj" }
|
vlcj = { group = "uk.co.caprica", name = "vlcj", version.ref = "vlcj" }
|
||||||
dev-whyoleg-cryptography-provider-apple-optimal = { module = "dev.whyoleg.cryptography:cryptography-provider-optimal", version.ref = "devWhyolegCryptography" }
|
dev-whyoleg-cryptography-provider-apple-optimal = { module = "dev.whyoleg.cryptography:cryptography-provider-optimal", version.ref = "devWhyolegCryptography" }
|
||||||
drfonfon-geohash = { group = "com.github.drfonfon", name = "android-kotlin-geohash", version.ref = "androidKotlinGeohash" }
|
|
||||||
firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" }
|
firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" }
|
||||||
firebase-messaging = { group = "com.google.firebase", name = "firebase-messaging" }
|
firebase-messaging = { group = "com.google.firebase", name = "firebase-messaging" }
|
||||||
jetbrains-compose-components-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "jetbrainsCompose" }
|
jetbrains-compose-components-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "jetbrainsCompose" }
|
||||||
@@ -142,7 +138,6 @@ google-mlkit-language-id = { group = "com.google.mlkit", name = "language-id", v
|
|||||||
google-mlkit-translate = { group = "com.google.mlkit", name = "translate", version.ref = "translate" }
|
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" }
|
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" }
|
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" }
|
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||||
kchesslib = { module = "io.github.cvb941:kchesslib", version.ref = "kchesslib" }
|
kchesslib = { module = "io.github.cvb941:kchesslib", version.ref = "kchesslib" }
|
||||||
kotlinx-collections-immutable = { group = "org.jetbrains.kotlinx", name = "kotlinx-collections-immutable", version.ref = "kotlinxCollectionsImmutable" }
|
kotlinx-collections-immutable = { group = "org.jetbrains.kotlinx", name = "kotlinx-collections-immutable", version.ref = "kotlinxCollectionsImmutable" }
|
||||||
@@ -165,7 +160,6 @@ secp256k1-kmp-jni-android = { group = "fr.acinq.secp256k1", name = "secp256k1-km
|
|||||||
secp256k1-kmp-jni-jvm = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-jvm", version.ref = "secp256k1KmpJniAndroid" }
|
secp256k1-kmp-jni-jvm = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-jvm", version.ref = "secp256k1KmpJniAndroid" }
|
||||||
stream-webrtc-android = { group = "io.getstream", name = "stream-webrtc-android", version.ref = "streamWebrtcAndroid" }
|
stream-webrtc-android = { group = "io.getstream", name = "stream-webrtc-android", version.ref = "streamWebrtcAndroid" }
|
||||||
tarsosdsp = { group = "be.tarsos.dsp", name = "core", version.ref = "tarsosdsp" }
|
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" }
|
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-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" }
|
vico-charts-m3 = { group = "com.patrykandpatrick.vico", name = "compose-m3", version.ref = "vico-charts-compose" }
|
||||||
|
|||||||
@@ -315,7 +315,7 @@ mavenPublishing {
|
|||||||
coordinates(
|
coordinates(
|
||||||
groupId = "com.vitorpamplona.quartz",
|
groupId = "com.vitorpamplona.quartz",
|
||||||
artifactId = "quartz",
|
artifactId = "quartz",
|
||||||
version = "1.07.5",
|
version = "1.08.0",
|
||||||
)
|
)
|
||||||
|
|
||||||
// Configure publishing to Maven Central
|
// Configure publishing to Maven Central
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
.arti-source/
|
||||||
|
target/
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
arti-v2.2.0
|
||||||
@@ -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"
|
||||||
@@ -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>
|
||||||
|
```
|
||||||
Executable
+248
@@ -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 "$@"
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user