Manual merge, due to other unfinished work.

This commit is contained in:
KotlinGeekDev
2025-04-15 11:39:25 +01:00
216 changed files with 4044 additions and 3104 deletions
+3 -2
View File
@@ -25,8 +25,9 @@ If applicable, add a video and/or screenshots to help explain your problem.
**Device (please complete the following information):** **Device (please complete the following information):**
- Phone Brand/Model [e.g. Pixel 7 Pro]: - Phone Brand/Model [e.g. Pixel 7 Pro]:
- Android Version [e.g. 33]: - Android Version [e.g. 34]:
- App Version [e.g. v0.20.3]: - App Version [e.g. v0.94.3]:
- App Flavour [e.g. Google Play or FDroid]:
- Amber Version (if using it to sign): - Amber Version (if using it to sign):
**Bounty (in Bitcoin sats) offered for a solution** **Bounty (in Bitcoin sats) offered for a solution**
-1
View File
@@ -108,7 +108,6 @@ android {
release { release {
proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), 'proguard-rules.pro' proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), 'proguard-rules.pro'
minifyEnabled true minifyEnabled true
resValue "string", "app_name", "@string/app_name_release"
} }
debug { debug {
applicationIdSuffix '.debug' applicationIdSuffix '.debug'
+2 -4
View File
@@ -61,7 +61,5 @@
# JSON parsing # JSON parsing
-keep class com.vitorpamplona.quartz.** { *; } -keep class com.vitorpamplona.quartz.** { *; }
-keep class com.vitorpamplona.amethyst.model.** { *; } -keep class com.vitorpamplona.amethyst.** { *; }
-keep class com.vitorpamplona.amethyst.service.** { *; } -keep class com.vitorpamplona.ammolite.** { *; }
-keep class com.vitorpamplona.ammolite.service.** { *; }
-keep class com.vitorpamplona.ammolite.relays.** { *; }
@@ -21,17 +21,20 @@
package com.vitorpamplona.amethyst package com.vitorpamplona.amethyst
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.service.okhttp.HttpClientManager import com.vitorpamplona.amethyst.service.okhttp.EncryptedBlobInterceptor
import com.vitorpamplona.amethyst.service.okhttp.EncryptionKeyCache
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip17Dm.files.encryption.AESGCM import com.vitorpamplona.quartz.nip17Dm.files.encryption.AESGCM
import junit.framework.TestCase.assertEquals import junit.framework.TestCase.assertEquals
import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class DMDecryptionTest { class DMFileDecryptionTest {
val okHttp = HttpClientManager.getHttpClient(false) // Key cache to download and decrypt encrypted files before caching them.
val keyCache = EncryptionKeyCache()
val url = "https://cdn.satellite.earth/812fd4cf9d4d4b59c141ecd6a6c08c7571b5872237ad6477916cb2d119b5cacd" val url = "https://cdn.satellite.earth/812fd4cf9d4d4b59c141ecd6a6c08c7571b5872237ad6477916cb2d119b5cacd"
val cipher = val cipher =
@@ -44,7 +47,13 @@ class DMDecryptionTest {
@Test @Test
fun runDownloadAndDecryptVideo() { fun runDownloadAndDecryptVideo() {
HttpClientManager.addCipherToCache(url, cipher, "video/mp4") val client =
OkHttpClient
.Builder()
.addNetworkInterceptor(EncryptedBlobInterceptor(keyCache))
.build()
keyCache.add(url, cipher, "video/mp4")
val request = val request =
Request Request
@@ -54,7 +63,7 @@ class DMDecryptionTest {
.get() .get()
.build() .build()
okHttp.newCall(request).execute().use { client.newCall(request).execute().use {
assertEquals(decryptedSize, it.body.bytes().size) assertEquals(decryptedSize, it.body.bytes().size)
assertEquals(expectedMimeType, it.body.contentType().toString()) assertEquals(expectedMimeType, it.body.contentType().toString())
} }
@@ -26,6 +26,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry import androidx.test.platform.app.InstrumentationRegistry
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.service.okhttp.DefaultContentTypeInterceptor
import com.vitorpamplona.amethyst.service.uploads.FileHeader import com.vitorpamplona.amethyst.service.uploads.FileHeader
import com.vitorpamplona.amethyst.service.uploads.ImageDownloader import com.vitorpamplona.amethyst.service.uploads.ImageDownloader
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader
@@ -43,6 +44,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import okhttp3.OkHttpClient
import org.junit.Assert import org.junit.Assert
import org.junit.Ignore import org.junit.Ignore
import org.junit.Test import org.junit.Test
@@ -52,11 +54,21 @@ import kotlin.random.Random
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class ImageUploadTesting { class ImageUploadTesting {
val account = companion object {
Account( val account =
AccountSettings(KeyPair()), Account(
scope = CoroutineScope(Dispatchers.IO + SupervisorJob()), AccountSettings(KeyPair()),
) scope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
)
}
val client =
OkHttpClient
.Builder()
.followRedirects(true)
.followSslRedirects(true)
.addInterceptor(DefaultContentTypeInterceptor("Amethyst/${BuildConfig.VERSION_NAME}"))
.build()
private suspend fun getBitmap(): ByteArray { private suspend fun getBitmap(): ByteArray {
val bitmap = Bitmap.createBitmap(200, 300, Bitmap.Config.ARGB_8888) val bitmap = Bitmap.createBitmap(200, 300, Bitmap.Config.ARGB_8888)
@@ -94,7 +106,7 @@ class ImageUploadTesting {
alt = null, alt = null,
sensitiveContent = null, sensitiveContent = null,
serverBaseUrl = server.baseUrl, serverBaseUrl = server.baseUrl,
forceProxy = { false }, okHttpClient = { client },
httpAuth = account::createBlossomUploadAuth, httpAuth = account::createBlossomUploadAuth,
context = InstrumentationRegistry.getInstrumentation().targetContext, context = InstrumentationRegistry.getInstrumentation().targetContext,
) )
@@ -105,7 +117,7 @@ class ImageUploadTesting {
assertEquals("${server.baseUrl}/$initialHash", result.url?.removeSuffix(".png")) assertEquals("${server.baseUrl}/$initialHash", result.url?.removeSuffix(".png"))
val imageData: ByteArray = val imageData: ByteArray =
ImageDownloader().waitAndGetImage(result.url!!, false)?.bytes ImageDownloader().waitAndGetImage(result.url!!, { client })?.bytes
?: run { ?: run {
fail("${server.name}: Should not be null") fail("${server.name}: Should not be null")
return return
@@ -120,7 +132,7 @@ class ImageUploadTesting {
ServerInfoRetriever() ServerInfoRetriever()
.loadInfo( .loadInfo(
server.baseUrl, server.baseUrl,
false, { client },
) )
val paylod = getBitmap() val paylod = getBitmap()
@@ -134,7 +146,7 @@ class ImageUploadTesting {
alt = null, alt = null,
sensitiveContent = null, sensitiveContent = null,
server = serverInfo, server = serverInfo,
forceProxy = { false }, okHttpClient = { client },
onProgress = {}, onProgress = {},
httpAuth = account::createHTTPAuthorization, httpAuth = account::createHTTPAuthorization,
context = InstrumentationRegistry.getInstrumentation().targetContext, context = InstrumentationRegistry.getInstrumentation().targetContext,
@@ -148,7 +160,7 @@ class ImageUploadTesting {
Assert.assertTrue("${server.name}: Invalid result url", url.startsWith("http")) Assert.assertTrue("${server.name}: Invalid result url", url.startsWith("http"))
val imageData: ByteArray = val imageData: ByteArray =
ImageDownloader().waitAndGetImage(url, false)?.bytes ImageDownloader().waitAndGetImage(url, { client })?.bytes
?: run { ?: run {
fail("${server.name}: Should not be null") fail("${server.name}: Should not be null")
return return
@@ -255,6 +267,7 @@ class ImageUploadTesting {
} }
@Test() @Test()
@Ignore("Not Working anymore/ Timeout")
fun testNostrCheckBlossom() = fun testNostrCheckBlossom() =
runBlocking { runBlocking {
testBase(ServerName("nostrcheck", "https://cdn.nostrcheck.me", ServerType.Blossom)) testBase(ServerName("nostrcheck", "https://cdn.nostrcheck.me", ServerType.Blossom))
@@ -21,17 +21,17 @@
package com.vitorpamplona.amethyst package com.vitorpamplona.amethyst
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.service.ots.OkHttpBlockstreamExplorer import com.vitorpamplona.amethyst.service.ots.OkHttpBitcoinExplorer
import com.vitorpamplona.amethyst.service.ots.OkHttpCalendarBuilder import com.vitorpamplona.amethyst.service.ots.OkHttpCalendarBuilder
import com.vitorpamplona.amethyst.service.ots.OtsBlockHeightCache
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent
import com.vitorpamplona.quartz.nip03Timestamp.OtsResolver import com.vitorpamplona.quartz.nip03Timestamp.OtsResolver
import com.vitorpamplona.quartz.nip03Timestamp.ots.OpenTimestamps
import junit.framework.TestCase.assertEquals import junit.framework.TestCase.assertEquals
import okhttp3.OkHttpClient
import org.junit.Assert import org.junit.Assert
import org.junit.Before
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import java.util.concurrent.CountDownLatch import java.util.concurrent.CountDownLatch
@@ -44,31 +44,37 @@ class OkHttpOtsTest {
val otsPendingEvent = "{\"id\":\"12fa15ad4b4cf9dc5940389325b69b93c5c1f59c049c701ee669b275299fdaf1\",\"pubkey\":\"dcaa6c8a2f47b6fef4a34b20e8843c59dbe7c5f07a402338c09fd147dd01d22b\",\"created_at\":1708877521,\"kind\":1040,\"tags\":[[\"e\",\"a8634f5368e17789e8fb3836cad0e4b9fe4f2288afafc28723c04bebc68a18d6\"],[\"alt\",\"Opentimestamps Attestation\"]],\"content\":\"AE9wZW5UaW1lc3RhbXBzAABQcm9vZgC/ieLohOiSlAEIqGNPU2jhd4no+zg2ytDkuf5PIoivr8KHI8BL68aKGNbwELidvzr0usf55CkpKf6OABQI//AQK3sWd2tq+7KO8YNJIARJugjxBGXbZtPwCL0H4/7GL5+SAIPf4w0u+QyOLCtodHRwczovL2JvYi5idGMuY2FsZW5kYXIub3BlbnRpbWVzdGFtcHMub3Jn//AQPDZsJgN1TnJXoUzlsgo93wjwIIfBc7LUqkCbC1BLZRZ+6LXztK50UdH5xe7fn40bupkrCPEEZdtm0/AI0CADXN5ZIncAg9/jDS75DI4uLWh0dHBzOi8vYWxpY2UuYnRjLmNhbGVuZGFyLm9wZW50aW1lc3RhbXBzLm9yZ/AQcELcSrE04cuGKlZQf2LeVwjwILUDSf9vK2GaefKTpn/LV2oUsQaA5WbqaP3C+1ZxQfRNCPEEZdtm0/AIbCtb+yRXFqUAg9/jDS75DI4pKGh0dHBzOi8vZmlubmV5LmNhbGVuZGFyLmV0ZXJuaXR5d2FsbC5jb20=\",\"sig\":\"f6854c0228c15c08aeb70bbabe9ed87bbb7289fab31b13cabac15138bb71179553e06080b83f4a813fbdaf614f63293beea3fc73fe865da6551193fa4d38de04\"}" val otsPendingEvent = "{\"id\":\"12fa15ad4b4cf9dc5940389325b69b93c5c1f59c049c701ee669b275299fdaf1\",\"pubkey\":\"dcaa6c8a2f47b6fef4a34b20e8843c59dbe7c5f07a402338c09fd147dd01d22b\",\"created_at\":1708877521,\"kind\":1040,\"tags\":[[\"e\",\"a8634f5368e17789e8fb3836cad0e4b9fe4f2288afafc28723c04bebc68a18d6\"],[\"alt\",\"Opentimestamps Attestation\"]],\"content\":\"AE9wZW5UaW1lc3RhbXBzAABQcm9vZgC/ieLohOiSlAEIqGNPU2jhd4no+zg2ytDkuf5PIoivr8KHI8BL68aKGNbwELidvzr0usf55CkpKf6OABQI//AQK3sWd2tq+7KO8YNJIARJugjxBGXbZtPwCL0H4/7GL5+SAIPf4w0u+QyOLCtodHRwczovL2JvYi5idGMuY2FsZW5kYXIub3BlbnRpbWVzdGFtcHMub3Jn//AQPDZsJgN1TnJXoUzlsgo93wjwIIfBc7LUqkCbC1BLZRZ+6LXztK50UdH5xe7fn40bupkrCPEEZdtm0/AI0CADXN5ZIncAg9/jDS75DI4uLWh0dHBzOi8vYWxpY2UuYnRjLmNhbGVuZGFyLm9wZW50aW1lc3RhbXBzLm9yZ/AQcELcSrE04cuGKlZQf2LeVwjwILUDSf9vK2GaefKTpn/LV2oUsQaA5WbqaP3C+1ZxQfRNCPEEZdtm0/AIbCtb+yRXFqUAg9/jDS75DI4pKGh0dHBzOi8vZmlubmV5LmNhbGVuZGFyLmV0ZXJuaXR5d2FsbC5jb20=\",\"sig\":\"f6854c0228c15c08aeb70bbabe9ed87bbb7289fab31b13cabac15138bb71179553e06080b83f4a813fbdaf614f63293beea3fc73fe865da6551193fa4d38de04\"}"
val otsEvent2Digest = "a8634f5368e17789e8fb3836cad0e4b9fe4f2288afafc28723c04bebc68a18d6" val otsEvent2Digest = "a8634f5368e17789e8fb3836cad0e4b9fe4f2288afafc28723c04bebc68a18d6"
val otsCache = OtsBlockHeightCache()
@Before val resolver =
fun setup() { OtsResolver(
OtsResolver.ots = OpenTimestamps(OkHttpBlockstreamExplorer(forceProxy = { false }), OkHttpCalendarBuilder(forceProxy = { false })) OkHttpBitcoinExplorer(
} OkHttpBitcoinExplorer.MEMPOOL_API_URL,
client = OkHttpClient.Builder().build(),
otsCache,
),
OkHttpCalendarBuilder { OkHttpClient.Builder().build() },
)
@Test @Test
fun verifyNostrEvent() { fun verifyNostrEvent() {
val ots = EventMapper.fromJson(otsEvent) as OtsEvent val ots = EventMapper.fromJson(otsEvent) as OtsEvent
println(OtsResolver.info(ots.otsByteArray())) println(resolver.info(ots.otsByteArray()))
assertEquals(1707688818L, ots.verify()) assertEquals(1707688818L, ots.verify(resolver))
} }
@Test @Test
fun verifyNostrEvent2() { fun verifyNostrEvent2() {
val ots = EventMapper.fromJson(otsEvent2) as OtsEvent val ots = EventMapper.fromJson(otsEvent2) as OtsEvent
println(OtsResolver.info(ots.otsByteArray())) println(resolver.info(ots.otsByteArray()))
assertEquals(1706322179L, ots.verify()) assertEquals(1706322179L, ots.verify(resolver))
} }
@Test @Test
fun verifyNostrPendingEvent() { fun verifyNostrPendingEvent() {
val ots = EventMapper.fromJson(otsPendingEvent) as OtsEvent val ots = EventMapper.fromJson(otsPendingEvent) as OtsEvent
println(OtsResolver.info(ots.otsByteArray())) println(resolver.info(ots.otsByteArray()))
assertEquals(null, ots.verify()) assertEquals(null, ots.verify(resolver))
} }
@Test @Test
@@ -78,7 +84,7 @@ class OkHttpOtsTest {
val countDownLatch = CountDownLatch(1) val countDownLatch = CountDownLatch(1)
val otsFile = OtsEvent.stamp(otsEvent2Digest) val otsFile = OtsEvent.stamp(otsEvent2Digest, resolver)
signer.sign(OtsEvent.build(otsEvent2Digest, otsFile)) { signer.sign(OtsEvent.build(otsEvent2Digest, otsFile)) {
ots = it ots = it
@@ -88,9 +94,9 @@ class OkHttpOtsTest {
Assert.assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) Assert.assertTrue(countDownLatch.await(1, TimeUnit.SECONDS))
println(ots!!.toJson()) println(ots!!.toJson())
println(OtsResolver.info(ots!!.otsByteArray())) println(resolver.info(ots.otsByteArray()))
// Should not be valid because we need to wait for confirmations // Should not be valid because we need to wait for confirmations
assertEquals(null, ots!!.verify()) assertEquals(null, ots.verify(resolver))
} }
} }
@@ -1,5 +1,5 @@
<resources> <resources>
<string name="app_name_release">Amethyst</string> <string name="app_name">Amethyst</string>
<string name="app_name_debug">Amy Debug</string> <string name="app_name_debug">Amy Debug</string>
<string name="app_name_benchmark">Amy Benchmark</string> <string name="app_name_benchmark">Amy Benchmark</string>
</resources> </resources>
@@ -89,7 +89,9 @@ class PushMessageReceiver : MessagingReceiver() {
Log.d(TAG, "New endpoint provided:- $endpoint for Instance: $instance ${pushHandler.getSavedEndpoint()} $sanitizedEndpoint") Log.d(TAG, "New endpoint provided:- $endpoint for Instance: $instance ${pushHandler.getSavedEndpoint()} $sanitizedEndpoint")
pushHandler.setEndpoint(sanitizedEndpoint) pushHandler.setEndpoint(sanitizedEndpoint)
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
RegisterAccounts(LocalPreferences.allSavedAccounts()).go(sanitizedEndpoint) PushNotificationUtils.checkAndInit(sanitizedEndpoint, LocalPreferences.allSavedAccounts()) {
Amethyst.instance.okHttpClients.getHttpClient(Amethyst.instance.torManager.isSocksReady())
}
notificationManager().getOrCreateZapChannel(appContext) notificationManager().getOrCreateZapChannel(appContext)
notificationManager().getOrCreateDMChannel(appContext) notificationManager().getOrCreateDMChannel(appContext)
} }
@@ -20,28 +20,53 @@
*/ */
package com.vitorpamplona.amethyst.service.notifications package com.vitorpamplona.amethyst.service.notifications
import android.util.Log
import com.vitorpamplona.amethyst.AccountInfo import com.vitorpamplona.amethyst.AccountInfo
import kotlinx.coroutines.CancellationException import com.vitorpamplona.amethyst.service.retryIfException
import kotlinx.coroutines.Dispatchers
import okhttp3.OkHttpClient
object PushNotificationUtils { object PushNotificationUtils {
var hasInit: Boolean = false var lastToken: String? = null
var hasInit: List<AccountInfo>? = null
private val pushHandler = PushDistributorHandler private val pushHandler = PushDistributorHandler
suspend fun init(accounts: List<AccountInfo>) { suspend fun checkAndInit(
if (hasInit) { accounts: List<AccountInfo>,
return okHttpClient: (String) -> OkHttpClient,
} ) = with(Dispatchers.IO) {
try { if (!pushHandler.savedDistributorExists()) return
if (pushHandler.savedDistributorExists()) {
val currentDistributor = PushDistributorHandler.getSavedDistributor()
PushDistributorHandler.saveDistributor(currentDistributor)
RegisterAccounts(accounts).go(pushHandler.getSavedEndpoint()) val currentDistributor = PushDistributorHandler.getSavedDistributor()
} PushDistributorHandler.saveDistributor(currentDistributor)
} catch (e: Exception) { val token = pushHandler.getSavedEndpoint()
if (e is CancellationException) throw e
Log.d("Amethyst-OSSPushUtils", "Failed to get endpoint.") if (hasInit?.equals(accounts) == true && lastToken == token) {
return@with
} }
registerToken(token, accounts, okHttpClient)
}
suspend fun checkAndInit(
token: String,
accounts: List<AccountInfo>,
okHttpClient: (String) -> OkHttpClient,
) = with(Dispatchers.IO) {
// initializes if the accounts are different or if the token has changed
if (hasInit?.equals(accounts) == true && lastToken == token) {
return@with
}
registerToken(token, accounts, okHttpClient)
}
private suspend fun registerToken(
token: String,
accounts: List<AccountInfo>,
okHttpClient: (String) -> OkHttpClient,
) = retryIfException("RegisterAccounts") {
RegisterAccounts(accounts, okHttpClient).go(token)
lastToken = token
hasInit = accounts.toList()
} }
} }
+2 -2
View File
@@ -66,7 +66,7 @@
<activity <activity
android:name=".ui.MainActivity" android:name=".ui.MainActivity"
android:exported="true" android:exported="true"
android:launchMode="singleTop" android:launchMode="singleInstance"
android:windowSoftInputMode="adjustResize" android:windowSoftInputMode="adjustResize"
android:configChanges="orientation|screenSize|screenLayout" android:configChanges="orientation|screenSize|screenLayout"
android:taskAffinity=".service.playback.pip.PipVideoActivity" android:taskAffinity=".service.playback.pip.PipVideoActivity"
@@ -127,7 +127,7 @@
android:autoRemoveFromRecents="true" android:autoRemoveFromRecents="true"
android:configChanges="orientation|screenLayout|screenSize|smallestScreenSize|keyboardHidden|keyboard|uiMode" android:configChanges="orientation|screenLayout|screenSize|smallestScreenSize|keyboardHidden|keyboard|uiMode"
android:supportsPictureInPicture="true" android:supportsPictureInPicture="true"
android:launchMode="singleTask" android:launchMode="singleInstance"
android:exported="false" android:exported="false"
android:resizeableActivity="true" android:resizeableActivity="true"
android:theme="@style/noAnimTheme" android:theme="@style/noAnimTheme"
@@ -21,145 +21,116 @@
package com.vitorpamplona.amethyst package com.vitorpamplona.amethyst
import android.app.Application import android.app.Application
import android.app.PendingIntent
import android.content.ContentResolver import android.content.ContentResolver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Build
import android.os.Looper
import android.os.StrictMode
import android.os.StrictMode.ThreadPolicy
import android.os.StrictMode.VmPolicy
import android.util.Log import android.util.Log
import androidx.core.net.toUri
import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.EncryptedSharedPreferences
import coil3.ImageLoader
import coil3.disk.DiskCache import coil3.disk.DiskCache
import coil3.memory.MemoryCache import coil3.memory.MemoryCache
import com.vitorpamplona.amethyst.service.connectivity.ConnectivityManager
import com.vitorpamplona.amethyst.service.images.ImageCacheFactory
import com.vitorpamplona.amethyst.service.images.ImageLoaderSetup
import com.vitorpamplona.amethyst.service.location.LocationState import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.service.logging.Logging
import com.vitorpamplona.amethyst.service.notifications.PokeyReceiver import com.vitorpamplona.amethyst.service.notifications.PokeyReceiver
import com.vitorpamplona.amethyst.service.okhttp.HttpClientManager import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManager
import com.vitorpamplona.amethyst.service.okhttp.EncryptionKeyCache
import com.vitorpamplona.amethyst.service.okhttp.OkHttpWebSocket import com.vitorpamplona.amethyst.service.okhttp.OkHttpWebSocket
import com.vitorpamplona.amethyst.service.ots.OtsBlockHeightCache
import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCache import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCache
import com.vitorpamplona.amethyst.ui.MainActivity import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCacheFactory
import com.vitorpamplona.amethyst.service.uploads.nip95.Nip95CacheFactory
import com.vitorpamplona.amethyst.ui.tor.TorManager
import com.vitorpamplona.ammolite.relays.NostrClient import com.vitorpamplona.ammolite.relays.NostrClient
import com.vitorpamplona.quartz.nip03Timestamp.VerificationStateCache
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import okio.Path.Companion.toOkioPath
import java.io.File import java.io.File
import kotlin.time.measureTimedValue
class Amethyst : Application() { class Amethyst : Application() {
val applicationIOScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) val appAgent = "Amethyst/${BuildConfig.VERSION_NAME}"
val client: NostrClient = NostrClient(OkHttpWebSocket.BuilderFactory()) val exceptionHandler =
CoroutineExceptionHandler { _, throwable ->
Log.e("AmethystCoroutine", "Caught exception: ${throwable.message}", throwable)
}
// Service Manager is only active when the activity is active. val applicationIOScope = CoroutineScope(Dispatchers.IO + SupervisorJob() + exceptionHandler)
val serviceManager = ServiceManager(client, applicationIOScope)
// Key cache to download and decrypt encrypted files before caching them.
val keyCache = EncryptionKeyCache()
// App services that should be run as soon as there are subscribers to their flows
val locationManager = LocationState(this, applicationIOScope) val locationManager = LocationState(this, applicationIOScope)
val torManager = TorManager(this, applicationIOScope)
val connManager = ConnectivityManager(this, applicationIOScope)
// Service that will run at all times.
val pokeyReceiver = PokeyReceiver() val pokeyReceiver = PokeyReceiver()
val okHttpClients =
DualHttpClientManager(
userAgent = appAgent,
proxyPortProvider = torManager.activePortOrNull,
isMobileDataProvider = connManager.isMobileOrNull,
keyCache = keyCache,
scope = applicationIOScope,
)
val factory =
OkHttpWebSocket.BuilderFactory { _, useProxy ->
okHttpClients.getHttpClient(useProxy)
}
val client: NostrClient = NostrClient(factory)
val serviceManager = ServiceManager(client, applicationIOScope)
val nip95cache: File by lazy { Nip95CacheFactory.new(this) }
val videoCache: VideoCache by lazy { VideoCacheFactory.new(this) }
val diskCache: DiskCache by lazy { ImageCacheFactory.newDisk(this) }
val memoryCache: MemoryCache by lazy { ImageCacheFactory.newMemory(this) }
val otsVerifCache by lazy { VerificationStateCache() }
val otsBlockHeightCache by lazy { OtsBlockHeightCache() }
override fun onCreate() {
super.onCreate()
Log.d("AmethystApp", "onCreate $this")
instance = this
if (isDebug()) {
Logging.setup()
}
// initializes diskcache on an IO thread.
applicationIOScope.launch { videoCache }
// registers to receive events
pokeyReceiver.register(this)
}
override fun onTerminate() { override fun onTerminate() {
super.onTerminate() super.onTerminate()
unregisterReceiver(pokeyReceiver) Log.d("AmethystApp", "onTerminate $this")
applicationIOScope.cancel()
}
fun nip95cache() = safeCacheDir.resolve("NIP95") pokeyReceiver.unregister(this)
applicationIOScope.cancel("Application onTerminate $this")
val videoCache: VideoCache by lazy {
val newCache = VideoCache()
runBlocking {
newCache.initFileCache(
this@Amethyst,
safeCacheDir.resolve("exoplayer"),
)
}
newCache
}
val coilCache: DiskCache by lazy {
DiskCache
.Builder()
.directory(safeCacheDir.resolve("image_cache").toOkioPath())
.maxSizePercent(0.2)
.maximumMaxSizeBytes(1024 * 1024 * 1024) // 1GB
.build()
}
val memoryCache: MemoryCache by lazy {
MemoryCache
.Builder()
.maxSizePercent(this)
.build()
} }
fun contentResolverFn(): ContentResolver = contentResolver fun contentResolverFn(): ContentResolver = contentResolver
override fun onCreate() { fun isDebug() = BuildConfig.DEBUG || BuildConfig.BUILD_TYPE == "benchmark"
super.onCreate()
instance = this fun setImageLoader(shouldUseTor: Boolean?) =
ImageLoaderSetup.setup(this, diskCache, memoryCache, isDebug()) {
HttpClientManager.setDefaultUserAgent("Amethyst/${BuildConfig.VERSION_NAME}") shouldUseTor?.let { okHttpClients.getHttpClient(it) } ?: okHttpClients.getHttpClient(false)
if (BuildConfig.DEBUG || BuildConfig.BUILD_TYPE == "benchmark") {
StrictMode.setThreadPolicy(
ThreadPolicy
.Builder()
.detectAll()
.penaltyLog()
.build(),
)
StrictMode.setVmPolicy(
VmPolicy
.Builder()
.detectAll()
.penaltyLog()
.build(),
)
Looper.getMainLooper().setMessageLogging(LogMonitor())
ChoreographerHelper.start()
} }
GlobalScope.launch(Dispatchers.IO) {
val (value, elapsed) =
measureTimedValue {
// initializes the video cache in a thread
videoCache
}
Log.d("Rendering Metrics", "VideoCache initialized in $elapsed")
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
registerReceiver(
pokeyReceiver,
IntentFilter(PokeyReceiver.POKEY_ACTION),
RECEIVER_EXPORTED,
)
} else {
@Suppress("UnspecifiedRegisterReceiverFlag")
registerReceiver(
pokeyReceiver,
IntentFilter(PokeyReceiver.POKEY_ACTION),
)
}
}
fun imageLoaderBuilder(): ImageLoader.Builder =
ImageLoader
.Builder(this)
.diskCache { coilCache }
.memoryCache { memoryCache }
fun encryptedStorage(npub: String? = null): EncryptedSharedPreferences = EncryptedStorage.preferences(instance, npub) fun encryptedStorage(npub: String? = null): EncryptedSharedPreferences = EncryptedStorage.preferences(instance, npub)
/** /**
@@ -167,32 +138,16 @@ class Amethyst : Application() {
* *
* @param level the memory-related event that was raised. * @param level the memory-related event that was raised.
*/ */
@OptIn(DelicateCoroutinesApi::class)
override fun onTrimMemory(level: Int) { override fun onTrimMemory(level: Int) {
super.onTrimMemory(level) super.onTrimMemory(level)
println("Trim Memory $level") Log.d("AmethystApp", "onTrimMemory $level")
GlobalScope.launch(Dispatchers.Default) { applicationIOScope.launch(Dispatchers.Default) {
println("Trim Memory Inside $level")
serviceManager.trimMemory() serviceManager.trimMemory()
} }
} }
fun createIntent(callbackUri: String): PendingIntent =
PendingIntent.getActivity(
this,
0,
Intent(Intent.ACTION_VIEW, callbackUri.toUri(), this, MainActivity::class.java),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
companion object { companion object {
lateinit var instance: Amethyst lateinit var instance: Amethyst
private set private set
} }
} }
internal val Context.safeCacheDir: File
get() {
val cacheDir = checkNotNull(cacheDir) { "cacheDir == null" }
return cacheDir.apply { mkdirs() }
}
@@ -91,7 +91,7 @@ fun debugState(context: Context) {
Log.d( Log.d(
"STATE DUMP", "STATE DUMP",
"Image Disk Cache ${(Amethyst.instance.coilCache.size) / (1024 * 1024)}/${(Amethyst.instance.coilCache.maxSize) / (1024 * 1024)} MB", "Image Disk Cache ${(Amethyst.instance.diskCache.size) / (1024 * 1024)}/${(Amethyst.instance.diskCache.maxSize) / (1024 * 1024)} MB",
) )
Log.d( Log.d(
"STATE DUMP", "STATE DUMP",
@@ -25,6 +25,7 @@ import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import android.util.Log import android.util.Log
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.core.content.edit
import com.fasterxml.jackson.module.kotlin.readValue import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.amethyst.model.AccountLanguagePreferencesInternal import com.vitorpamplona.amethyst.model.AccountLanguagePreferencesInternal
import com.vitorpamplona.amethyst.model.AccountReactionPreferencesInternal import com.vitorpamplona.amethyst.model.AccountReactionPreferencesInternal
@@ -151,13 +152,13 @@ object LocalPreferences {
if (info == null) { if (info == null) {
currentAccount = null currentAccount = null
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
encryptedPreferences().edit().clear().apply() encryptedPreferences().edit { clear() }
} }
} else if (currentAccount != info.npub) { } else if (currentAccount != info.npub) {
currentAccount = info.npub currentAccount = info.npub
if (!info.isTransient) { if (!info.isTransient) {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
encryptedPreferences().edit().apply { putString(PrefKeys.CURRENT_ACCOUNT, info.npub) }.apply() encryptedPreferences().edit { putString(PrefKeys.CURRENT_ACCOUNT, info.npub) }
} }
} }
} }
@@ -189,7 +190,7 @@ object LocalPreferences {
savedAccounts.emit(migrated) savedAccounts.emit(migrated)
edit().apply { putString(PrefKeys.ALL_ACCOUNT_INFO, EventMapper.mapper.writeValueAsString(savedAccounts.value)) }.apply() edit { putString(PrefKeys.ALL_ACCOUNT_INFO, EventMapper.mapper.writeValueAsString(savedAccounts.value)) }
} }
} }
} }
@@ -206,13 +207,12 @@ object LocalPreferences {
savedAccounts.emit(accounts) savedAccounts.emit(accounts)
encryptedPreferences() encryptedPreferences()
.edit() .edit {
.apply {
putString( putString(
PrefKeys.ALL_ACCOUNT_INFO, PrefKeys.ALL_ACCOUNT_INFO,
EventMapper.mapper.writeValueAsString(accounts.filter { !it.isTransient }), EventMapper.mapper.writeValueAsString(accounts.filter { !it.isTransient }),
) )
}.apply() }
} }
} }
@@ -280,7 +280,7 @@ object LocalPreferences {
suspend fun updatePrefsForLogout(accountInfo: AccountInfo) { suspend fun updatePrefsForLogout(accountInfo: AccountInfo) {
Log.d("LocalPreferences", "Saving to encrypted storage updatePrefsForLogout ${accountInfo.npub}") Log.d("LocalPreferences", "Saving to encrypted storage updatePrefsForLogout ${accountInfo.npub}")
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
encryptedPreferences(accountInfo.npub).edit().clear().commit() encryptedPreferences(accountInfo.npub).edit(commit = true) { clear() }
removeAccount(accountInfo) removeAccount(accountInfo)
deleteUserPreferenceFile(accountInfo.npub) deleteUserPreferenceFile(accountInfo.npub)
@@ -304,142 +304,140 @@ object LocalPreferences {
if (!settings.transientAccount) { if (!settings.transientAccount) {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val prefs = encryptedPreferences(settings.keyPair.pubKey.toNpub()) val prefs = encryptedPreferences(settings.keyPair.pubKey.toNpub())
prefs prefs.edit {
.edit() putBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, settings.externalSignerPackageName != null)
.apply { if (settings.externalSignerPackageName != null) {
putBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, settings.externalSignerPackageName != null) remove(PrefKeys.NOSTR_PRIVKEY)
if (settings.externalSignerPackageName != null) { putString(PrefKeys.SIGNER_PACKAGE_NAME, settings.externalSignerPackageName)
remove(PrefKeys.NOSTR_PRIVKEY) } else {
putString(PrefKeys.SIGNER_PACKAGE_NAME, settings.externalSignerPackageName) remove(PrefKeys.SIGNER_PACKAGE_NAME)
} else { settings.keyPair.privKey?.let { putString(PrefKeys.NOSTR_PRIVKEY, it.toHexKey()) }
remove(PrefKeys.SIGNER_PACKAGE_NAME) }
settings.keyPair.privKey?.let { putString(PrefKeys.NOSTR_PRIVKEY, it.toHexKey()) } settings.keyPair.pubKey.let { putString(PrefKeys.NOSTR_PUBKEY, it.toHexKey()) }
} putString(PrefKeys.RELAYS, EventMapper.mapper.writeValueAsString(settings.localRelays))
settings.keyPair.pubKey.let { putString(PrefKeys.NOSTR_PUBKEY, it.toHexKey()) }
putString(PrefKeys.RELAYS, EventMapper.mapper.writeValueAsString(settings.localRelays))
putString(
PrefKeys.DEFAULT_FILE_SERVER,
EventMapper.mapper.writeValueAsString(settings.defaultFileServer),
)
putString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, settings.defaultHomeFollowList.value)
putString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, settings.defaultStoriesFollowList.value)
putString(
PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST,
settings.defaultNotificationFollowList.value,
)
putString(
PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST,
settings.defaultDiscoveryFollowList.value,
)
putString(
PrefKeys.ZAP_PAYMENT_REQUEST_SERVER,
EventMapper.mapper.writeValueAsString(settings.zapPaymentRequest),
)
if (settings.backupContactList != null) {
putString( putString(
PrefKeys.DEFAULT_FILE_SERVER, PrefKeys.LATEST_CONTACT_LIST,
EventMapper.mapper.writeValueAsString(settings.defaultFileServer), EventMapper.mapper.writeValueAsString(settings.backupContactList),
) )
putString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, settings.defaultHomeFollowList.value) } else {
putString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, settings.defaultStoriesFollowList.value) remove(PrefKeys.LATEST_CONTACT_LIST)
}
if (settings.backupUserMetadata != null) {
putString( putString(
PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, PrefKeys.LATEST_USER_METADATA,
settings.defaultNotificationFollowList.value, EventMapper.mapper.writeValueAsString(settings.backupUserMetadata),
) )
} else {
remove(PrefKeys.LATEST_USER_METADATA)
}
if (settings.backupDMRelayList != null) {
putString( putString(
PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, PrefKeys.LATEST_DM_RELAY_LIST,
settings.defaultDiscoveryFollowList.value, EventMapper.mapper.writeValueAsString(settings.backupDMRelayList),
) )
} else {
remove(PrefKeys.LATEST_DM_RELAY_LIST)
}
if (settings.backupNIP65RelayList != null) {
putString( putString(
PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, PrefKeys.LATEST_NIP65_RELAY_LIST,
EventMapper.mapper.writeValueAsString(settings.zapPaymentRequest), EventMapper.mapper.writeValueAsString(settings.backupNIP65RelayList),
) )
if (settings.backupContactList != null) { } else {
putString( remove(PrefKeys.LATEST_NIP65_RELAY_LIST)
PrefKeys.LATEST_CONTACT_LIST, }
EventMapper.mapper.writeValueAsString(settings.backupContactList),
)
} else {
remove(PrefKeys.LATEST_CONTACT_LIST)
}
if (settings.backupUserMetadata != null) {
putString(
PrefKeys.LATEST_USER_METADATA,
EventMapper.mapper.writeValueAsString(settings.backupUserMetadata),
)
} else {
remove(PrefKeys.LATEST_USER_METADATA)
}
if (settings.backupDMRelayList != null) {
putString(
PrefKeys.LATEST_DM_RELAY_LIST,
EventMapper.mapper.writeValueAsString(settings.backupDMRelayList),
)
} else {
remove(PrefKeys.LATEST_DM_RELAY_LIST)
}
if (settings.backupNIP65RelayList != null) {
putString(
PrefKeys.LATEST_NIP65_RELAY_LIST,
EventMapper.mapper.writeValueAsString(settings.backupNIP65RelayList),
)
} else {
remove(PrefKeys.LATEST_NIP65_RELAY_LIST)
}
if (settings.backupSearchRelayList != null) {
putString(
PrefKeys.LATEST_SEARCH_RELAY_LIST,
EventMapper.mapper.writeValueAsString(settings.backupSearchRelayList),
)
} else {
remove(PrefKeys.LATEST_SEARCH_RELAY_LIST)
}
if (settings.localRelayServers.isNotEmpty()) {
putStringSet(PrefKeys.LOCAL_RELAY_SERVERS, settings.localRelayServers)
} else {
remove(PrefKeys.LOCAL_RELAY_SERVERS)
}
if (settings.backupMuteList != null) {
putString(
PrefKeys.LATEST_MUTE_LIST,
EventMapper.mapper.writeValueAsString(settings.backupMuteList),
)
} else {
remove(PrefKeys.LATEST_MUTE_LIST)
}
if (settings.backupPrivateHomeRelayList != null) {
putString(
PrefKeys.LATEST_PRIVATE_HOME_RELAY_LIST,
EventMapper.mapper.writeValueAsString(settings.backupPrivateHomeRelayList),
)
} else {
remove(PrefKeys.LATEST_PRIVATE_HOME_RELAY_LIST)
}
if (settings.backupAppSpecificData != null) {
putString(
PrefKeys.LATEST_APP_SPECIFIC_DATA,
EventMapper.mapper.writeValueAsString(settings.backupAppSpecificData),
)
} else {
remove(PrefKeys.LATEST_APP_SPECIFIC_DATA)
}
putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, settings.hideDeleteRequestDialog)
putBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, settings.hideNIP17WarningDialog)
putBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, settings.hideBlockAlertDialog)
// migrating from previous design
remove(PrefKeys.USE_PROXY)
remove(PrefKeys.PROXY_PORT)
putString(PrefKeys.TOR_SETTINGS, EventMapper.mapper.writeValueAsString(settings.torSettings.toSettings()))
val regularMap =
settings.lastReadPerRoute.value.mapValues {
it.value.value
}
if (settings.backupSearchRelayList != null) {
putString( putString(
PrefKeys.LAST_READ_PER_ROUTE, PrefKeys.LATEST_SEARCH_RELAY_LIST,
EventMapper.mapper.writeValueAsString(regularMap), EventMapper.mapper.writeValueAsString(settings.backupSearchRelayList),
) )
putStringSet(PrefKeys.HAS_DONATED_IN_VERSION, settings.hasDonatedInVersion.value) } else {
remove(PrefKeys.LATEST_SEARCH_RELAY_LIST)
}
if (settings.localRelayServers.isNotEmpty()) {
putStringSet(PrefKeys.LOCAL_RELAY_SERVERS, settings.localRelayServers)
} else {
remove(PrefKeys.LOCAL_RELAY_SERVERS)
}
if (settings.backupMuteList != null) {
putString( putString(
PrefKeys.PENDING_ATTESTATIONS, PrefKeys.LATEST_MUTE_LIST,
EventMapper.mapper.writeValueAsString(settings.pendingAttestations.value), EventMapper.mapper.writeValueAsString(settings.backupMuteList),
) )
}.apply() } else {
remove(PrefKeys.LATEST_MUTE_LIST)
}
if (settings.backupPrivateHomeRelayList != null) {
putString(
PrefKeys.LATEST_PRIVATE_HOME_RELAY_LIST,
EventMapper.mapper.writeValueAsString(settings.backupPrivateHomeRelayList),
)
} else {
remove(PrefKeys.LATEST_PRIVATE_HOME_RELAY_LIST)
}
if (settings.backupAppSpecificData != null) {
putString(
PrefKeys.LATEST_APP_SPECIFIC_DATA,
EventMapper.mapper.writeValueAsString(settings.backupAppSpecificData),
)
} else {
remove(PrefKeys.LATEST_APP_SPECIFIC_DATA)
}
putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, settings.hideDeleteRequestDialog)
putBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, settings.hideNIP17WarningDialog)
putBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, settings.hideBlockAlertDialog)
// migrating from previous design
remove(PrefKeys.USE_PROXY)
remove(PrefKeys.PROXY_PORT)
putString(PrefKeys.TOR_SETTINGS, EventMapper.mapper.writeValueAsString(settings.torSettings.toSettings()))
val regularMap =
settings.lastReadPerRoute.value.mapValues {
it.value.value
}
putString(
PrefKeys.LAST_READ_PER_ROUTE,
EventMapper.mapper.writeValueAsString(regularMap),
)
putStringSet(PrefKeys.HAS_DONATED_IN_VERSION, settings.hasDonatedInVersion.value)
putString(
PrefKeys.PENDING_ATTESTATIONS,
EventMapper.mapper.writeValueAsString(settings.pendingAttestations.value),
)
}
} }
} }
Log.d("LocalPreferences", "Saved to encrypted storage") Log.d("LocalPreferences", "Saved to encrypted storage")
@@ -501,161 +499,163 @@ object LocalPreferences {
private suspend fun innerLoadCurrentAccountFromEncryptedStorage(npub: String?): AccountSettings? { private suspend fun innerLoadCurrentAccountFromEncryptedStorage(npub: String?): AccountSettings? {
Log.d("LocalPreferences", "Load account from file $npub") Log.d("LocalPreferences", "Load account from file $npub")
val result =
withContext(Dispatchers.IO) {
checkNotInMainThread()
return withContext(Dispatchers.IO) { return@withContext with(encryptedPreferences(npub)) {
checkNotInMainThread() val privKey = getString(PrefKeys.NOSTR_PRIVKEY, null)
val pubKey = getString(PrefKeys.NOSTR_PUBKEY, null) ?: return@with null
val externalSignerPackageName =
getString(PrefKeys.SIGNER_PACKAGE_NAME, null)
?: if (getBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, false)) "com.greenart7c3.nostrsigner" else null
return@withContext with(encryptedPreferences(npub)) { val defaultHomeFollowList =
val privKey = getString(PrefKeys.NOSTR_PRIVKEY, null) getString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, null) ?: KIND3_FOLLOWS
val pubKey = getString(PrefKeys.NOSTR_PUBKEY, null) ?: return@with null val defaultStoriesFollowList =
val externalSignerPackageName = getString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS
getString(PrefKeys.SIGNER_PACKAGE_NAME, null) val defaultNotificationFollowList =
?: if (getBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, false)) "com.greenart7c3.nostrsigner" else null getString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS
val defaultDiscoveryFollowList =
getString(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS
val defaultHomeFollowList = val defaultZapType =
getString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, null) ?: KIND3_FOLLOWS getString(PrefKeys.DEFAULT_ZAPTYPE, "")?.let { serverName ->
val defaultStoriesFollowList = LnZapEvent.ZapType.entries.firstOrNull { it.name == serverName }
getString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS } ?: LnZapEvent.ZapType.PUBLIC
val defaultNotificationFollowList =
getString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS
val defaultDiscoveryFollowList =
getString(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS
val defaultZapType = val localRelays = parseOrNull<Set<RelaySetupInfo>>(PrefKeys.RELAYS) ?: emptySet()
getString(PrefKeys.DEFAULT_ZAPTYPE, "")?.let { serverName ->
LnZapEvent.ZapType.entries.firstOrNull { it.name == serverName }
} ?: LnZapEvent.ZapType.PUBLIC
val localRelays = parseOrNull<Set<RelaySetupInfo>>(PrefKeys.RELAYS) ?: emptySet() val zapPaymentRequestServer = parseOrNull<Nip47WalletConnect.Nip47URI>(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER)
val defaultFileServer = parseOrNull<ServerName>(PrefKeys.DEFAULT_FILE_SERVER) ?: DEFAULT_MEDIA_SERVERS[0]
val zapPaymentRequestServer = parseOrNull<Nip47WalletConnect.Nip47URI>(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER) val pendingAttestations = parseOrNull<Map<HexKey, String>>(PrefKeys.PENDING_ATTESTATIONS) ?: mapOf()
val defaultFileServer = parseOrNull<ServerName>(PrefKeys.DEFAULT_FILE_SERVER) ?: DEFAULT_MEDIA_SERVERS[0] val localRelayServers = getStringSet(PrefKeys.LOCAL_RELAY_SERVERS, null) ?: setOf()
val pendingAttestations = parseOrNull<Map<HexKey, String>>(PrefKeys.PENDING_ATTESTATIONS) ?: mapOf() val latestUserMetadata = parseEventOrNull<MetadataEvent>(PrefKeys.LATEST_USER_METADATA)
val localRelayServers = getStringSet(PrefKeys.LOCAL_RELAY_SERVERS, null) ?: setOf() val latestContactList = parseEventOrNull<ContactListEvent>(PrefKeys.LATEST_CONTACT_LIST)
val latestDmRelayList = parseEventOrNull<ChatMessageRelayListEvent>(PrefKeys.LATEST_DM_RELAY_LIST)
val latestNip65RelayList = parseEventOrNull<AdvertisedRelayListEvent>(PrefKeys.LATEST_NIP65_RELAY_LIST)
val latestSearchRelayList = parseEventOrNull<SearchRelayListEvent>(PrefKeys.LATEST_SEARCH_RELAY_LIST)
val latestMuteList = parseEventOrNull<MuteListEvent>(PrefKeys.LATEST_MUTE_LIST)
val latestPrivateHomeRelayList = parseEventOrNull<PrivateOutboxRelayListEvent>(PrefKeys.LATEST_PRIVATE_HOME_RELAY_LIST)
val latestAppSpecificData = parseEventOrNull<AppSpecificDataEvent>(PrefKeys.LATEST_APP_SPECIFIC_DATA)
val latestUserMetadata = parseEventOrNull<MetadataEvent>(PrefKeys.LATEST_USER_METADATA) val syncedSettings =
val latestContactList = parseEventOrNull<ContactListEvent>(PrefKeys.LATEST_CONTACT_LIST) if (latestAppSpecificData != null) {
val latestDmRelayList = parseEventOrNull<ChatMessageRelayListEvent>(PrefKeys.LATEST_DM_RELAY_LIST) null
val latestNip65RelayList = parseEventOrNull<AdvertisedRelayListEvent>(PrefKeys.LATEST_NIP65_RELAY_LIST) } else {
val latestSearchRelayList = parseEventOrNull<SearchRelayListEvent>(PrefKeys.LATEST_SEARCH_RELAY_LIST) // previous version. Delete this when ready.
val latestMuteList = parseEventOrNull<MuteListEvent>(PrefKeys.LATEST_MUTE_LIST) val reactionChoices = parseOrNull<List<String>>(PrefKeys.REACTION_CHOICES)?.ifEmpty { DefaultReactions } ?: DefaultReactions
val latestPrivateHomeRelayList = parseEventOrNull<PrivateOutboxRelayListEvent>(PrefKeys.LATEST_PRIVATE_HOME_RELAY_LIST) val zapAmountChoices = parseOrNull<List<Long>>(PrefKeys.ZAP_AMOUNTS)?.ifEmpty { DefaultZapAmounts } ?: DefaultZapAmounts
val latestAppSpecificData = parseEventOrNull<AppSpecificDataEvent>(PrefKeys.LATEST_APP_SPECIFIC_DATA)
val syncedSettings = val languagePreferences = parseOrNull<Map<String, String>>(PrefKeys.LANGUAGE_PREFS) ?: mapOf()
if (latestAppSpecificData != null) {
null
} else {
// previous version. Delete this when ready.
val reactionChoices = parseOrNull<List<String>>(PrefKeys.REACTION_CHOICES)?.ifEmpty { DefaultReactions } ?: DefaultReactions
val zapAmountChoices = parseOrNull<List<Long>>(PrefKeys.ZAP_AMOUNTS)?.ifEmpty { DefaultZapAmounts } ?: DefaultZapAmounts
val languagePreferences = parseOrNull<Map<String, String>>(PrefKeys.LANGUAGE_PREFS) ?: mapOf() val showSensitiveContent =
if (contains(PrefKeys.SHOW_SENSITIVE_CONTENT)) {
getBoolean(PrefKeys.SHOW_SENSITIVE_CONTENT, false)
} else {
null
}
val filterSpam = getBoolean(PrefKeys.FILTER_SPAM_FROM_STRANGERS, true)
val warnAboutReports = getBoolean(PrefKeys.WARN_ABOUT_REPORTS, true)
val showSensitiveContent = val dontTranslateFrom = getStringSet(PrefKeys.DONT_TRANSLATE_FROM, null) ?: setOf()
if (contains(PrefKeys.SHOW_SENSITIVE_CONTENT)) { val translateTo = getString(PrefKeys.TRANSLATE_TO, null) ?: Locale.getDefault().language
getBoolean(PrefKeys.SHOW_SENSITIVE_CONTENT, false)
} else {
null
}
val filterSpam = getBoolean(PrefKeys.FILTER_SPAM_FROM_STRANGERS, true)
val warnAboutReports = getBoolean(PrefKeys.WARN_ABOUT_REPORTS, true)
val dontTranslateFrom = getStringSet(PrefKeys.DONT_TRANSLATE_FROM, null) ?: setOf() AccountSyncedSettingsInternal(
val translateTo = getString(PrefKeys.TRANSLATE_TO, null) ?: Locale.getDefault().language reactions =
AccountReactionPreferencesInternal(
reactionChoices = reactionChoices,
),
zaps =
AccountZapPreferencesInternal(
zapAmountChoices = zapAmountChoices,
defaultZapType = defaultZapType,
),
languages =
AccountLanguagePreferencesInternal(
dontTranslateFrom = dontTranslateFrom,
languagePreferences = languagePreferences,
translateTo = translateTo,
),
security =
AccountSecurityPreferencesInternal(
showSensitiveContent = showSensitiveContent,
warnAboutPostsWithReports = warnAboutReports,
filterSpamFromStrangers = filterSpam,
),
)
}
AccountSyncedSettingsInternal( val hideDeleteRequestDialog = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false)
reactions = val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false)
AccountReactionPreferencesInternal( val hideNIP17WarningDialog = getBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, false)
reactionChoices = reactionChoices, val useProxy = getBoolean(PrefKeys.USE_PROXY, false)
),
zaps =
AccountZapPreferencesInternal(
zapAmountChoices = zapAmountChoices,
defaultZapType = defaultZapType,
),
languages =
AccountLanguagePreferencesInternal(
dontTranslateFrom = dontTranslateFrom,
languagePreferences = languagePreferences,
translateTo = translateTo,
),
security =
AccountSecurityPreferencesInternal(
showSensitiveContent = showSensitiveContent,
warnAboutPostsWithReports = warnAboutReports,
filterSpamFromStrangers = filterSpam,
),
)
}
val hideDeleteRequestDialog = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false) val torSettings =
val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false) if (useProxy) {
val hideNIP17WarningDialog = getBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, false) // old settings, means Orbot
val useProxy = getBoolean(PrefKeys.USE_PROXY, false) TorSettings(
TorType.EXTERNAL,
getInt(PrefKeys.PROXY_PORT, 9050),
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
)
} else {
parseOrNull<TorSettings>(PrefKeys.TOR_SETTINGS) ?: TorSettings()
}
val torSettings = val lastReadPerRoute =
if (useProxy) { parseOrNull<Map<String, Long>>(PrefKeys.LAST_READ_PER_ROUTE)?.mapValues {
// old settings, means Orbot MutableStateFlow(it.value)
TorSettings( } ?: mapOf()
TorType.EXTERNAL,
getInt(PrefKeys.PROXY_PORT, 9050),
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
)
} else {
parseOrNull<TorSettings>(PrefKeys.TOR_SETTINGS) ?: TorSettings()
}
val lastReadPerRoute = val keyPair = KeyPair(privKey = privKey?.hexToByteArray(), pubKey = pubKey.hexToByteArray())
parseOrNull<Map<String, Long>>(PrefKeys.LAST_READ_PER_ROUTE)?.mapValues { val hasDonatedInVersion = getStringSet(PrefKeys.HAS_DONATED_IN_VERSION, null) ?: setOf()
MutableStateFlow(it.value)
} ?: mapOf()
val keyPair = KeyPair(privKey = privKey?.hexToByteArray(), pubKey = pubKey.hexToByteArray()) return@with AccountSettings(
val hasDonatedInVersion = getStringSet(PrefKeys.HAS_DONATED_IN_VERSION, null) ?: setOf() keyPair = keyPair,
transientAccount = false,
return@with AccountSettings( externalSignerPackageName = externalSignerPackageName,
keyPair = keyPair, localRelays = localRelays,
transientAccount = false, localRelayServers = localRelayServers,
externalSignerPackageName = externalSignerPackageName, defaultFileServer = defaultFileServer,
localRelays = localRelays, defaultHomeFollowList = MutableStateFlow(defaultHomeFollowList),
localRelayServers = localRelayServers, defaultStoriesFollowList = MutableStateFlow(defaultStoriesFollowList),
defaultFileServer = defaultFileServer, defaultNotificationFollowList = MutableStateFlow(defaultNotificationFollowList),
defaultHomeFollowList = MutableStateFlow(defaultHomeFollowList), defaultDiscoveryFollowList = MutableStateFlow(defaultDiscoveryFollowList),
defaultStoriesFollowList = MutableStateFlow(defaultStoriesFollowList), zapPaymentRequest = zapPaymentRequestServer,
defaultNotificationFollowList = MutableStateFlow(defaultNotificationFollowList), hideDeleteRequestDialog = hideDeleteRequestDialog,
defaultDiscoveryFollowList = MutableStateFlow(defaultDiscoveryFollowList), hideBlockAlertDialog = hideBlockAlertDialog,
zapPaymentRequest = zapPaymentRequestServer, hideNIP17WarningDialog = hideNIP17WarningDialog,
hideDeleteRequestDialog = hideDeleteRequestDialog, backupUserMetadata = latestUserMetadata,
hideBlockAlertDialog = hideBlockAlertDialog, backupContactList = latestContactList,
hideNIP17WarningDialog = hideNIP17WarningDialog, backupNIP65RelayList = latestNip65RelayList,
backupUserMetadata = latestUserMetadata, backupDMRelayList = latestDmRelayList,
backupContactList = latestContactList, backupSearchRelayList = latestSearchRelayList,
backupNIP65RelayList = latestNip65RelayList, backupPrivateHomeRelayList = latestPrivateHomeRelayList,
backupDMRelayList = latestDmRelayList, backupMuteList = latestMuteList,
backupSearchRelayList = latestSearchRelayList, backupAppSpecificData = latestAppSpecificData,
backupPrivateHomeRelayList = latestPrivateHomeRelayList, backupSyncedSettings = syncedSettings,
backupMuteList = latestMuteList, torSettings = TorSettingsFlow.build(torSettings),
backupAppSpecificData = latestAppSpecificData, lastReadPerRoute = MutableStateFlow(lastReadPerRoute),
backupSyncedSettings = syncedSettings, hasDonatedInVersion = MutableStateFlow(hasDonatedInVersion),
torSettings = TorSettingsFlow.build(torSettings), pendingAttestations = MutableStateFlow(pendingAttestations),
lastReadPerRoute = MutableStateFlow(lastReadPerRoute), )
hasDonatedInVersion = MutableStateFlow(hasDonatedInVersion), }
pendingAttestations = MutableStateFlow(pendingAttestations),
)
} }
} Log.d("LocalPreferences", "Loaded account from file $npub")
return result
} }
private inline fun <reified T> SharedPreferences.parseOrNull(key: String): T? { private inline fun <reified T> SharedPreferences.parseOrNull(key: String): T? {
@@ -20,21 +20,11 @@
*/ */
package com.vitorpamplona.amethyst package com.vitorpamplona.amethyst
import android.os.Build
import android.util.Log import android.util.Log
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import coil3.SingletonImageLoader
import coil3.annotation.DelicateCoilApi import coil3.annotation.DelicateCoilApi
import coil3.gif.AnimatedImageDecoder
import coil3.gif.GifDecoder
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import coil3.size.Precision
import coil3.svg.SvgDecoder
import coil3.util.DebugLogger
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.Base64Fetcher
import com.vitorpamplona.amethyst.service.BlurHashFetcher
import com.vitorpamplona.amethyst.service.NostrAccountDataSource import com.vitorpamplona.amethyst.service.NostrAccountDataSource
import com.vitorpamplona.amethyst.service.NostrChannelDataSource import com.vitorpamplona.amethyst.service.NostrChannelDataSource
import com.vitorpamplona.amethyst.service.NostrChatroomDataSource import com.vitorpamplona.amethyst.service.NostrChatroomDataSource
@@ -51,17 +41,10 @@ import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
import com.vitorpamplona.amethyst.service.NostrThreadDataSource import com.vitorpamplona.amethyst.service.NostrThreadDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
import com.vitorpamplona.amethyst.service.NostrVideoDataSource import com.vitorpamplona.amethyst.service.NostrVideoDataSource
import com.vitorpamplona.amethyst.service.okhttp.HttpClientManager import com.vitorpamplona.amethyst.service.eventCache.MemoryTrimmingService
import com.vitorpamplona.amethyst.service.ots.OkHttpBlockstreamExplorer
import com.vitorpamplona.amethyst.service.ots.OkHttpCalendarBuilder
import com.vitorpamplona.amethyst.ui.tor.TorManager
import com.vitorpamplona.amethyst.ui.tor.TorType
import com.vitorpamplona.ammolite.relays.NostrClient import com.vitorpamplona.ammolite.relays.NostrClient
import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip03Timestamp.OtsResolver
import com.vitorpamplona.quartz.nip03Timestamp.ots.OpenTimestamps
import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -71,7 +54,6 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import java.util.concurrent.atomic.AtomicBoolean
@Stable @Stable
class ServiceManager( class ServiceManager(
@@ -85,7 +67,7 @@ class ServiceManager(
private var collectorJob: Job? = null private var collectorJob: Job? = null
var isTrimmingMemoryMutex = AtomicBoolean(false) private val trimmingService = MemoryTrimmingService()
private fun start(account: Account) { private fun start(account: Account) {
this.account = account this.account = account
@@ -94,87 +76,17 @@ class ServiceManager(
@OptIn(DelicateCoilApi::class) @OptIn(DelicateCoilApi::class)
private fun start() { private fun start() {
Log.d("ServiceManager", "Pre Starting Relay Services $isStarted $account") Log.d("ServiceManager", "-- May Start (hasStarted: $isStarted) for account $account")
if (isStarted && account != null) { if (isStarted && account != null) {
Log.d("ServiceManager", "---- Restarting innactive relay Services with Tor: ${account?.settings?.torSettings?.torType?.value}")
client.reconnect()
return return
} }
Log.d("ServiceManager", "Starting Relay Services Tor: ${account?.settings?.torSettings?.torType?.value}") Log.d("ServiceManager", "---- Starting Relay Services with Tor: ${account?.settings?.torSettings?.torType?.value}")
val myAccount = account val myAccount = account
// Resets Proxy Use Amethyst.instance.setImageLoader(myAccount?.shouldUseTorForImageDownload())
if (myAccount != null) {
when (myAccount.settings.torSettings.torType.value) {
TorType.INTERNAL -> {
// Tor's lib will automatically set this port.
if (TorManager.isSocksReady()) {
HttpClientManager.setDefaultProxyOnPort(TorManager.socksPort())
} else {
HttpClientManager.setProxyNotReady()
}
}
TorType.EXTERNAL -> {
val port = myAccount.settings.torSettings.externalSocksPort.value
if (port > 0) {
HttpClientManager.setDefaultProxyOnPort(port)
} else {
HttpClientManager.setProxyNotReady()
}
}
else -> HttpClientManager.setDefaultProxy(null)
}
OtsResolver.ots =
OpenTimestamps(
OkHttpBlockstreamExplorer(myAccount::shouldUseTorForMoneyOperations),
OkHttpCalendarBuilder(myAccount::shouldUseTorForMoneyOperations),
)
} else {
OtsResolver.ots =
OpenTimestamps(
OkHttpBlockstreamExplorer { false },
OkHttpCalendarBuilder { false },
)
HttpClientManager.setDefaultProxy(null)
}
// Convert this into a flow
LocalCache.antiSpam.active = account
?.settings
?.syncedSettings
?.security
?.filterSpamFromStrangers ?: true
SingletonImageLoader.setUnsafe {
Amethyst.instance
.imageLoaderBuilder()
.components {
if (Build.VERSION.SDK_INT >= 28) {
add(AnimatedImageDecoder.Factory())
} else {
add(GifDecoder.Factory())
}
add(SvgDecoder.Factory())
add(Base64Fetcher.Factory)
add(BlurHashFetcher.Factory)
add(Base64Fetcher.BKeyer)
add(BlurHashFetcher.BKeyer)
add(
OkHttpNetworkFetcherFactory(
callFactory = {
myAccount?.shouldUseTorForImageDownload()?.let { HttpClientManager.getHttpClient(it) }
?: HttpClientManager.getHttpClient(false)
},
),
)
}.apply {
if (BuildConfig.DEBUG || BuildConfig.BUILD_TYPE == "benchmark") {
this.logger(DebugLogger())
}
}.precision(Precision.INEXACT)
.build()
}
if (myAccount != null) { if (myAccount != null) {
val relaySet = myAccount.connectToRelaysWithProxy.value val relaySet = myAccount.connectToRelaysWithProxy.value
@@ -230,7 +142,7 @@ class ServiceManager(
} }
private fun pause() { private fun pause() {
Log.d("ServiceManager", "Pausing Relay Services") Log.d("ServiceManager", "-- Pausing Relay Services")
collectorJob?.cancel() collectorJob?.cancel()
collectorJob = null collectorJob = null
@@ -262,27 +174,7 @@ class ServiceManager(
} }
suspend fun trimMemory() { suspend fun trimMemory() {
if (isTrimmingMemoryMutex.compareAndSet(false, true)) { trimmingService.run(account)
try {
LocalCache.cleanObservers()
val accounts =
LocalPreferences.allSavedAccounts().mapNotNull { decodePublicKeyAsHexOrNull(it.npub) }.toSet()
account?.let {
LocalCache.pruneOldAndHiddenMessages(it)
NostrChatroomDataSource.clearEOSEs(it)
LocalCache.pruneHiddenMessages(it)
LocalCache.pruneContactLists(accounts)
LocalCache.pruneRepliesAndReactions(accounts)
LocalCache.prunePastVersionsOfReplaceables()
LocalCache.pruneExpiredEvents()
}
} finally {
isTrimmingMemoryMutex.getAndSet(false)
}
}
} }
// This method keeps the pause/start in a Syncronized block to // This method keeps the pause/start in a Syncronized block to
@@ -293,6 +185,7 @@ class ServiceManager(
start: Boolean = true, start: Boolean = true,
pause: Boolean = true, pause: Boolean = true,
) { ) {
Log.d("ServiceManager", "-- Force Restart (start:$start) (pause:$pause) for $account")
if (pause) { if (pause) {
pause() pause()
} }
@@ -306,25 +199,25 @@ class ServiceManager(
} }
} }
fun restartIfDifferentAccount(account: Account) { fun setAccountAndRestart(account: Account) {
if (this.account != account) { forceRestart(account, true, true)
forceRestart(account, true, true)
}
} }
fun forceRestart() { fun forceRestart() {
forceRestart(null, true, true) forceRestart(null, true, true)
} }
fun justStart() { fun justStartIfItHasAccount() {
forceRestart(null, true, false) if (account != null) {
forceRestart(null, true, false)
}
} }
fun pauseForGood() { fun pauseForGood() {
forceRestart(null, false, true) forceRestart(null, false, true)
} }
fun pauseForGoodAndClearAccount() { fun pauseAndLogOff() {
account = null account = null
forceRestart(null, false, true) forceRestart(null, false, true)
} }
@@ -36,6 +36,7 @@ import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.service.NostrLnZapPaymentResponseDataSource import com.vitorpamplona.amethyst.service.NostrLnZapPaymentResponseDataSource
import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.location.LocationState import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.service.ots.OtsResolverBuilder
import com.vitorpamplona.amethyst.service.uploads.FileHeader import com.vitorpamplona.amethyst.service.uploads.FileHeader
import com.vitorpamplona.amethyst.tryAndWait import com.vitorpamplona.amethyst.tryAndWait
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
@@ -102,6 +103,7 @@ import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip02FollowList.ReadWrite import com.vitorpamplona.quartz.nip02FollowList.ReadWrite
import com.vitorpamplona.quartz.nip02FollowList.tags.ContactTag import com.vitorpamplona.quartz.nip02FollowList.tags.ContactTag
import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent
import com.vitorpamplona.quartz.nip03Timestamp.OtsResolver
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip04Dm.messages.reply import com.vitorpamplona.quartz.nip04Dm.messages.reply
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
@@ -1217,7 +1219,7 @@ class Account(
filterSpam: Boolean, filterSpam: Boolean,
): Boolean { ): Boolean {
if (settings.updateOptOutOptions(warnReports, filterSpam)) { if (settings.updateOptOutOptions(warnReports, filterSpam)) {
if (!settings.syncedSettings.security.filterSpamFromStrangers) { if (!settings.syncedSettings.security.filterSpamFromStrangers.value) {
transientHiddenUsers.update { transientHiddenUsers.update {
emptySet() emptySet()
} }
@@ -1769,8 +1771,10 @@ class Account(
suspend fun updateAttestations() { suspend fun updateAttestations() {
Log.d("Pending Attestations", "Updating ${settings.pendingAttestations.value.size} pending attestations") Log.d("Pending Attestations", "Updating ${settings.pendingAttestations.value.size} pending attestations")
val otsResolver = otsResolver()
settings.pendingAttestations.value.forEach { pair -> settings.pendingAttestations.value.forEach { pair ->
val otsState = OtsEvent.upgrade(Base64.getDecoder().decode(pair.value), pair.key) val otsState = OtsEvent.upgrade(Base64.getDecoder().decode(pair.value), pair.key, otsResolver)
if (otsState != null) { if (otsState != null) {
val hint = LocalCache.getNoteIfExists(pair.key)?.toEventHint<Event>() val hint = LocalCache.getNoteIfExists(pair.key)?.toEventHint<Event>()
@@ -1804,8 +1808,9 @@ class Account(
if (note.isDraft()) return if (note.isDraft()) return
val id = note.event?.id ?: note.idHex val id = note.event?.id ?: note.idHex
val otsResolver = otsResolver()
settings.addPendingAttestation(id, Base64.getEncoder().encodeToString(OtsEvent.stamp(id))) settings.addPendingAttestation(id, Base64.getEncoder().encodeToString(OtsEvent.stamp(id, otsResolver)))
} }
fun follow(user: User) { fun follow(user: User) {
@@ -3770,6 +3775,13 @@ class Account(
fun isTrustedRelay(url: String): Boolean = connectToRelays.value.any { it.url == url } || url == settings.zapPaymentRequest?.relayUri fun isTrustedRelay(url: String): Boolean = connectToRelays.value.any { it.url == url } || url == settings.zapPaymentRequest?.relayUri
fun otsResolver(): OtsResolver =
OtsResolverBuilder().build(
Amethyst.instance.okHttpClients,
::shouldUseTorForMoneyOperations,
Amethyst.instance.otsBlockHeightCache,
)
init { init {
Log.d("AccountRegisterObservers", "Init") Log.d("AccountRegisterObservers", "Init")
settings.backupContactList?.let { settings.backupContactList?.let {
@@ -122,13 +122,13 @@ class AccountSettings(
var hasDonatedInVersion: MutableStateFlow<Set<String>> = MutableStateFlow(setOf<String>()), var hasDonatedInVersion: MutableStateFlow<Set<String>> = MutableStateFlow(setOf<String>()),
val pendingAttestations: MutableStateFlow<Map<HexKey, String>> = MutableStateFlow<Map<HexKey, String>>(mapOf()), val pendingAttestations: MutableStateFlow<Map<HexKey, String>> = MutableStateFlow<Map<HexKey, String>>(mapOf()),
) { ) {
val saveable = MutableStateFlow(AccountSettingsUpdater(this)) val saveable = MutableStateFlow(AccountSettingsUpdater(null))
val syncedSettings: AccountSyncedSettings = val syncedSettings: AccountSyncedSettings =
backupSyncedSettings?.let { AccountSyncedSettings(it) } backupSyncedSettings?.let { AccountSyncedSettings(it) }
?: AccountSyncedSettings(AccountSyncedSettingsInternal()) ?: AccountSyncedSettings(AccountSyncedSettingsInternal())
class AccountSettingsUpdater( class AccountSettingsUpdater(
val accountSettings: AccountSettings, val accountSettings: AccountSettings?,
) )
fun saveAccountSettings() { fun saveAccountSettings() {
@@ -49,7 +49,7 @@ class AccountSyncedSettings(
AccountSecurityPreferences( AccountSecurityPreferences(
MutableStateFlow(internalSettings.security.showSensitiveContent), MutableStateFlow(internalSettings.security.showSensitiveContent),
internalSettings.security.warnAboutPostsWithReports, internalSettings.security.warnAboutPostsWithReports,
internalSettings.security.filterSpamFromStrangers, MutableStateFlow(internalSettings.security.filterSpamFromStrangers),
) )
fun toInternal(): AccountSyncedSettingsInternal = fun toInternal(): AccountSyncedSettingsInternal =
@@ -70,7 +70,7 @@ class AccountSyncedSettings(
AccountSecurityPreferencesInternal( AccountSecurityPreferencesInternal(
security.showSensitiveContent.value, security.showSensitiveContent.value,
security.warnAboutPostsWithReports, security.warnAboutPostsWithReports,
security.filterSpamFromStrangers, security.filterSpamFromStrangers.value,
), ),
) )
@@ -105,8 +105,8 @@ class AccountSyncedSettings(
security.showSensitiveContent.tryEmit(syncedSettingsInternal.security.showSensitiveContent) security.showSensitiveContent.tryEmit(syncedSettingsInternal.security.showSensitiveContent)
} }
if (security.filterSpamFromStrangers != syncedSettingsInternal.security.filterSpamFromStrangers) { if (security.filterSpamFromStrangers.value != syncedSettingsInternal.security.filterSpamFromStrangers) {
security.filterSpamFromStrangers = syncedSettingsInternal.security.filterSpamFromStrangers security.filterSpamFromStrangers.tryEmit(syncedSettingsInternal.security.filterSpamFromStrangers)
} }
if (security.warnAboutPostsWithReports != syncedSettingsInternal.security.warnAboutPostsWithReports) { if (security.warnAboutPostsWithReports != syncedSettingsInternal.security.warnAboutPostsWithReports) {
@@ -180,7 +180,7 @@ class AccountLanguagePreferences(
class AccountSecurityPreferences( class AccountSecurityPreferences(
val showSensitiveContent: MutableStateFlow<Boolean?> = MutableStateFlow(null), val showSensitiveContent: MutableStateFlow<Boolean?> = MutableStateFlow(null),
var warnAboutPostsWithReports: Boolean = true, var warnAboutPostsWithReports: Boolean = true,
var filterSpamFromStrangers: Boolean = true, var filterSpamFromStrangers: MutableStateFlow<Boolean> = MutableStateFlow(true),
) { ) {
fun updateShowSensitiveContent(show: Boolean?): Boolean { fun updateShowSensitiveContent(show: Boolean?): Boolean {
if (showSensitiveContent.value != show) { if (showSensitiveContent.value != show) {
@@ -197,9 +197,9 @@ class AccountSecurityPreferences(
warnReports: Boolean, warnReports: Boolean,
filterSpam: Boolean, filterSpam: Boolean,
): Boolean = ): Boolean =
if (warnAboutPostsWithReports != warnReports || filterSpam != filterSpamFromStrangers) { if (warnAboutPostsWithReports != warnReports || filterSpam != filterSpamFromStrangers.value) {
warnAboutPostsWithReports = warnReports warnAboutPostsWithReports = warnReports
filterSpamFromStrangers = filterSpam filterSpamFromStrangers.tryEmit(filterSpam)
true true
} else { } else {
@@ -33,6 +33,7 @@ import com.vitorpamplona.amethyst.commons.hashtags.Btc
import com.vitorpamplona.amethyst.commons.hashtags.Cashu import com.vitorpamplona.amethyst.commons.hashtags.Cashu
import com.vitorpamplona.amethyst.commons.hashtags.Coffee import com.vitorpamplona.amethyst.commons.hashtags.Coffee
import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons
import com.vitorpamplona.amethyst.commons.hashtags.Flowerstr
import com.vitorpamplona.amethyst.commons.hashtags.Footstr import com.vitorpamplona.amethyst.commons.hashtags.Footstr
import com.vitorpamplona.amethyst.commons.hashtags.Gamestr import com.vitorpamplona.amethyst.commons.hashtags.Gamestr
import com.vitorpamplona.amethyst.commons.hashtags.Grownostr import com.vitorpamplona.amethyst.commons.hashtags.Grownostr
@@ -57,7 +58,7 @@ import com.vitorpamplona.quartz.nip02FollowList.EmptyTagList
fun RenderHashTagIconsPreview() { fun RenderHashTagIconsPreview() {
ThemeComparisonColumn { ThemeComparisonColumn {
RenderRegular( RenderRegular(
"Testing rendering of hashtags: #Bitcoin, #nostr, #lightning, #zap, #amethyst, #cashu, #plebs, #coffee, #skullofsatoshi, #grownostr, #footstr, #tunestr, #weed, #mate, #gamestr, #gamechain", "Testing rendering of hashtags: #flowerstr #Bitcoin, #nostr, #lightning, #zap, #amethyst, #cashu, #plebs, #coffee, #skullofsatoshi, #grownostr, #footstr, #tunestr, #weed, #mate, #gamestr, #gamechain",
EmptyTagList, EmptyTagList,
) { word, state -> ) { word, state ->
when (word) { when (word) {
@@ -81,6 +82,7 @@ fun checkForHashtagWithIcon(tag: String): HashtagIcon? =
"skullofsatoshi" -> skull "skullofsatoshi" -> skull
"grownostr", "gardening", "garden" -> growstr "grownostr", "gardening", "garden" -> growstr
"footstr" -> footstr "footstr" -> footstr
"flowerstr" -> flowerstr
"tunestr", "music", "nowplaying" -> tunestr "tunestr", "music", "nowplaying" -> tunestr
"mate", "matechain", "matestr" -> matestr "mate", "matechain", "matestr" -> matestr
"weed", "weedstr", "420", "cannabis", "marijuana" -> weed "weed", "weedstr", "420", "cannabis", "marijuana" -> weed
@@ -99,6 +101,7 @@ val coffee = HashtagIcon(CustomHashTagIcons.Coffee, "Coffee", Modifier.padding(s
val skull = HashtagIcon(CustomHashTagIcons.Skull, "SkullofSatoshi", Modifier.padding(start = 1.dp, bottom = 1.dp, top = 1.dp)) val skull = HashtagIcon(CustomHashTagIcons.Skull, "SkullofSatoshi", Modifier.padding(start = 1.dp, bottom = 1.dp, top = 1.dp))
val growstr = HashtagIcon(CustomHashTagIcons.Grownostr, "GrowNostr", Modifier.padding(start = 1.dp, bottom = 1.dp, top = 1.dp)) val growstr = HashtagIcon(CustomHashTagIcons.Grownostr, "GrowNostr", Modifier.padding(start = 1.dp, bottom = 1.dp, top = 1.dp))
val footstr = HashtagIcon(CustomHashTagIcons.Footstr, "Footstr", Modifier.padding(start = 2.dp, bottom = 1.dp, top = 1.dp)) val footstr = HashtagIcon(CustomHashTagIcons.Footstr, "Footstr", Modifier.padding(start = 2.dp, bottom = 1.dp, top = 1.dp))
val flowerstr = HashtagIcon(CustomHashTagIcons.Flowerstr, "Flowerstr", Modifier.padding(start = 2.dp, bottom = 1.dp, top = 1.dp))
val tunestr = HashtagIcon(CustomHashTagIcons.Tunestr, "Tunestr", Modifier.padding(start = 1.dp, bottom = 1.dp, top = 1.dp)) val tunestr = HashtagIcon(CustomHashTagIcons.Tunestr, "Tunestr", Modifier.padding(start = 1.dp, bottom = 1.dp, top = 1.dp))
val weed = HashtagIcon(CustomHashTagIcons.Weed, "Weed", Modifier.padding(start = 1.dp, bottom = 0.dp, top = 0.dp)) val weed = HashtagIcon(CustomHashTagIcons.Weed, "Weed", Modifier.padding(start = 1.dp, bottom = 0.dp, top = 0.dp))
val matestr = HashtagIcon(CustomHashTagIcons.Mate, "Mate", Modifier.padding(start = 1.dp, bottom = 0.dp, top = 0.dp)) val matestr = HashtagIcon(CustomHashTagIcons.Mate, "Mate", Modifier.padding(start = 1.dp, bottom = 0.dp, top = 0.dp))
@@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.commons.data.DeletionIndex
import com.vitorpamplona.amethyst.commons.data.LargeCache import com.vitorpamplona.amethyst.commons.data.LargeCache
import com.vitorpamplona.amethyst.model.observables.LatestByKindAndAuthor import com.vitorpamplona.amethyst.model.observables.LatestByKindAndAuthor
import com.vitorpamplona.amethyst.model.observables.LatestByKindWithETag import com.vitorpamplona.amethyst.model.observables.LatestByKindWithETag
import com.vitorpamplona.amethyst.service.NostrAccountDataSource.account
import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.ammolite.relays.BundledInsert import com.vitorpamplona.ammolite.relays.BundledInsert
import com.vitorpamplona.ammolite.relays.Relay import com.vitorpamplona.ammolite.relays.Relay
@@ -68,6 +69,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUsers
import com.vitorpamplona.quartz.nip01Core.verify import com.vitorpamplona.quartz.nip01Core.verify
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent
import com.vitorpamplona.quartz.nip03Timestamp.OtsResolver
import com.vitorpamplona.quartz.nip03Timestamp.VerificationState import com.vitorpamplona.quartz.nip03Timestamp.VerificationState
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
@@ -938,9 +940,6 @@ object LocalCache {
// Already processed this event. // Already processed this event.
if (version.event?.id == event.id) return if (version.event?.id == event.id) return
// makes sure the OTS has a valid certificate
if (event.cacheVerify() is VerificationState.Error) return // no valid OTS
if (version.event == null) { if (version.event == null) {
version.loadEvent(event, author, emptyList()) version.loadEvent(event, author, emptyList())
version.liveSet?.innerOts?.invalidateData() version.liveSet?.innerOts?.invalidateData()
@@ -1630,7 +1629,7 @@ object LocalCache {
} }
try { try {
val cachePath = Amethyst.instance.nip95cache() val cachePath = Amethyst.instance.nip95cache
cachePath.mkdirs() cachePath.mkdirs()
val file = File(cachePath, event.id) val file = File(cachePath, event.id)
if (!file.exists()) { if (!file.exists()) {
@@ -1991,7 +1990,10 @@ object LocalCache {
.toImmutableList() .toImmutableList()
} }
suspend fun findEarliestOtsForNote(note: Note): Long? { suspend fun findEarliestOtsForNote(
note: Note,
resolverBuilder: () -> OtsResolver,
): Long? {
checkNotInMainThread() checkNotInMainThread()
var minTime: Long? = null var minTime: Long? = null
@@ -2000,7 +2002,7 @@ object LocalCache {
notes.forEach { _, item -> notes.forEach { _, item ->
val noteEvent = item.event val noteEvent = item.event
if ((noteEvent is OtsEvent && noteEvent.isTaggedEvent(note.idHex) && !noteEvent.isExpirationBefore(time))) { if ((noteEvent is OtsEvent && noteEvent.isTaggedEvent(note.idHex) && !noteEvent.isExpirationBefore(time))) {
(noteEvent.cacheVerify() as? VerificationState.Verified)?.verifiedTime?.let { stampedTime -> (Amethyst.instance.otsVerifCache.cacheVerify(noteEvent, resolverBuilder) as? VerificationState.Verified)?.verifiedTime?.let { stampedTime ->
if (minTime == null || stampedTime < (minTime ?: Long.MAX_VALUE)) { if (minTime == null || stampedTime < (minTime ?: Long.MAX_VALUE)) {
minTime = stampedTime minTime = stampedTime
} }
@@ -24,6 +24,7 @@ import android.util.LruCache
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.service.previews.UrlPreview import com.vitorpamplona.amethyst.service.previews.UrlPreview
import com.vitorpamplona.amethyst.ui.components.UrlPreviewState import com.vitorpamplona.amethyst.ui.components.UrlPreviewState
import okhttp3.OkHttpClient
@Stable @Stable
object UrlCachedPreviewer { object UrlCachedPreviewer {
@@ -32,7 +33,7 @@ object UrlCachedPreviewer {
suspend fun previewInfo( suspend fun previewInfo(
url: String, url: String,
forceProxy: Boolean, okHttpClient: (String) -> OkHttpClient,
onReady: suspend (UrlPreviewState) -> Unit, onReady: suspend (UrlPreviewState) -> Unit,
) { ) {
cache[url]?.let { cache[url]?.let {
@@ -42,7 +43,7 @@ object UrlCachedPreviewer {
UrlPreview().fetch( UrlPreview().fetch(
url, url,
forceProxy, okHttpClient,
onComplete = { urlInfo -> onComplete = { urlInfo ->
cache[url]?.let { cache[url]?.let {
if (it is UrlPreviewState.Loaded || it is UrlPreviewState.Empty) { if (it is UrlPreviewState.Loaded || it is UrlPreviewState.Empty) {
@@ -0,0 +1,29 @@
/**
* Copyright (c) 2024 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
import android.app.Application
import java.io.File
fun Application.safeCacheDir(): File {
val cacheDir = checkNotNull(cacheDir) { "cacheDir == null" }
return cacheDir.apply { mkdirs() }
}
@@ -28,7 +28,6 @@ import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
import com.vitorpamplona.amethyst.service.okhttp.HttpClientManager
import com.vitorpamplona.amethyst.ui.components.GenericLoadable import com.vitorpamplona.amethyst.ui.components.GenericLoadable
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey
@@ -40,6 +39,7 @@ import kotlinx.serialization.cbor.ByteString
import kotlinx.serialization.cbor.Cbor import kotlinx.serialization.cbor.Cbor
import kotlinx.serialization.decodeFromByteArray import kotlinx.serialization.decodeFromByteArray
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import java.util.Base64 import java.util.Base64
@@ -218,7 +218,7 @@ class CashuProcessor {
suspend fun melt( suspend fun melt(
token: CashuToken, token: CashuToken,
lud16: String, lud16: String,
forceProxy: (url: String) -> Boolean, okHttpClient: (String) -> OkHttpClient,
onSuccess: (String, String) -> Unit, onSuccess: (String, String) -> Unit,
onError: (String, String) -> Unit, onError: (String, String) -> Unit,
context: Context, context: Context,
@@ -232,12 +232,12 @@ class CashuProcessor {
// Make invoice and leave room for fees // Make invoice and leave room for fees
milliSats = token.totalAmount * 1000, milliSats = token.totalAmount * 1000,
message = "Calculate Fees for Cashu", message = "Calculate Fees for Cashu",
forceProxy = forceProxy, okHttpClient = okHttpClient,
onSuccess = { baseInvoice -> onSuccess = { baseInvoice ->
feeCalculator( feeCalculator(
token.mint, token.mint,
baseInvoice, baseInvoice,
forceProxy = forceProxy, okHttpClient = okHttpClient,
onSuccess = { fees -> onSuccess = { fees ->
LightningAddressResolver() LightningAddressResolver()
.lnAddressInvoice( .lnAddressInvoice(
@@ -245,9 +245,9 @@ class CashuProcessor {
// Make invoice and leave room for fees // Make invoice and leave room for fees
milliSats = (token.totalAmount - fees) * 1000, milliSats = (token.totalAmount - fees) * 1000,
message = "Redeem Cashu", message = "Redeem Cashu",
forceProxy = forceProxy, okHttpClient = okHttpClient,
onSuccess = { invoice -> onSuccess = { invoice ->
meltInvoice(token, invoice, fees, forceProxy, onSuccess, onError, context) meltInvoice(token, invoice, fees, okHttpClient, onSuccess, onError, context)
}, },
onProgress = {}, onProgress = {},
onError = onError, onError = onError,
@@ -268,7 +268,7 @@ class CashuProcessor {
fun feeCalculator( fun feeCalculator(
mintAddress: String, mintAddress: String,
invoice: String, invoice: String,
forceProxy: (String) -> Boolean, okHttpClient: (String) -> OkHttpClient,
onSuccess: (Int) -> Unit, onSuccess: (Int) -> Unit,
onError: (String, String) -> Unit, onError: (String, String) -> Unit,
context: Context, context: Context,
@@ -277,7 +277,7 @@ class CashuProcessor {
try { try {
val url = "$mintAddress/checkfees" // Melt cashu tokens at Mint val url = "$mintAddress/checkfees" // Melt cashu tokens at Mint
val client = HttpClientManager.getHttpClient(forceProxy(url)) val client = okHttpClient(url)
val factory = JsonNodeFactory.instance val factory = JsonNodeFactory.instance
@@ -334,14 +334,14 @@ class CashuProcessor {
token: CashuToken, token: CashuToken,
invoice: String, invoice: String,
fees: Int, fees: Int,
forceProxy: (String) -> Boolean, okHttpClient: (String) -> OkHttpClient,
onSuccess: (String, String) -> Unit, onSuccess: (String, String) -> Unit,
onError: (String, String) -> Unit, onError: (String, String) -> Unit,
context: Context, context: Context,
) { ) {
try { try {
val url = token.mint + "/melt" // Melt cashu tokens at Mint val url = token.mint + "/melt" // Melt cashu tokens at Mint
val client = HttpClientManager.getHttpClient(forceProxy(url)) val client = okHttpClient(url)
val factory = JsonNodeFactory.instance val factory = JsonNodeFactory.instance
@@ -18,28 +18,34 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * 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. * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
package com.vitorpamplona.amethyst.ui.components package com.vitorpamplona.amethyst.service
import androidx.compose.foundation.text.ClickableText import android.util.Log
import androidx.compose.material3.LocalTextStyle import kotlinx.coroutines.CancellationException
import androidx.compose.material3.MaterialTheme import kotlinx.coroutines.delay
import androidx.compose.runtime.Composable
import androidx.compose.ui.text.AnnotatedString
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.navigation.routeFor
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable suspend fun <T> retryIfException(
fun ClickableNoteTag( debugTag: String = "RetryIfException",
baseNote: Note, maxRetries: Int = 10,
accountViewModel: AccountViewModel, delayMs: Long = 1000,
nav: INav, func: suspend () -> T,
) { ) {
ClickableText( var tentative = 0
text = AnnotatedString("@${baseNote.idNote().toShortenHex()}"), var currentDelay = delayMs
onClick = { routeFor(baseNote, accountViewModel.userProfile())?.let { nav.nav(it) } }, while (tentative < maxRetries) {
style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary), try {
) func()
// if it works, finishes.
return
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e(debugTag, "Tentative $tentative failed", e)
delay(currentDelay)
tentative++
currentDelay = currentDelay * 2
}
}
// gives up
} }
@@ -21,17 +21,17 @@
package com.vitorpamplona.amethyst.service package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.service.okhttp.HttpClientManager
import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05 import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
class Nip05NostrAddressVerifier { class Nip05NostrAddressVerifier {
suspend fun fetchNip05Json( suspend fun fetchNip05Json(
nip05: String, nip05: String,
forceProxy: (String) -> Boolean, okttpClient: (String) -> OkHttpClient,
onSuccess: suspend (String) -> Unit, onSuccess: suspend (String) -> Unit,
onError: (String) -> Unit, onError: (String) -> Unit,
) = withContext(Dispatchers.IO) { ) = withContext(Dispatchers.IO) {
@@ -52,8 +52,7 @@ class Nip05NostrAddressVerifier {
.url(url) .url(url)
.build() .build()
// Fetchers MUST ignore any HTTP redirects given by the /.well-known/nostr.json endpoint. // Fetchers MUST ignore any HTTP redirects given by the /.well-known/nostr.json endpoint.
HttpClientManager okttpClient(url)
.getHttpClient(forceProxy(url))
.newBuilder() .newBuilder()
.followRedirects(false) .followRedirects(false)
.build() .build()
@@ -78,7 +77,7 @@ class Nip05NostrAddressVerifier {
suspend fun verifyNip05( suspend fun verifyNip05(
nip05: String, nip05: String,
forceProxy: (String) -> Boolean, okttpClient: (String) -> OkHttpClient,
onSuccess: suspend (String) -> Unit, onSuccess: suspend (String) -> Unit,
onError: (String) -> Unit, onError: (String) -> Unit,
) { ) {
@@ -87,7 +86,7 @@ class Nip05NostrAddressVerifier {
fetchNip05Json( fetchNip05Json(
nip05, nip05,
forceProxy, okttpClient,
onSuccess = { onSuccess = {
checkNotInMainThread() checkNotInMainThread()
@@ -20,15 +20,16 @@
*/ */
package com.vitorpamplona.amethyst.service package com.vitorpamplona.amethyst.service
import android.content.ContentProviderOperation.newCall
import android.util.Log import android.util.Log
import android.util.LruCache import android.util.LruCache
import com.vitorpamplona.amethyst.service.okhttp.HttpClientManager
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
import com.vitorpamplona.quartz.nip65RelayList.RelayUrlFormatter import com.vitorpamplona.quartz.nip65RelayList.RelayUrlFormatter
import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import okhttp3.Call import okhttp3.Call
import okhttp3.Callback import okhttp3.Callback
import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
import java.io.IOException import java.io.IOException
@@ -60,7 +61,7 @@ object Nip11CachedRetriever {
suspend fun loadRelayInfo( suspend fun loadRelayInfo(
dirtyUrl: String, dirtyUrl: String,
forceProxy: Boolean, okHttpClient: (String) -> OkHttpClient,
onInfo: (Nip11RelayInformation) -> Unit, onInfo: (Nip11RelayInformation) -> Unit,
onError: (String, Nip11Retriever.ErrorCode, String?) -> Unit, onError: (String, Nip11Retriever.ErrorCode, String?) -> Unit,
) { ) {
@@ -75,24 +76,24 @@ object Nip11CachedRetriever {
if (TimeUtils.now() - doc.time < TimeUtils.ONE_MINUTE) { if (TimeUtils.now() - doc.time < TimeUtils.ONE_MINUTE) {
// just wait. // just wait.
} else { } else {
retrieve(url, dirtyUrl, forceProxy, onInfo, onError) retrieve(url, dirtyUrl, okHttpClient, onInfo, onError)
} }
} else if (doc is RetrieveResultError) { } else if (doc is RetrieveResultError) {
if (TimeUtils.now() - doc.time < TimeUtils.ONE_HOUR) { if (TimeUtils.now() - doc.time < TimeUtils.ONE_HOUR) {
onError(dirtyUrl, doc.error, null) onError(dirtyUrl, doc.error, null)
} else { } else {
retrieve(url, dirtyUrl, forceProxy, onInfo, onError) retrieve(url, dirtyUrl, okHttpClient, onInfo, onError)
} }
} }
} else { } else {
retrieve(url, dirtyUrl, forceProxy, onInfo, onError) retrieve(url, dirtyUrl, okHttpClient, onInfo, onError)
} }
} }
private suspend fun retrieve( private suspend fun retrieve(
url: String, url: String,
dirtyUrl: String, dirtyUrl: String,
forceProxy: Boolean, okHttpClient: (String) -> OkHttpClient,
onInfo: (Nip11RelayInformation) -> Unit, onInfo: (Nip11RelayInformation) -> Unit,
onError: (String, Nip11Retriever.ErrorCode, String?) -> Unit, onError: (String, Nip11Retriever.ErrorCode, String?) -> Unit,
) { ) {
@@ -100,7 +101,7 @@ object Nip11CachedRetriever {
retriever.loadRelayInfo( retriever.loadRelayInfo(
url = url, url = url,
dirtyUrl = dirtyUrl, dirtyUrl = dirtyUrl,
forceProxy = forceProxy, okHttpClient = okHttpClient,
onInfo = { onInfo = {
checkNotInMainThread() checkNotInMainThread()
relayInformationDocumentCache.put(url, RetrieveResultSuccess(it)) relayInformationDocumentCache.put(url, RetrieveResultSuccess(it))
@@ -126,7 +127,7 @@ class Nip11Retriever {
suspend fun loadRelayInfo( suspend fun loadRelayInfo(
url: String, url: String,
dirtyUrl: String, dirtyUrl: String,
forceProxy: Boolean, okHttpClient: (String) -> OkHttpClient,
onInfo: (Nip11RelayInformation) -> Unit, onInfo: (Nip11RelayInformation) -> Unit,
onError: (String, ErrorCode, String?) -> Unit, onError: (String, ErrorCode, String?) -> Unit,
) { ) {
@@ -139,8 +140,7 @@ class Nip11Retriever {
.url(url) .url(url)
.build() .build()
HttpClientManager okHttpClient(url)
.getHttpClient(forceProxy)
.newCall(request) .newCall(request)
.enqueue( .enqueue(
object : Callback { object : Callback {
@@ -20,6 +20,7 @@
*/ */
package com.vitorpamplona.amethyst.service package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
@@ -39,6 +40,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
@@ -311,6 +313,11 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") {
checkNotInMainThread() checkNotInMainThread()
when (event) { when (event) {
is OtsEvent -> {
// verifies new OTS upon arrival
Amethyst.instance.otsVerifCache.cacheVerify(event, account::otsResolver)
}
is PrivateOutboxRelayListEvent -> { is PrivateOutboxRelayListEvent -> {
val note = LocalCache.getAddressableNoteIfExists(event.addressTag()) val note = LocalCache.getAddressableNoteIfExists(event.addressTag())
val noteEvent = note?.event val noteEvent = note?.event
@@ -24,9 +24,9 @@ import android.util.Log
import android.util.LruCache import android.util.LruCache
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.service.okhttp.HttpClientManager
import com.vitorpamplona.quartz.utils.RandomInstance import com.vitorpamplona.quartz.utils.RandomInstance
import okhttp3.EventListener import okhttp3.EventListener
import okhttp3.OkHttpClient
import okhttp3.Protocol import okhttp3.Protocol
import okhttp3.Request import okhttp3.Request
import okio.ByteString.Companion.toByteString import okio.ByteString.Companion.toByteString
@@ -51,7 +51,7 @@ object OnlineChecker {
fun isOnline( fun isOnline(
url: String?, url: String?,
forceProxy: Boolean, okttpClient: (String) -> OkHttpClient,
): Boolean { ): Boolean {
checkNotInMainThread() checkNotInMainThread()
@@ -76,8 +76,7 @@ object OnlineChecker {
.build() .build()
val client = val client =
HttpClientManager okttpClient(url)
.getHttpClient(forceProxy)
.newBuilder() .newBuilder()
.eventListener(EventListener.NONE) .eventListener(EventListener.NONE)
.protocols(listOf(Protocol.HTTP_1_1)) .protocols(listOf(Protocol.HTTP_1_1))
@@ -96,7 +95,7 @@ object OnlineChecker {
.get() .get()
.build() .build()
HttpClientManager.getHttpClient(forceProxy).newCall(request).execute().use { okttpClient(url).newCall(request).execute().use {
checkNotInMainThread() checkNotInMainThread()
it.isSuccessful it.isSuccessful
} }
@@ -44,6 +44,7 @@ import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import kotlin.math.round import kotlin.math.round
class ZapPaymentHandler( class ZapPaymentHandler(
@@ -64,7 +65,7 @@ class ZapPaymentHandler(
message: String, message: String,
context: Context, context: Context,
showErrorIfNoLnAddress: Boolean, showErrorIfNoLnAddress: Boolean,
forceProxy: (String) -> Boolean, okHttpClient: (String) -> OkHttpClient,
onError: (String, String, User?) -> Unit, onError: (String, String, User?) -> Unit,
onProgress: (percent: Float) -> Unit, onProgress: (percent: Float) -> Unit,
onPayViaIntent: (ImmutableList<Payable>) -> Unit, onPayViaIntent: (ImmutableList<Payable>) -> Unit,
@@ -130,7 +131,7 @@ class ZapPaymentHandler(
onProgress(0.05f) onProgress(0.05f)
} }
assembleAllInvoices(splitZapRequestPairs, amountMilliSats, message, showErrorIfNoLnAddress, forceProxy, onError, onProgress = { assembleAllInvoices(splitZapRequestPairs, amountMilliSats, message, showErrorIfNoLnAddress, okHttpClient, onError, onProgress = {
onProgress(it * 0.7f + 0.05f) // keeps within range. onProgress(it * 0.7f + 0.05f) // keeps within range.
}, context) { payables -> }, context) { payables ->
if (payables.isEmpty()) { if (payables.isEmpty()) {
@@ -228,7 +229,7 @@ class ZapPaymentHandler(
totalAmountMilliSats: Long, totalAmountMilliSats: Long,
message: String, message: String,
showErrorIfNoLnAddress: Boolean, showErrorIfNoLnAddress: Boolean,
forceProxy: (String) -> Boolean, okHttpClient: (String) -> OkHttpClient,
onError: (String, String, User?) -> Unit, onError: (String, String, User?) -> Unit,
onProgress: (percent: Float) -> Unit, onProgress: (percent: Float) -> Unit,
context: Context, context: Context,
@@ -247,7 +248,7 @@ class ZapPaymentHandler(
zapValue = calculateZapValue(totalAmountMilliSats, splitZapRequestPair.inputSetup.weight, totalWeight), zapValue = calculateZapValue(totalAmountMilliSats, splitZapRequestPair.inputSetup.weight, totalWeight),
message = message, message = message,
showErrorIfNoLnAddress = showErrorIfNoLnAddress, showErrorIfNoLnAddress = showErrorIfNoLnAddress,
forceProxy = forceProxy, okHttpClient = okHttpClient,
onError = onError, onError = onError,
onProgressStep = { percentStepForThisPayment -> onProgressStep = { percentStepForThisPayment ->
progressAllPayments += percentStepForThisPayment / requests.size progressAllPayments += percentStepForThisPayment / requests.size
@@ -319,7 +320,7 @@ class ZapPaymentHandler(
zapValue: Long, zapValue: Long,
message: String, message: String,
showErrorIfNoLnAddress: Boolean = true, showErrorIfNoLnAddress: Boolean = true,
forceProxy: (String) -> Boolean, okHttpClient: (String) -> OkHttpClient,
onError: (String, String, User?) -> Unit, onError: (String, String, User?) -> Unit,
onProgressStep: (percent: Float) -> Unit, onProgressStep: (percent: Float) -> Unit,
context: Context, context: Context,
@@ -341,7 +342,7 @@ class ZapPaymentHandler(
milliSats = zapValue, milliSats = zapValue,
message = message, message = message,
nostrRequest = nostrZapRequest, nostrRequest = nostrZapRequest,
forceProxy = forceProxy, okHttpClient = okHttpClient,
onError = { title, msg -> onError = { title, msg ->
onError(title, msg, toUser) onError(title, msg, toUser)
}, },
@@ -0,0 +1,94 @@
/**
* Copyright (c) 2024 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.connectivity
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.util.Log
import com.vitorpamplona.ammolite.service.checkNotInMainThread
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn
class ConnectivityFlow(
val context: Context,
) {
@OptIn(FlowPreview::class)
val status =
callbackFlow {
trySend(ConnectivityStatus.Connecting)
val connectivityManager = context.getConnectivityManager()
Log.d("ConnectivityFlow", "Starting Connectivity Flow")
val networkCallback =
object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
super.onAvailable(network)
checkNotInMainThread()
Log.d("ConnectivityFlow", "onAvailable ${network.networkHandle}")
connectivityManager.getNetworkCapabilities(network)?.let {
trySend(ConnectivityStatus.Active(network.networkHandle, it.isMeteredOrMobileData()))
}
}
override fun onCapabilitiesChanged(
network: Network,
networkCapabilities: NetworkCapabilities,
) {
super.onCapabilitiesChanged(network, networkCapabilities)
checkNotInMainThread()
val isMobile = networkCapabilities.isMeteredOrMobileData()
Log.d("ConnectivityFlow", "onCapabilitiesChanged ${network.networkHandle} $isMobile")
trySend(ConnectivityStatus.Active(network.networkHandle, isMobile))
}
}
connectivityManager.registerDefaultNetworkCallback(networkCallback)
connectivityManager.activeNetwork?.let { network ->
connectivityManager.getNetworkCapabilities(network)?.let {
trySend(ConnectivityStatus.Active(network.networkHandle, it.isMeteredOrMobileData()))
}
}
awaitClose {
checkNotInMainThread()
Log.d("ConnectivityFlow", "Stopping Connectivity Flow")
connectivityManager.unregisterNetworkCallback(networkCallback)
trySend(ConnectivityStatus.Off)
}
}.distinctUntilChanged().debounce(200).flowOn(Dispatchers.IO)
}
fun NetworkCapabilities.isMeteredOrMobileData(): Boolean {
val metered = !hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
val mobileData = hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)
return metered || mobileData
}
fun Context.getConnectivityManager() = getSystemService(ConnectivityManager::class.java) as ConnectivityManager
@@ -0,0 +1,55 @@
/**
* Copyright (c) 2024 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.connectivity
import android.app.Application
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
/**
* There should be only one instance of the Tor binding per app.
*
* Tor will connect as soon as status is listened to.
*/
class ConnectivityManager(
app: Application,
scope: CoroutineScope,
) {
val status: StateFlow<ConnectivityStatus> =
ConnectivityFlow(app).status.stateIn(
scope,
SharingStarted.WhileSubscribed(30000),
ConnectivityStatus.Off,
)
val isMobileOrNull: StateFlow<Boolean?> =
status
.map {
(status.value as? ConnectivityStatus.Active)?.isMobile
}.stateIn(
scope,
SharingStarted.WhileSubscribed(2000),
(status.value as? ConnectivityStatus.Active)?.isMobile,
)
}
@@ -0,0 +1,32 @@
/**
* Copyright (c) 2024 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.connectivity
sealed class ConnectivityStatus {
data class Active(
val networkId: Long,
val isMobile: Boolean,
) : ConnectivityStatus()
object Off : ConnectivityStatus()
object Connecting : ConnectivityStatus()
}
@@ -0,0 +1,61 @@
/**
* Copyright (c) 2024 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.eventCache
import android.util.Log
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.NostrChatroomDataSource
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
import java.util.concurrent.atomic.AtomicBoolean
class MemoryTrimmingService {
var isTrimmingMemoryMutex = AtomicBoolean(false)
private suspend fun doTrim(account: Account?) {
LocalCache.cleanObservers()
val accounts = LocalPreferences.allSavedAccounts().mapNotNull { decodePublicKeyAsHexOrNull(it.npub) }.toSet()
account?.let {
LocalCache.pruneHiddenMessages(it)
LocalCache.pruneOldAndHiddenMessages(it)
NostrChatroomDataSource.clearEOSEs(it)
LocalCache.pruneContactLists(accounts)
LocalCache.pruneRepliesAndReactions(accounts)
LocalCache.prunePastVersionsOfReplaceables()
LocalCache.pruneExpiredEvents()
}
}
suspend fun run(account: Account?) {
if (isTrimmingMemoryMutex.compareAndSet(false, true)) {
Log.d("ServiceManager", "Trimming Memory")
try {
doTrim(account)
} finally {
isTrimmingMemoryMutex.getAndSet(false)
}
}
}
}
@@ -18,10 +18,8 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * 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. * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
package com.vitorpamplona.amethyst.service package com.vitorpamplona.amethyst.service.images
import android.content.Context
import android.graphics.BitmapFactory
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import coil3.ImageLoader import coil3.ImageLoader
import coil3.Uri import coil3.Uri
@@ -31,38 +29,22 @@ import coil3.fetch.FetchResult
import coil3.fetch.Fetcher import coil3.fetch.Fetcher
import coil3.fetch.ImageFetchResult import coil3.fetch.ImageFetchResult
import coil3.key.Keyer import coil3.key.Keyer
import coil3.request.ImageRequest
import coil3.request.Options import coil3.request.Options
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser.Companion.base64contentPattern import com.vitorpamplona.amethyst.commons.base64Image.Base64Image
import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.utils.sha256.sha256 import com.vitorpamplona.quartz.utils.sha256.sha256
import java.util.Base64
@Stable @Stable
class Base64Fetcher( class Base64Fetcher(
private val options: Options, private val options: Options,
private val data: Uri, private val data: Uri,
) : Fetcher { ) : Fetcher {
override suspend fun fetch(): FetchResult { override suspend fun fetch(): FetchResult =
checkNotInMainThread() ImageFetchResult(
image = Base64Image.Companion.toBitmap(data.toString()).asImage(true),
val matcher = base64contentPattern.matcher(data.toString()) isSampled = false,
dataSource = DataSource.MEMORY,
if (matcher.find()) { )
val base64String = matcher.group(2)
val byteArray = Base64.getDecoder().decode(base64String)
val bitmap = BitmapFactory.decodeByteArray(byteArray, 0, byteArray.size) ?: throw Exception("Unable to load base64 $base64String")
return ImageFetchResult(
image = bitmap.asImage(true),
isSampled = false,
dataSource = DataSource.MEMORY,
)
} else {
throw Exception("Unable to load base64 $data")
}
}
object Factory : Fetcher.Factory<Uri> { object Factory : Fetcher.Factory<Uri> {
override fun create( override fun create(
@@ -89,15 +71,3 @@ class Base64Fetcher(
} }
} }
} }
object Base64Requester {
fun imageRequest(
context: Context,
message: String,
): ImageRequest =
ImageRequest
.Builder(context)
.data(message)
.fetcherFactory(Base64Fetcher.Factory)
.build()
}
@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * 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. * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
package com.vitorpamplona.amethyst.service package com.vitorpamplona.amethyst.service.images
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import coil3.ImageLoader import coil3.ImageLoader
@@ -31,18 +31,16 @@ import coil3.key.Keyer
import coil3.request.Options import coil3.request.Options
import com.vitorpamplona.amethyst.commons.blurhash.BlurHashDecoder import com.vitorpamplona.amethyst.commons.blurhash.BlurHashDecoder
class Blurhash( data class BlurhashWrapper(
val blurhash: String, val blurhash: String,
) )
@Stable @Stable
class BlurHashFetcher( class BlurHashFetcher(
private val options: Options, private val options: Options,
private val data: Blurhash, private val data: BlurhashWrapper,
) : Fetcher { ) : Fetcher {
override suspend fun fetch(): FetchResult { override suspend fun fetch(): FetchResult {
checkNotInMainThread()
val hash = data.blurhash val hash = data.blurhash
val bitmap = BlurHashDecoder.decodeKeepAspectRatio(hash, 25) ?: throw Exception("Unable to convert Blurhash $data") val bitmap = BlurHashDecoder.decodeKeepAspectRatio(hash, 25) ?: throw Exception("Unable to convert Blurhash $data")
@@ -54,17 +52,17 @@ class BlurHashFetcher(
) )
} }
object Factory : Fetcher.Factory<Blurhash> { object Factory : Fetcher.Factory<BlurhashWrapper> {
override fun create( override fun create(
data: Blurhash, data: BlurhashWrapper,
options: Options, options: Options,
imageLoader: ImageLoader, imageLoader: ImageLoader,
): Fetcher = BlurHashFetcher(options, data) ): Fetcher = BlurHashFetcher(options, data)
} }
object BKeyer : Keyer<Blurhash> { object BKeyer : Keyer<BlurhashWrapper> {
override fun key( override fun key(
data: Blurhash, data: BlurhashWrapper,
options: Options, options: Options,
): String = data.blurhash ): String = data.blurhash
} }
@@ -0,0 +1,45 @@
/**
* Copyright (c) 2024 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.images
import android.app.Application
import coil3.disk.DiskCache
import coil3.memory.MemoryCache
import com.vitorpamplona.amethyst.service.safeCacheDir
import okio.Path.Companion.toOkioPath
class ImageCacheFactory {
companion object {
fun newDisk(app: Application): DiskCache =
DiskCache
.Builder()
.directory(app.safeCacheDir().resolve("image_cache").toOkioPath())
.maxSizePercent(0.2)
.maximumMaxSizeBytes(1024 * 1024 * 1024) // 1GB
.build()
fun newMemory(app: Application): MemoryCache =
MemoryCache
.Builder()
.maxSizePercent(app)
.build()
}
}
@@ -0,0 +1,69 @@
/**
* Copyright (c) 2024 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.images
import android.app.Application
import android.os.Build
import coil3.ImageLoader
import coil3.SingletonImageLoader
import coil3.disk.DiskCache
import coil3.gif.AnimatedImageDecoder
import coil3.gif.GifDecoder
import coil3.memory.MemoryCache
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import coil3.size.Precision
import coil3.svg.SvgDecoder
import coil3.util.DebugLogger
import okhttp3.Call
class ImageLoaderSetup {
companion object {
fun setup(
app: Application,
diskCache: DiskCache,
memoryCache: MemoryCache,
isDebug: Boolean,
callFactory: () -> Call.Factory,
) {
SingletonImageLoader.setUnsafe(
ImageLoader
.Builder(app)
.diskCache { diskCache }
.memoryCache { memoryCache }
.precision(Precision.INEXACT)
.logger(if (isDebug) DebugLogger() else null)
.components {
if (Build.VERSION.SDK_INT >= 28) {
add(AnimatedImageDecoder.Factory())
} else {
add(GifDecoder.Factory())
}
add(SvgDecoder.Factory())
add(Base64Fetcher.Factory)
add(BlurHashFetcher.Factory)
add(Base64Fetcher.BKeyer)
add(BlurHashFetcher.BKeyer)
add(OkHttpNetworkFetcherFactory(callFactory))
}.build(),
)
}
}
}
@@ -27,10 +27,10 @@ import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.HttpStatusMessages import com.vitorpamplona.amethyst.service.HttpStatusMessages
import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.okhttp.HttpClientManager
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.lightning.LnInvoiceUtil import com.vitorpamplona.quartz.lightning.LnInvoiceUtil
import com.vitorpamplona.quartz.lightning.Lud06 import com.vitorpamplona.quartz.lightning.Lud06
import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
import java.math.BigDecimal import java.math.BigDecimal
@@ -55,7 +55,7 @@ class LightningAddressResolver {
private fun fetchLightningAddressJson( private fun fetchLightningAddressJson(
lnaddress: String, lnaddress: String,
forceProxy: (url: String) -> Boolean, okttpClient: (String) -> OkHttpClient,
onSuccess: (String) -> Unit, onSuccess: (String) -> Unit,
onError: (String, String) -> Unit, onError: (String, String) -> Unit,
context: Context, context: Context,
@@ -76,7 +76,7 @@ class LightningAddressResolver {
return return
} }
val client = HttpClientManager.getHttpClient(forceProxy(url)) val client = okttpClient(url)
try { try {
val request: Request = val request: Request =
@@ -125,7 +125,7 @@ class LightningAddressResolver {
milliSats: Long, milliSats: Long,
message: String, message: String,
nostrRequest: String? = null, nostrRequest: String? = null,
forceProxy: (url: String) -> Boolean, okttpClient: (String) -> OkHttpClient,
onSuccess: (String) -> Unit, onSuccess: (String) -> Unit,
onError: (String, String) -> Unit, onError: (String, String) -> Unit,
context: Context, context: Context,
@@ -142,7 +142,7 @@ class LightningAddressResolver {
url += "&nostr=$encodedNostrRequest" url += "&nostr=$encodedNostrRequest"
} }
val client = HttpClientManager.getHttpClient(forceProxy(url)) val client = okttpClient(url)
val request: Request = val request: Request =
Request Request
@@ -208,7 +208,7 @@ class LightningAddressResolver {
milliSats: Long, milliSats: Long,
message: String, message: String,
nostrRequest: String? = null, nostrRequest: String? = null,
forceProxy: (url: String) -> Boolean, okHttpClient: (String) -> OkHttpClient,
onSuccess: (String) -> Unit, onSuccess: (String) -> Unit,
onError: (String, String) -> Unit, onError: (String, String) -> Unit,
onProgress: (percent: Float) -> Unit, onProgress: (percent: Float) -> Unit,
@@ -218,7 +218,7 @@ class LightningAddressResolver {
fetchLightningAddressJson( fetchLightningAddressJson(
lnaddress, lnaddress,
forceProxy, okHttpClient,
onSuccess = { lnAddressJson -> onSuccess = { lnAddressJson ->
onProgress(0.4f) onProgress(0.4f)
@@ -259,7 +259,7 @@ class LightningAddressResolver {
milliSats, milliSats,
message, message,
if (allowsNostr) nostrRequest else null, if (allowsNostr) nostrRequest else null,
forceProxy, okHttpClient,
onSuccess = { onSuccess = {
onProgress(0.6f) onProgress(0.6f)
@@ -0,0 +1,52 @@
/**
* Copyright (c) 2024 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.logging
import android.util.Log
import android.view.Choreographer
object ChoreographerHelper {
var lastFrameTimeNanos: Long = 0
fun start() {
Choreographer.getInstance().postFrameCallback(
object : Choreographer.FrameCallback {
override fun doFrame(frameTimeNanos: Long) {
// Last callback time
if (lastFrameTimeNanos == 0L) {
lastFrameTimeNanos = frameTimeNanos
Choreographer.getInstance().postFrameCallback(this)
return
}
val diff = (frameTimeNanos - lastFrameTimeNanos) / 1000000
// only report after 30ms because videos play at 30fps
if (diff > 35) {
// Follow the frame number
val droppedCount = (diff / 16.6).toInt()
Log.w("block-canary", "Dropped $droppedCount frames. Skipped $diff ms")
}
lastFrameTimeNanos = frameTimeNanos
Choreographer.getInstance().postFrameCallback(this)
}
},
)
}
}
@@ -18,14 +18,13 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * 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. * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
package com.vitorpamplona.amethyst package com.vitorpamplona.amethyst.service.logging
import android.os.Handler import android.os.Handler
import android.os.HandlerThread import android.os.HandlerThread
import android.os.Looper import android.os.Looper
import android.util.Log import android.util.Log
import android.util.Printer import android.util.Printer
import android.view.Choreographer
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
@@ -168,31 +167,3 @@ class StackSampler(
val TIME_FORMATTER: SimpleDateFormat = SimpleDateFormat("MM-dd HH:mm:ss.SSS") val TIME_FORMATTER: SimpleDateFormat = SimpleDateFormat("MM-dd HH:mm:ss.SSS")
} }
} }
object ChoreographerHelper {
var lastFrameTimeNanos: Long = 0
fun start() {
Choreographer.getInstance().postFrameCallback(
object : Choreographer.FrameCallback {
override fun doFrame(frameTimeNanos: Long) {
// Last callback time
if (lastFrameTimeNanos == 0L) {
lastFrameTimeNanos = frameTimeNanos
Choreographer.getInstance().postFrameCallback(this)
return
}
val diff = (frameTimeNanos - lastFrameTimeNanos) / 1000000
// only report after 30ms because videos play at 30fps
if (diff > 35) {
// Follow the frame number
val droppedCount = (diff / 16.6).toInt()
Log.w("block-canary", "Dropped $droppedCount frames. Skipped $diff ms")
}
lastFrameTimeNanos = frameTimeNanos
Choreographer.getInstance().postFrameCallback(this)
}
},
)
}
}
@@ -0,0 +1,64 @@
/**
* Copyright (c) 2024 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.logging
import android.os.Build
import android.os.Looper
import android.os.StrictMode
import android.os.StrictMode.ThreadPolicy
import android.os.StrictMode.VmPolicy
class Logging {
companion object {
fun setup() {
StrictMode.setThreadPolicy(
ThreadPolicy
.Builder()
.detectAll()
.penaltyLog()
.build(),
)
StrictMode.setVmPolicy(
VmPolicy
.Builder()
.detectLeakedSqlLiteObjects()
.detectActivityLeaks()
.detectLeakedClosableObjects()
.detectLeakedRegistrationObjects()
.detectFileUriExposure()
.detectCleartextNetwork()
.detectContentUriWithoutPermission()
.apply {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
detectCredentialProtectedWhileLocked()
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
detectIncorrectContextUse()
detectUnsafeIntentLaunch()
}
}.penaltyLog()
.build(),
)
Looper.getMainLooper().setMessageLogging(LogMonitor())
ChoreographerHelper.start()
}
}
}
@@ -20,23 +20,39 @@
*/ */
package com.vitorpamplona.amethyst.service.notifications package com.vitorpamplona.amethyst.service.notifications
import android.app.Application
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Context.RECEIVER_EXPORTED
import android.content.Intent import android.content.Intent
import android.content.IntentFilter
import android.os.Build
import android.provider.LiveFolders.INTENT
import android.util.Log import android.util.Log
import androidx.core.content.ContextCompat.registerReceiver
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.Event
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class PokeyReceiver : BroadcastReceiver() { class PokeyReceiver : BroadcastReceiver() {
companion object { companion object {
val POKEY_ACTION = "com.shared.NOSTR" const val POKEY_ACTION = "com.shared.NOSTR"
val TAG = "PokeyReceiver" const val TAG = "PokeyReceiver"
val INTENT = IntentFilter(POKEY_ACTION)
} }
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) fun register(app: Application) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
app.registerReceiver(this, INTENT, RECEIVER_EXPORTED)
} else {
@Suppress("UnspecifiedRegisterReceiverFlag")
app.registerReceiver(this, INTENT)
}
}
fun unregister(app: Application) {
app.unregisterReceiver(this)
}
override fun onReceive( override fun onReceive(
context: Context, context: Context,
@@ -48,9 +64,11 @@ class PokeyReceiver : BroadcastReceiver() {
if (eventStr == null) return if (eventStr == null) return
scope.launch(Dispatchers.IO) { val app = context.applicationContext as Amethyst
app.applicationIOScope.launch {
try { try {
EventNotificationConsumer(context.applicationContext).findAccountAndConsume( EventNotificationConsumer(app).findAccountAndConsume(
Event.fromJson(eventStr), Event.fromJson(eventStr),
) )
} catch (e: Exception) { } catch (e: Exception) {
@@ -27,20 +27,20 @@ import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.launchAndWaitAll import com.vitorpamplona.amethyst.launchAndWaitAll
import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.service.okhttp.HttpClientManager
import com.vitorpamplona.amethyst.tryAndWait import com.vitorpamplona.amethyst.tryAndWait
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
import com.vitorpamplona.quartz.nip55AndroidSigner.NostrSignerExternal import com.vitorpamplona.quartz.nip55AndroidSigner.NostrSignerExternal
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import kotlin.coroutines.resume import kotlin.coroutines.resume
class RegisterAccounts( class RegisterAccounts(
private val accounts: List<AccountInfo>, private val accounts: List<AccountInfo>,
private val client: (String) -> OkHttpClient,
) { ) {
val tag = val tag =
if (BuildConfig.FLAVOR == "play") { if (BuildConfig.FLAVOR == "play") {
@@ -133,33 +133,26 @@ class RegisterAccounts(
} }
fun postRegistrationEvent(events: List<RelayAuthEvent>) { fun postRegistrationEvent(events: List<RelayAuthEvent>) {
try { val jsonObject =
val jsonObject = """{
"""{ "events": [ ${events.joinToString(", ") { it.toJson() }} ]
"events": [ ${events.joinToString(", ") { it.toJson() }} ]
}
"""
val mediaType = "application/json; charset=utf-8".toMediaType()
val body = jsonObject.toRequestBody(mediaType)
val request =
Request
.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url("https://push.amethyst.social/register")
.post(body)
.build()
// Always try via Tor for Amethyst.
val client = HttpClientManager.getHttpClient(true)
val isSucess = client.newCall(request).execute().use { it.isSuccessful }
Log.i(tag, "Server registration $isSucess")
} catch (e: java.lang.Exception) {
if (e is CancellationException) throw e
Log.e(tag, "Unable to register with push server", e)
} }
"""
val url = "https://push.amethyst.social/register"
val mediaType = "application/json; charset=utf-8".toMediaType()
val body = jsonObject.toRequestBody(mediaType)
val request =
Request
.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url)
.post(body)
.build()
val isSucess = client(url).newCall(request).execute().use { it.isSuccessful }
Log.i(tag, "Server registration $isSucess")
} }
suspend fun go(notificationToken: String) { suspend fun go(notificationToken: String) {
@@ -0,0 +1,76 @@
/**
* Copyright (c) 2024 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.okhttp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import okhttp3.OkHttpClient
import java.net.InetSocketAddress
import java.net.Proxy
class DualHttpClientManager(
userAgent: String,
proxyPortProvider: StateFlow<Int?>,
isMobileDataProvider: StateFlow<Boolean?>,
keyCache: EncryptionKeyCache,
scope: CoroutineScope,
) {
val factory = OkHttpClientFactory(keyCache)
private val defaultHttpClient: StateFlow<OkHttpClient> =
combine(proxyPortProvider, isMobileDataProvider) { proxy, mobile ->
factory.buildHttpClient(proxy, mobile, userAgent)
}.stateIn(
scope,
SharingStarted.Lazily,
factory.buildHttpClient(proxyPortProvider.value, isMobileDataProvider.value, userAgent),
)
private val defaultHttpClientWithoutProxy: StateFlow<OkHttpClient> =
isMobileDataProvider
.map { mobile ->
factory.buildHttpClient(mobile, userAgent)
}.stateIn(
scope,
SharingStarted.Lazily,
factory.buildHttpClient(isMobileDataProvider.value, userAgent),
)
fun getCurrentProxy(): Proxy? = defaultHttpClient.value.proxy
fun getCurrentProxyPort(useProxy: Boolean): Int? =
if (useProxy) {
(getCurrentProxy()?.address() as? InetSocketAddress)?.port
} else {
null
}
fun getHttpClient(useProxy: Boolean): OkHttpClient =
if (useProxy) {
defaultHttpClient.value
} else {
defaultHttpClientWithoutProxy.value
}
}
@@ -1,137 +0,0 @@
/**
* Copyright (c) 2024 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.okhttp
import android.R.attr.port
import android.util.Log
import com.vitorpamplona.quartz.nip17Dm.files.encryption.NostrCipher
import okhttp3.OkHttpClient
import java.net.InetSocketAddress
import java.net.Proxy
import java.time.Duration
object HttpClientManager {
private val rootClient =
OkHttpClient
.Builder()
.followRedirects(true)
.followSslRedirects(true)
.build()
val DEFAULT_TOR_PROXY = Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", 9050))
val DEFAULT_TIMEOUT_ON_WIFI: Duration = Duration.ofSeconds(10L)
val DEFAULT_TIMEOUT_ON_MOBILE: Duration = Duration.ofSeconds(30L)
private var defaultTimeout = DEFAULT_TIMEOUT_ON_WIFI
private var defaultHttpClient: OkHttpClient? = null
private var defaultHttpClientWithoutProxy: OkHttpClient? = null
private var userAgent: String = "Amethyst"
private var currentProxy: Proxy? = DEFAULT_TOR_PROXY
private val cache = EncryptionKeyCache()
fun setDefaultProxy(proxy: Proxy?) {
if (currentProxy != proxy) {
Log.d("HttpClient", "Changing proxy to: $proxy")
currentProxy = proxy
// recreates singleton
defaultHttpClient = buildHttpClient(currentProxy, defaultTimeout)
}
}
fun getCurrentProxy(): Proxy? = currentProxy
fun setDefaultTimeout(timeout: Duration) {
Log.d("HttpClient", "Changing timeout to: $timeout")
if (defaultTimeout.seconds != timeout.seconds) {
defaultTimeout = timeout
// recreates singleton
defaultHttpClient = buildHttpClient(currentProxy, defaultTimeout)
defaultHttpClientWithoutProxy = buildHttpClient(null, defaultTimeout)
}
}
fun setDefaultUserAgent(userAgentHeader: String) {
Log.d("HttpClient", "Changing userAgent")
if (userAgent != userAgentHeader) {
userAgent = userAgentHeader
defaultHttpClient = buildHttpClient(currentProxy, defaultTimeout)
defaultHttpClientWithoutProxy = buildHttpClient(null, defaultTimeout)
}
}
private fun buildHttpClient(
proxy: Proxy?,
timeout: Duration,
): OkHttpClient {
val seconds = if (proxy != null) timeout.seconds * 3 else timeout.seconds
val duration = Duration.ofSeconds(seconds)
return rootClient
.newBuilder()
.proxy(proxy)
.readTimeout(duration)
.connectTimeout(duration)
.writeTimeout(duration)
.addInterceptor(DefaultContentTypeInterceptor(userAgent))
.addNetworkInterceptor(LoggingInterceptor())
.addNetworkInterceptor(EncryptedBlobInterceptor(cache))
.build()
}
fun getCurrentProxyPort(useProxy: Boolean): Int? =
if (useProxy) {
(currentProxy?.address() as? InetSocketAddress)?.port
} else {
null
}
fun getHttpClient(useProxy: Boolean): OkHttpClient =
if (useProxy) {
if (defaultHttpClient == null) {
defaultHttpClient = buildHttpClient(currentProxy, defaultTimeout)
}
defaultHttpClient!!
} else {
if (defaultHttpClientWithoutProxy == null) {
defaultHttpClientWithoutProxy = buildHttpClient(null, defaultTimeout)
}
defaultHttpClientWithoutProxy!!
}
fun setProxyNotReady() {
// this blocks all connections unless Orbot is live.
setDefaultProxy(DEFAULT_TOR_PROXY)
}
fun setDefaultProxyOnPort(port: Int) {
setDefaultProxy(Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", port)))
}
fun addCipherToCache(
url: String,
cipher: NostrCipher,
expectedMimeType: String?,
) = cache.add(url, cipher, expectedMimeType)
}
@@ -0,0 +1,94 @@
/**
* Copyright (c) 2024 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.okhttp
import okhttp3.OkHttpClient
import java.net.InetSocketAddress
import java.net.Proxy
import java.time.Duration
class OkHttpClientFactory(
val keyCache: EncryptionKeyCache,
) {
companion object {
// by picking a random proxy port, the connection will fail as it shouold.
const val DEFAULT_SOCKS_PORT: Int = 9050
const val DEFAULT_IS_MOBILE: Boolean = false
const val DEFAULT_TIMEOUT_ON_WIFI_SECS: Int = 10
const val DEFAULT_TIMEOUT_ON_MOBILE_SECS: Int = 30
}
private val rootClient =
OkHttpClient
.Builder()
.followRedirects(true)
.followSslRedirects(true)
.build()
fun buildHttpClient(
proxy: Proxy?,
timeoutSeconds: Int,
userAgent: String,
): OkHttpClient {
val seconds = if (proxy != null) timeoutSeconds * 3 else timeoutSeconds
val duration = Duration.ofSeconds(seconds.toLong())
return rootClient
.newBuilder()
.proxy(proxy)
.readTimeout(duration)
.connectTimeout(duration)
.writeTimeout(duration)
.addInterceptor(DefaultContentTypeInterceptor(userAgent))
.addNetworkInterceptor(LoggingInterceptor())
.addNetworkInterceptor(EncryptedBlobInterceptor(keyCache))
.build()
}
fun buildHttpClient(
localSocksProxyPort: Int?,
isMobile: Boolean?,
userAgent: String,
): OkHttpClient =
buildHttpClient(
buildLocalSocksProxy(localSocksProxyPort),
buildTimeout(isMobile ?: DEFAULT_IS_MOBILE),
userAgent,
)
fun buildHttpClient(
isMobile: Boolean?,
userAgent: String,
): OkHttpClient =
buildHttpClient(
null,
buildTimeout(isMobile ?: DEFAULT_IS_MOBILE),
userAgent,
)
fun buildTimeout(isMobile: Boolean): Int =
if (isMobile) {
DEFAULT_TIMEOUT_ON_MOBILE_SECS
} else {
DEFAULT_TIMEOUT_ON_WIFI_SECS
}
fun buildLocalSocksProxy(port: Int?) = Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", port ?: DEFAULT_SOCKS_PORT))
}
@@ -24,12 +24,14 @@ import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocket
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocketListener import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocketListener
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilderFactory import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilderFactory
import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
class OkHttpWebSocket( class OkHttpWebSocket(
val url: String, val url: String,
val forceProxy: Boolean, val forceProxy: Boolean,
val httpClient: (url: String, forceProxy: Boolean) -> OkHttpClient,
val out: WebSocketListener, val out: WebSocketListener,
) : WebSocket { ) : WebSocket {
private val listener = OkHttpWebsocketListener() private val listener = OkHttpWebsocketListener()
@@ -38,7 +40,7 @@ class OkHttpWebSocket(
fun buildRequest() = Request.Builder().url(url.trim()).build() fun buildRequest() = Request.Builder().url(url.trim()).build()
override fun connect() { override fun connect() {
socket = HttpClientManager.getHttpClient(forceProxy).newWebSocket(buildRequest(), listener) socket = httpClient(url, forceProxy).newWebSocket(buildRequest(), listener)
} }
inner class OkHttpWebsocketListener : okhttp3.WebSocketListener() { inner class OkHttpWebsocketListener : okhttp3.WebSocketListener() {
@@ -76,15 +78,22 @@ class OkHttpWebSocket(
class Builder( class Builder(
val forceProxy: Boolean, val forceProxy: Boolean,
val httpClient: (String, Boolean) -> OkHttpClient,
) : WebsocketBuilder { ) : WebsocketBuilder {
// Called when connecting.
override fun build( override fun build(
url: String, url: String,
out: WebSocketListener, out: WebSocketListener,
) = OkHttpWebSocket(url, forceProxy, out) ) = OkHttpWebSocket(url, forceProxy, httpClient, out)
} }
class BuilderFactory : WebsocketBuilderFactory { class BuilderFactory(
override fun build(forceProxy: Boolean) = Builder(forceProxy) val httpClient: (String, Boolean) -> OkHttpClient,
) : WebsocketBuilderFactory {
override fun build(
url: String,
forceProxy: Boolean,
) = Builder(forceProxy, httpClient)
} }
override fun cancel() { override fun cancel() {
@@ -21,21 +21,19 @@
package com.vitorpamplona.amethyst.service.ots package com.vitorpamplona.amethyst.service.ots
import android.util.Log import android.util.Log
import android.util.LruCache
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.service.okhttp.HttpClientManager
import com.vitorpamplona.quartz.nip03Timestamp.ots.BitcoinExplorer import com.vitorpamplona.quartz.nip03Timestamp.ots.BitcoinExplorer
import com.vitorpamplona.quartz.nip03Timestamp.ots.BlockHeader import com.vitorpamplona.quartz.nip03Timestamp.ots.BlockHeader
import com.vitorpamplona.quartz.nip03Timestamp.ots.exceptions.UrlException import com.vitorpamplona.quartz.nip03Timestamp.ots.exceptions.UrlException
import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
class OkHttpBlockstreamExplorer( class OkHttpBitcoinExplorer(
val forceProxy: (String) -> Boolean, val baseAPI: String,
val client: OkHttpClient,
val cache: OtsBlockHeightCache,
) : BitcoinExplorer { ) : BitcoinExplorer {
private val cacheHeaders = LruCache<String, BlockHeader>(100)
private val cacheHeights = LruCache<Int, String>(100)
/** /**
* Retrieve the block information from the block hash. * Retrieve the block information from the block hash.
* *
@@ -44,13 +42,11 @@ class OkHttpBlockstreamExplorer(
* @throws Exception desc * @throws Exception desc
*/ */
override fun block(hash: String): BlockHeader { override fun block(hash: String): BlockHeader {
cacheHeaders.get(hash)?.let { cache.cacheHeaders.get(hash)?.let {
return it return it
} }
val usingTor = forceProxy(BLOCKSTREAM_API_URL) val url = "$baseAPI/block/$hash"
val url = "${getAPI(usingTor)}/block/$hash"
val client = HttpClientManager.getHttpClient(forceProxy(url))
val request = val request =
Request Request
@@ -63,17 +59,15 @@ class OkHttpBlockstreamExplorer(
client.newCall(request).execute().use { client.newCall(request).execute().use {
if (it.isSuccessful) { if (it.isSuccessful) {
Log.d("OkHttpBlockstreamExplorer", "$baseAPI/block/$hash")
val jsonObject = jacksonObjectMapper().readTree(it.body.string()) val jsonObject = jacksonObjectMapper().readTree(it.body.string())
val blockHeader = val blockHeader = BlockHeader()
BlockHeader()
blockHeader.merkleroot = jsonObject["merkle_root"].asText() blockHeader.merkleroot = jsonObject["merkle_root"].asText()
blockHeader.setTime(jsonObject["timestamp"].asInt().toString()) blockHeader.setTime(jsonObject["timestamp"].asInt().toString())
blockHeader.blockHash = hash blockHeader.blockHash = hash
Log.d("OkHttpBlockstreamExplorer", "$BLOCKSTREAM_API_URL/block/$hash") cache.cacheHeaders.put(hash, blockHeader)
cacheHeaders.put(hash, blockHeader)
return blockHeader return blockHeader
} else { } else {
throw UrlException( throw UrlException(
@@ -92,13 +86,11 @@ class OkHttpBlockstreamExplorer(
*/ */
@Throws(Exception::class) @Throws(Exception::class)
override fun blockHash(height: Int): String { override fun blockHash(height: Int): String {
cacheHeights[height]?.let { cache.cacheHeights[height]?.let {
return it return it
} }
val usingTor = forceProxy(BLOCKSTREAM_API_URL) val url = "$baseAPI/block-height/$height"
val url = "${getAPI(usingTor)}/block-height/$height"
val client = HttpClientManager.getHttpClient(usingTor)
val request = val request =
Request Request
@@ -114,25 +106,19 @@ class OkHttpBlockstreamExplorer(
Log.d("OkHttpBlockstreamExplorer", "$url $blockHash") Log.d("OkHttpBlockstreamExplorer", "$url $blockHash")
cacheHeights.put(height, blockHash) cache.cacheHeights.put(height, blockHash)
return blockHash return blockHash
} else { } else {
throw UrlException( throw UrlException("Couldn't open $url: " + it.message + " " + it.code)
"Couldn't open $url: " + it.message + " " + it.code,
)
} }
} }
} }
companion object { companion object {
private const val BLOCKSTREAM_API_URL = "https://blockstream.info/api" // doesn't accept Tor
private const val MEMPOOL_API_URL = "https://mempool.space/api/" const val BLOCKSTREAM_API_URL = "https://blockstream.info/api"
fun getAPI(usingTor: Boolean) = // accepts Tor
if (usingTor) { const val MEMPOOL_API_URL = "https://mempool.space/api/"
MEMPOOL_API_URL
} else {
BLOCKSTREAM_API_URL
}
} }
} }
@@ -21,7 +21,6 @@
package com.vitorpamplona.amethyst.service.ots package com.vitorpamplona.amethyst.service.ots
import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.service.okhttp.HttpClientManager
import com.vitorpamplona.quartz.nip03Timestamp.ots.ICalendar import com.vitorpamplona.quartz.nip03Timestamp.ots.ICalendar
import com.vitorpamplona.quartz.nip03Timestamp.ots.StreamDeserializationContext import com.vitorpamplona.quartz.nip03Timestamp.ots.StreamDeserializationContext
import com.vitorpamplona.quartz.nip03Timestamp.ots.Timestamp import com.vitorpamplona.quartz.nip03Timestamp.ots.Timestamp
@@ -31,6 +30,7 @@ import com.vitorpamplona.quartz.nip03Timestamp.ots.exceptions.ExceededSizeExcept
import com.vitorpamplona.quartz.nip03Timestamp.ots.exceptions.UrlException import com.vitorpamplona.quartz.nip03Timestamp.ots.exceptions.UrlException
import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.Hex
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
/** /**
@@ -38,7 +38,7 @@ import okhttp3.RequestBody.Companion.toRequestBody
*/ */
class OkHttpCalendar( class OkHttpCalendar(
val url: String, val url: String,
val forceProxy: Boolean, val client: OkHttpClient,
) : ICalendar { ) : ICalendar {
/** /**
* Submitting a digest to remote calendar. Returns a com.eternitywall.ots.Timestamp committing to that digest. * Submitting a digest to remote calendar. Returns a com.eternitywall.ots.Timestamp committing to that digest.
@@ -52,7 +52,6 @@ class OkHttpCalendar(
@Throws(ExceededSizeException::class, UrlException::class, DeserializationException::class) @Throws(ExceededSizeException::class, UrlException::class, DeserializationException::class)
override fun submit(digest: ByteArray): Timestamp { override fun submit(digest: ByteArray): Timestamp {
try { try {
val client = HttpClientManager.getHttpClient(forceProxy)
val url = "$url/digest" val url = "$url/digest"
val mediaType = "application/x-www-form-urlencoded; charset=utf-8".toMediaType() val mediaType = "application/x-www-form-urlencoded; charset=utf-8".toMediaType()
@@ -108,7 +107,6 @@ class OkHttpCalendar(
) )
override fun getTimestamp(commitment: ByteArray): Timestamp { override fun getTimestamp(commitment: ByteArray): Timestamp {
try { try {
val client = HttpClientManager.getHttpClient(forceProxy)
val url = url + "/timestamp/" + Hex.encode(commitment) val url = url + "/timestamp/" + Hex.encode(commitment)
val request = val request =
@@ -21,14 +21,13 @@
package com.vitorpamplona.amethyst.service.ots package com.vitorpamplona.amethyst.service.ots
import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.service.okhttp.HttpClientManager
import com.vitorpamplona.quartz.nip03Timestamp.ots.ICalendarAsyncSubmit import com.vitorpamplona.quartz.nip03Timestamp.ots.ICalendarAsyncSubmit
import com.vitorpamplona.quartz.nip03Timestamp.ots.StreamDeserializationContext import com.vitorpamplona.quartz.nip03Timestamp.ots.StreamDeserializationContext
import com.vitorpamplona.quartz.nip03Timestamp.ots.Timestamp import com.vitorpamplona.quartz.nip03Timestamp.ots.Timestamp
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import java.util.Optional import java.util.Optional
import java.util.concurrent.BlockingQueue
/** /**
* For making async calls to a calendar server * For making async calls to a calendar server
@@ -36,19 +35,10 @@ import java.util.concurrent.BlockingQueue
class OkHttpCalendarAsyncSubmit( class OkHttpCalendarAsyncSubmit(
private val url: String, private val url: String,
private val digest: ByteArray, private val digest: ByteArray,
private val forceProxy: Boolean, private val client: OkHttpClient,
) : ICalendarAsyncSubmit { ) : ICalendarAsyncSubmit {
private var queue: BlockingQueue<Optional<Timestamp>>? = null
fun setQueue(queue: BlockingQueue<Optional<Timestamp>>?) {
this.queue = queue
}
@Throws(Exception::class)
override fun call(): Optional<Timestamp> { override fun call(): Optional<Timestamp> {
val client = HttpClientManager.getHttpClient(forceProxy)
val url = "$url/digest" val url = "$url/digest"
val mediaType = "application/x-www-form-urlencoded; charset=utf-8".toMediaType() val mediaType = "application/x-www-form-urlencoded; charset=utf-8".toMediaType()
val requestBody = digest.toRequestBody(mediaType) val requestBody = digest.toRequestBody(mediaType)
@@ -69,11 +59,8 @@ class OkHttpCalendarAsyncSubmit(
it.body.bytes(), it.body.bytes(),
) )
val timestamp = Timestamp.deserialize(ctx, digest) val timestamp = Timestamp.deserialize(ctx, digest)
val of = Optional.of(timestamp) return Optional.of(timestamp)
queue!!.add(of)
return of
} else { } else {
queue!!.add(Optional.empty())
return Optional.empty() return Optional.empty()
} }
} }
@@ -23,14 +23,15 @@ package com.vitorpamplona.amethyst.service.ots
import com.vitorpamplona.quartz.nip03Timestamp.ots.CalendarBuilder import com.vitorpamplona.quartz.nip03Timestamp.ots.CalendarBuilder
import com.vitorpamplona.quartz.nip03Timestamp.ots.ICalendar import com.vitorpamplona.quartz.nip03Timestamp.ots.ICalendar
import com.vitorpamplona.quartz.nip03Timestamp.ots.ICalendarAsyncSubmit import com.vitorpamplona.quartz.nip03Timestamp.ots.ICalendarAsyncSubmit
import okhttp3.OkHttpClient
class OkHttpCalendarBuilder( class OkHttpCalendarBuilder(
val forceProxy: (String) -> Boolean, val clientFn: (url: String) -> OkHttpClient,
) : CalendarBuilder { ) : CalendarBuilder {
override fun newSyncCalendar(url: String): ICalendar = OkHttpCalendar(url, forceProxy(url)) override fun newSyncCalendar(url: String): ICalendar = OkHttpCalendar(url, clientFn(url))
override fun newAsyncCalendar( override fun newAsyncCalendar(
url: String, url: String,
digest: ByteArray, digest: ByteArray,
): ICalendarAsyncSubmit = OkHttpCalendarAsyncSubmit(url, digest, forceProxy(url)) ): ICalendarAsyncSubmit = OkHttpCalendarAsyncSubmit(url, digest, clientFn(url))
} }
@@ -0,0 +1,29 @@
/**
* Copyright (c) 2024 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.ots
import android.util.LruCache
import com.vitorpamplona.quartz.nip03Timestamp.ots.BlockHeader
class OtsBlockHeightCache {
val cacheHeaders = LruCache<String, BlockHeader>(100)
val cacheHeights = LruCache<Int, String>(100)
}
@@ -0,0 +1,52 @@
/**
* Copyright (c) 2024 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.ots
import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManager
import com.vitorpamplona.quartz.nip03Timestamp.OtsResolver
class OtsResolverBuilder {
fun getAPI(usingTor: Boolean) =
if (usingTor) {
OkHttpBitcoinExplorer.MEMPOOL_API_URL
} else {
OkHttpBitcoinExplorer.BLOCKSTREAM_API_URL
}
fun build(
okHttpClients: DualHttpClientManager,
shouldUseTorForUrl: (String) -> Boolean,
cache: OtsBlockHeightCache,
): OtsResolver {
val shouldUseTor = shouldUseTorForUrl(OkHttpBitcoinExplorer.MEMPOOL_API_URL)
return OtsResolver(
OkHttpBitcoinExplorer(
getAPI(shouldUseTor),
okHttpClients.getHttpClient(shouldUseTor),
cache,
),
OkHttpCalendarBuilder {
okHttpClients.getHttpClient(shouldUseTorForUrl(it))
},
)
}
}
@@ -61,7 +61,7 @@ fun GetVideoController(
// If there is a connection, don't wait. // If there is a connection, don't wait.
if (!onlyOnePreparing.getAndSet(true)) { if (!onlyOnePreparing.getAndSet(true)) {
scope.launch { scope.launch {
Log.d("PlaybackService", "Preparing Video ${controllerId.id} $mediaItem.src.videoUri") Log.d("PlaybackService", "Preparing Video ${controllerId.id} ${mediaItem.src.videoUri}")
PlaybackServiceClient.prepareController( PlaybackServiceClient.prepareController(
controllerId, controllerId,
mediaItem.src.videoUri, mediaItem.src.videoUri,
@@ -26,7 +26,6 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import com.vitorpamplona.amethyst.service.okhttp.HttpClientManager
import com.vitorpamplona.amethyst.service.playback.composable.mainVideo.VideoPlayerActiveMutex import com.vitorpamplona.amethyst.service.playback.composable.mainVideo.VideoPlayerActiveMutex
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.GetMediaItem import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.GetMediaItem
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -64,10 +63,7 @@ fun VideoViewInner(
nostrUriCallback, nostrUriCallback,
mimeType, mimeType,
aspectRatio, aspectRatio,
proxyPort = proxyPort = accountViewModel.proxyPortFor(videoUri),
HttpClientManager.getCurrentProxyPort(
accountViewModel.account.shouldUseTorForVideoDownload(videoUri),
),
) { mediaItem -> ) { mediaItem ->
GetVideoController( GetVideoController(
mediaItem = mediaItem, mediaItem = mediaItem,
@@ -91,7 +91,7 @@ private fun saveMediaToGalleryInner(
) { ) {
MediaSaverToDisk.saveDownloadingIfNeeded( MediaSaverToDisk.saveDownloadingIfNeeded(
videoUri = videoUri, videoUri = videoUri,
forceProxy = accountViewModel.account.shouldUseTorForVideoDownload(), okHttpClient = accountViewModel::okHttpClientForVideo,
mimeType = mimeType, mimeType = mimeType,
localContext = localContext, localContext = localContext,
onSuccess = { onSuccess = {
@@ -32,26 +32,6 @@ import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import java.io.File import java.io.File
/**
* Copyright (c) 2024 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.
*/
@SuppressLint("UnsafeOptInUsageError") @SuppressLint("UnsafeOptInUsageError")
class VideoCache { class VideoCache {
var exoPlayerCacheSize: Long = 150 * 1024 * 1024 // 150MB var exoPlayerCacheSize: Long = 150 * 1024 * 1024 // 150MB
@@ -0,0 +1,40 @@
/**
* Copyright (c) 2024 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.playback.diskCache
import android.app.Application
import com.vitorpamplona.amethyst.service.safeCacheDir
import kotlinx.coroutines.runBlocking
class VideoCacheFactory {
companion object {
fun new(app: Application): VideoCache {
val newCache = VideoCache()
runBlocking {
newCache.initFileCache(
app,
app.safeCacheDir().resolve("exoplayer"),
)
}
return newCache
}
}
}
@@ -27,15 +27,18 @@ import androidx.annotation.OptIn
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DataSourceBitmapLoader
import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.session.MediaSession import androidx.media3.session.MediaSession
import com.google.common.util.concurrent.Futures import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListenableFuture import com.google.common.util.concurrent.ListenableFuture
import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.ui.MainActivity
import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
class SessionListener( class SessionListener(
val session: MediaSession, val session: MediaSession,
@@ -51,6 +54,7 @@ class SessionListener(
*/ */
class MediaSessionPool( class MediaSessionPool(
val exoPlayerPool: ExoPlayerPool, val exoPlayerPool: ExoPlayerPool,
val okHttpClient: OkHttpClient,
val reset: (MediaSession) -> Unit, val reset: (MediaSession) -> Unit,
) { ) {
val globalCallback = MediaSessionCallback(this) val globalCallback = MediaSessionCallback(this)
@@ -79,6 +83,7 @@ class MediaSessionPool(
} }
} }
@OptIn(UnstableApi::class)
fun newSession( fun newSession(
id: String, id: String,
context: Context, context: Context,
@@ -87,6 +92,12 @@ class MediaSessionPool(
MediaSession MediaSession
.Builder(context, exoPlayerPool.acquirePlayer(context)) .Builder(context, exoPlayerPool.acquirePlayer(context))
.apply { .apply {
setBitmapLoader(
DataSourceBitmapLoader(
DataSourceBitmapLoader.DEFAULT_EXECUTOR_SERVICE.get(),
OkHttpDataSource.Factory(okHttpClient),
),
)
setId(id) setId(id)
setCallback(globalCallback) setCallback(globalCallback)
}.build() }.build()
@@ -180,7 +191,7 @@ class MediaSessionPool(
// set up return call when clicking on the Notification bar // set up return call when clicking on the Notification bar
mediaItems.firstOrNull()?.mediaMetadata?.extras?.getString("callbackUri")?.let { mediaItems.firstOrNull()?.mediaMetadata?.extras?.getString("callbackUri")?.let {
mediaSession.setSessionActivity(Amethyst.Companion.instance.createIntent(it)) mediaSession.setSessionActivity(MainActivity.createIntent(it))
} }
return Futures.immediateFuture(mediaItems) return Futures.immediateFuture(mediaItems)
@@ -28,7 +28,7 @@ import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.session.MediaSession import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSessionService import androidx.media3.session.MediaSessionService
import com.vitorpamplona.amethyst.service.okhttp.HttpClientManager import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.service.playback.pip.BackgroundMedia import com.vitorpamplona.amethyst.service.playback.pip.BackgroundMedia
import com.vitorpamplona.amethyst.service.playback.playerPool.ExoPlayerBuilder import com.vitorpamplona.amethyst.service.playback.playerPool.ExoPlayerBuilder
import com.vitorpamplona.amethyst.service.playback.playerPool.ExoPlayerPool import com.vitorpamplona.amethyst.service.playback.playerPool.ExoPlayerPool
@@ -43,6 +43,7 @@ class PlaybackService : MediaSessionService() {
fun newPool(okHttp: OkHttpClient): MediaSessionPool = fun newPool(okHttp: OkHttpClient): MediaSessionPool =
MediaSessionPool( MediaSessionPool(
ExoPlayerPool(ExoPlayerBuilder(okHttp)), ExoPlayerPool(ExoPlayerBuilder(okHttp)),
okHttpClient = okHttp,
reset = { session -> reset = { session ->
(session.player as ExoPlayer).apply { (session.player as ExoPlayer).apply {
repeatMode = Player.REPEAT_MODE_ONE repeatMode = Player.REPEAT_MODE_ONE
@@ -59,12 +60,12 @@ class PlaybackService : MediaSessionService() {
poolNoProxy?.let { return it } poolNoProxy?.let { return it }
// creates new // creates new
return newPool(HttpClientManager.getHttpClient(false)).also { poolNoProxy = it } return newPool(Amethyst.instance.okHttpClients.getHttpClient(false)).also { poolNoProxy = it }
} else { } else {
poolWithProxy?.let { pool -> poolWithProxy?.let { pool ->
// with proxy, check if the port is the same. // with proxy, check if the port is the same.
val okHttp = HttpClientManager.getHttpClient(true) val okHttp = Amethyst.instance.okHttpClients.getHttpClient(true)
if (okHttp.proxy == pool.exoPlayerPool.builder.okHttp.proxy) { if (okHttp.proxy != null && okHttp.proxy == pool.exoPlayerPool.builder.okHttp.proxy) {
return pool return pool
} }
@@ -73,12 +74,17 @@ class PlaybackService : MediaSessionService() {
} }
// creates brand new // creates brand new
return newPool(HttpClientManager.getHttpClient(true)).also { poolWithProxy = it } return newPool(Amethyst.instance.okHttpClients.getHttpClient(true)).also { poolWithProxy = it }
} }
} }
override fun onCreate() {
super.onCreate()
Log.d("PlaybackService", "PlaybackService.onCreate")
}
override fun onDestroy() { override fun onDestroy() {
Log.d("Lifetime Event", "PlaybackService.onDestroy") Log.d("PlaybackService", "PlaybackService.onDestroy")
poolWithProxy?.destroy() poolWithProxy?.destroy()
poolNoProxy?.destroy() poolNoProxy?.destroy()
@@ -21,21 +21,21 @@
package com.vitorpamplona.amethyst.service.previews package com.vitorpamplona.amethyst.service.previews
import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.okhttp.HttpClientManager
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
class UrlPreview { class UrlPreview {
suspend fun fetch( suspend fun fetch(
url: String, url: String,
forceProxy: Boolean, okHttpClient: (String) -> OkHttpClient,
onComplete: suspend (urlInfo: UrlInfoItem) -> Unit, onComplete: suspend (urlInfo: UrlInfoItem) -> Unit,
onFailed: suspend (t: Throwable) -> Unit, onFailed: suspend (t: Throwable) -> Unit,
) = try { ) = try {
onComplete(getDocument(url, forceProxy)) onComplete(getDocument(url, okHttpClient))
} catch (t: Throwable) { } catch (t: Throwable) {
if (t is CancellationException) throw t if (t is CancellationException) throw t
onFailed(t) onFailed(t)
@@ -43,7 +43,7 @@ class UrlPreview {
suspend fun getDocument( suspend fun getDocument(
url: String, url: String,
forceProxy: Boolean, okHttpClient: (String) -> OkHttpClient,
): UrlInfoItem = ): UrlInfoItem =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val request = val request =
@@ -52,7 +52,7 @@ class UrlPreview {
.url(url) .url(url)
.get() .get()
.build() .build()
HttpClientManager.getHttpClient(forceProxy).newCall(request).execute().use { okHttpClient(url).newCall(request).execute().use {
checkNotInMainThread() checkNotInMainThread()
if (it.isSuccessful) { if (it.isSuccessful) {
val mimeType = val mimeType =
@@ -0,0 +1,34 @@
/**
* Copyright (c) 2024 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.proxyPort
import com.vitorpamplona.amethyst.Amethyst
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.onEach
class ImageProxyFlow(
image: StateFlow<Boolean>,
) {
val status =
image.onEach {
Amethyst.instance.setImageLoader(it)
}
}
@@ -0,0 +1,93 @@
/**
* Copyright (c) 2024 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.proxyPort
import com.vitorpamplona.amethyst.ui.tor.TorServiceStatus
import com.vitorpamplona.amethyst.ui.tor.TorType
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
class ProxyPortFlow(
torType: MutableStateFlow<TorType>,
externalSocksPort: MutableStateFlow<Int>,
torServiceStatus: StateFlow<TorServiceStatus>,
) {
@OptIn(ExperimentalCoroutinesApi::class)
val status =
torType
.flatMapLatest { torType ->
when (torType) {
TorType.INTERNAL -> {
// subscribing to status turns Tor service on
torServiceStatus.map {
if (it is TorServiceStatus.Active) {
it.port
} else {
null
}
}
}
TorType.EXTERNAL -> {
externalSocksPort.map { port ->
if (port > 0) {
port
} else {
null
}
}
}
else -> MutableStateFlow(null)
}
}.distinctUntilChanged()
companion object {
fun computePort(
torType: TorType,
externalPort: Int,
status: TorServiceStatus,
): Int? =
when (torType) {
TorType.INTERNAL -> {
if (status is TorServiceStatus.Active && status.port > 0) {
status.port
} else {
null
}
}
TorType.EXTERNAL -> {
if (externalPort > 0) {
externalPort
} else {
null
}
}
else -> null
}
}
}
@@ -0,0 +1,56 @@
/**
* Copyright (c) 2024 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.relays
import android.app.Application
import com.vitorpamplona.amethyst.service.connectivity.ConnectivityManager
import com.vitorpamplona.amethyst.ui.tor.TorManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
/**
* There should be only one instance of the Tor binding per app.
*
* Tor will connect as soon as status is listened to.
*/
class RelayManager(
app: Application,
scope: CoroutineScope,
torManager: TorManager,
connManager: ConnectivityManager,
) {
val relayService =
combine(
torManager.status,
connManager.status,
) { torStatus, connManager ->
}
val status: StateFlow<RelayServiceStatus> =
RelayService(app).status.stateIn(
scope,
SharingStarted.WhileSubscribed(30000),
RelayServiceStatus.Off,
)
}
@@ -18,34 +18,31 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * 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. * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
package com.vitorpamplona.amethyst.ui.components package com.vitorpamplona.amethyst.service.relays
import androidx.compose.foundation.text.ClickableText import android.content.Context
import androidx.compose.material3.LocalTextStyle import android.util.Log
import androidx.compose.material3.MaterialTheme import kotlinx.coroutines.channels.awaitClose
import androidx.compose.runtime.Composable import kotlinx.coroutines.flow.callbackFlow
import androidx.compose.runtime.getValue import kotlinx.coroutines.flow.distinctUntilChanged
import androidx.compose.runtime.livedata.observeAsState import kotlinx.coroutines.launch
import androidx.compose.runtime.remember
import androidx.compose.ui.text.AnnotatedString
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.navigation.INav
@Composable class RelayService(
fun ClickableUserTag( val context: Context,
user: User,
nav: INav,
) { ) {
val route = remember { "User/${user.pubkeyHex}" } val status =
callbackFlow {
Log.d("RelayService", "Starting Relay Services")
trySend(RelayServiceStatus.Connecting)
val innerUserState by user.live().metadata.observeAsState() // ServiceManager
val userName = awaitClose {
remember(innerUserState) { AnnotatedString("@${innerUserState?.user?.toBestDisplayName()}") } Log.d("RelayService", "Stopping Relay Services")
launch {
ClickableText( // ServiceManager.pauseAndLogOff()
text = userName, }
onClick = { nav.nav(route) }, trySend(RelayServiceStatus.Off)
style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary), }
) }.distinctUntilChanged()
} }
@@ -0,0 +1,33 @@
/**
* Copyright (c) 2024 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.relays
import com.vitorpamplona.ammolite.relays.NostrClient
sealed class RelayServiceStatus {
data class Active(
val client: NostrClient,
) : RelayServiceStatus()
object Off : RelayServiceStatus()
object Connecting : RelayServiceStatus()
}
@@ -26,11 +26,12 @@ import android.media.MediaDataSource
import android.media.MediaMetadataRetriever import android.media.MediaMetadataRetriever
import android.util.Log import android.util.Log
import com.vitorpamplona.amethyst.commons.blurhash.toBlurhash import com.vitorpamplona.amethyst.commons.blurhash.toBlurhash
import com.vitorpamplona.amethyst.service.Blurhash import com.vitorpamplona.amethyst.service.images.BlurhashWrapper
import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
import com.vitorpamplona.quartz.utils.sha256.sha256 import com.vitorpamplona.quartz.utils.sha256.sha256
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import okhttp3.OkHttpClient
import java.io.IOException import java.io.IOException
class FileHeader( class FileHeader(
@@ -38,7 +39,7 @@ class FileHeader(
val hash: String, val hash: String,
val size: Int, val size: Int,
val dim: DimensionTag?, val dim: DimensionTag?,
val blurHash: Blurhash?, val blurHash: BlurhashWrapper?,
) { ) {
class UnableToDownload( class UnableToDownload(
val fileUrl: String, val fileUrl: String,
@@ -49,10 +50,10 @@ class FileHeader(
fileUrl: String, fileUrl: String,
mimeType: String?, mimeType: String?,
dimPrecomputed: DimensionTag?, dimPrecomputed: DimensionTag?,
forceProxy: Boolean, okHttpClient: (String) -> OkHttpClient,
): Result<FileHeader> = ): Result<FileHeader> =
try { try {
val imageData: ImageDownloader.Blob? = ImageDownloader().waitAndGetImage(fileUrl, forceProxy) val imageData: ImageDownloader.Blob? = ImageDownloader().waitAndGetImage(fileUrl, okHttpClient)
if (imageData != null) { if (imageData != null) {
prepare(imageData.bytes, mimeType ?: imageData.contentType, dimPrecomputed) prepare(imageData.bytes, mimeType ?: imageData.contentType, dimPrecomputed)
@@ -79,13 +80,13 @@ class FileHeader(
val opt = BitmapFactory.Options() val opt = BitmapFactory.Options()
opt.inPreferredConfig = Bitmap.Config.ARGB_8888 opt.inPreferredConfig = Bitmap.Config.ARGB_8888
val mBitmap = BitmapFactory.decodeByteArray(data, 0, data.size, opt) val mBitmap = BitmapFactory.decodeByteArray(data, 0, data.size, opt)
Pair(Blurhash(mBitmap.toBlurhash()), DimensionTag(mBitmap.width, mBitmap.height)) Pair(BlurhashWrapper(mBitmap.toBlurhash()), DimensionTag(mBitmap.width, mBitmap.height))
} else if (mimeType?.startsWith("video/") == true) { } else if (mimeType?.startsWith("video/") == true) {
val mediaMetadataRetriever = MediaMetadataRetriever() val mediaMetadataRetriever = MediaMetadataRetriever()
mediaMetadataRetriever.setDataSource(ByteArrayMediaDataSource(data)) mediaMetadataRetriever.setDataSource(ByteArrayMediaDataSource(data))
val newDim = mediaMetadataRetriever.prepareDimFromVideo() ?: dimPrecomputed val newDim = mediaMetadataRetriever.prepareDimFromVideo() ?: dimPrecomputed
val blurhash = mediaMetadataRetriever.getThumbnail()?.toBlurhash()?.let { Blurhash(it) } val blurhash = mediaMetadataRetriever.getThumbnail()?.toBlurhash()?.let { BlurhashWrapper(it) }
if (newDim?.hasSize() == true) { if (newDim?.hasSize() == true) {
Pair(blurhash, newDim) Pair(blurhash, newDim)
@@ -20,11 +20,11 @@
*/ */
package com.vitorpamplona.amethyst.service.uploads package com.vitorpamplona.amethyst.service.uploads
import com.vitorpamplona.amethyst.service.okhttp.HttpClientManager
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import java.net.HttpURLConnection import java.net.HttpURLConnection
import java.net.URL import java.net.URL
@@ -36,7 +36,7 @@ class ImageDownloader {
suspend fun waitAndGetImage( suspend fun waitAndGetImage(
imageUrl: String, imageUrl: String,
forceProxy: Boolean, okHttpClient: (url: String) -> OkHttpClient,
): Blob? = ): Blob? =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
var imageData: Blob? = null var imageData: Blob? = null
@@ -46,7 +46,7 @@ class ImageDownloader {
while (imageData == null && tentatives < 15) { while (imageData == null && tentatives < 15) {
imageData = imageData =
try { try {
tryGetTheImage(imageUrl, forceProxy) tryGetTheImage(imageUrl, okHttpClient)
} catch (e: Exception) { } catch (e: Exception) {
if (e is CancellationException) throw e if (e is CancellationException) throw e
null null
@@ -63,15 +63,16 @@ class ImageDownloader {
private suspend fun tryGetTheImage( private suspend fun tryGetTheImage(
imageUrl: String, imageUrl: String,
forceProxy: Boolean, okHttpClient: (url: String) -> OkHttpClient,
): Blob? = ): Blob? =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
// TODO: Migrate to OkHttp // TODO: Migrate to OkHttp
HttpURLConnection.setFollowRedirects(true) HttpURLConnection.setFollowRedirects(true)
var url = URL(imageUrl) var url = URL(imageUrl)
var clientProxy = okHttpClient(imageUrl).proxy
var huc = var huc =
if (forceProxy) { if (clientProxy != null) {
url.openConnection(HttpClientManager.getCurrentProxy()) as HttpURLConnection url.openConnection(clientProxy) as HttpURLConnection
} else { } else {
url.openConnection() as HttpURLConnection url.openConnection() as HttpURLConnection
} }
@@ -83,9 +84,10 @@ class ImageDownloader {
// open the new connnection again // open the new connnection again
url = URL(newUrl) url = URL(newUrl)
clientProxy = okHttpClient(newUrl).proxy
huc = huc =
if (forceProxy) { if (clientProxy != null) {
url.openConnection(HttpClientManager.getCurrentProxy()) as HttpURLConnection url.openConnection(clientProxy) as HttpURLConnection
} else { } else {
url.openConnection() as HttpURLConnection url.openConnection() as HttpURLConnection
} }
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.service.uploads
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.uploads.UploadingState.UploadingFinalState import com.vitorpamplona.amethyst.service.uploads.UploadingState.UploadingFinalState
@@ -32,6 +33,7 @@ import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
import com.vitorpamplona.quartz.nip17Dm.files.encryption.NostrCipher import com.vitorpamplona.quartz.nip17Dm.files.encryption.NostrCipher
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import okhttp3.OkHttpClient
import kotlin.coroutines.cancellation.CancellationException import kotlin.coroutines.cancellation.CancellationException
sealed class UploadingState { sealed class UploadingState {
@@ -149,7 +151,7 @@ class UploadOrchestrator {
alt = alt, alt = alt,
sensitiveContent = contentWarningReason, sensitiveContent = contentWarningReason,
serverBaseUrl = serverBaseUrl, serverBaseUrl = serverBaseUrl,
forceProxy = account::shouldUseTorForNIP96, okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(account.shouldUseTorForNIP96(it)) },
onProgress = { percent: Float -> onProgress = { percent: Float ->
updateState(0.2 + (0.2 * percent), UploadingState.Uploading) updateState(0.2 + (0.2 * percent), UploadingState.Uploading)
}, },
@@ -162,7 +164,7 @@ class UploadOrchestrator {
localContentType = contentType, localContentType = contentType,
originalContentType = contentTypeForResult, originalContentType = contentTypeForResult,
originalHash = originalHash, originalHash = originalHash,
forceProxy = account::shouldUseTorForNIP96, okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(account.shouldUseTorForNIP96(it)) },
) )
} catch (e: Exception) { } catch (e: Exception) {
if (e is CancellationException) throw e if (e is CancellationException) throw e
@@ -193,7 +195,7 @@ class UploadOrchestrator {
alt = alt, alt = alt,
sensitiveContent = contentWarningReason, sensitiveContent = contentWarningReason,
serverBaseUrl = serverBaseUrl, serverBaseUrl = serverBaseUrl,
forceProxy = account::shouldUseTorForNIP96, okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(account.shouldUseTorForNIP96(it)) },
httpAuth = account::createBlossomUploadAuth, httpAuth = account::createBlossomUploadAuth,
context = context, context = context,
) )
@@ -201,7 +203,7 @@ class UploadOrchestrator {
verifyHeader( verifyHeader(
uploadResult = result, uploadResult = result,
localContentType = contentType, localContentType = contentType,
forceProxy = account::shouldUseTorForNIP96, okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(account.shouldUseTorForNIP96(it)) },
originalHash = originalHash, originalHash = originalHash,
originalContentType = contentTypeForResult, originalContentType = contentTypeForResult,
) )
@@ -216,7 +218,7 @@ class UploadOrchestrator {
localContentType: String?, localContentType: String?,
originalContentType: String?, originalContentType: String?,
originalHash: String?, originalHash: String?,
forceProxy: (String) -> Boolean, okHttpClient: (String) -> OkHttpClient,
): UploadingFinalState { ): UploadingFinalState {
if (uploadResult.url.isNullOrBlank()) { if (uploadResult.url.isNullOrBlank()) {
return error(R.string.server_did_not_provide_a_url_after_uploading) return error(R.string.server_did_not_provide_a_url_after_uploading)
@@ -224,7 +226,7 @@ class UploadOrchestrator {
updateState(0.6, UploadingState.Downloading) updateState(0.6, UploadingState.Downloading)
val imageData: ImageDownloader.Blob? = ImageDownloader().waitAndGetImage(uploadResult.url, forceProxy(uploadResult.url)) val imageData: ImageDownloader.Blob? = ImageDownloader().waitAndGetImage(uploadResult.url, okHttpClient)
if (imageData != null) { if (imageData != null) {
updateState(0.8, UploadingState.Hashing) updateState(0.8, UploadingState.Hashing)
@@ -31,7 +31,6 @@ import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.HttpStatusMessages import com.vitorpamplona.amethyst.service.HttpStatusMessages
import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.okhttp.HttpClientManager
import com.vitorpamplona.amethyst.service.uploads.MediaUploadResult import com.vitorpamplona.amethyst.service.uploads.MediaUploadResult
import com.vitorpamplona.amethyst.service.uploads.nip96.randomChars import com.vitorpamplona.amethyst.service.uploads.nip96.randomChars
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
@@ -40,6 +39,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.utils.sha256.sha256 import com.vitorpamplona.quartz.utils.sha256.sha256
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody import okhttp3.RequestBody
import okio.BufferedSink import okio.BufferedSink
@@ -70,7 +70,7 @@ class BlossomUploader {
alt: String?, alt: String?,
sensitiveContent: String?, sensitiveContent: String?,
serverBaseUrl: String, serverBaseUrl: String,
forceProxy: (String) -> Boolean, okHttpClient: (String) -> OkHttpClient,
httpAuth: suspend (hash: HexKey, size: Long, alt: String) -> BlossomAuthorizationEvent?, httpAuth: suspend (hash: HexKey, size: Long, alt: String) -> BlossomAuthorizationEvent?,
context: Context, context: Context,
): MediaUploadResult { ): MediaUploadResult {
@@ -103,7 +103,7 @@ class BlossomUploader {
alt, alt,
sensitiveContent, sensitiveContent,
serverBaseUrl, serverBaseUrl,
forceProxy, okHttpClient,
httpAuth, httpAuth,
context, context,
) )
@@ -123,7 +123,7 @@ class BlossomUploader {
alt: String?, alt: String?,
sensitiveContent: String?, sensitiveContent: String?,
serverBaseUrl: String, serverBaseUrl: String,
forceProxy: (String) -> Boolean, okHttpClient: (String) -> OkHttpClient,
httpAuth: suspend (hash: HexKey, size: Long, alt: String) -> BlossomAuthorizationEvent?, httpAuth: suspend (hash: HexKey, size: Long, alt: String) -> BlossomAuthorizationEvent?,
context: Context, context: Context,
): MediaUploadResult { ): MediaUploadResult {
@@ -135,7 +135,7 @@ class BlossomUploader {
val apiUrl = serverBaseUrl.removeSuffix("/") + "/upload" val apiUrl = serverBaseUrl.removeSuffix("/") + "/upload"
val client = HttpClientManager.getHttpClient(forceProxy(apiUrl)) val client = okHttpClient(apiUrl)
val requestBuilder = Request.Builder() val requestBuilder = Request.Builder()
val requestBody: RequestBody = val requestBody: RequestBody =
@@ -191,7 +191,7 @@ class BlossomUploader {
hash: String, hash: String,
contentType: String?, contentType: String?,
serverBaseUrl: String, serverBaseUrl: String,
forceProxy: (String) -> Boolean, okHttpClient: (String) -> OkHttpClient,
httpAuth: (hash: HexKey, alt: String) -> BlossomAuthorizationEvent?, httpAuth: (hash: HexKey, alt: String) -> BlossomAuthorizationEvent?,
context: Context, context: Context,
): Boolean { ): Boolean {
@@ -213,7 +213,7 @@ class BlossomUploader {
.delete() .delete()
.build() .build()
HttpClientManager.getHttpClient(forceProxy(apiUrl)).newCall(request).execute().use { response -> okHttpClient(apiUrl).newCall(request).execute().use { response ->
if (response.isSuccessful) { if (response.isSuccessful) {
return true return true
} else { } else {
@@ -0,0 +1,30 @@
/**
* Copyright (c) 2024 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.uploads.nip95
import android.app.Application
import com.vitorpamplona.amethyst.service.safeCacheDir
class Nip95CacheFactory {
companion object {
fun new(app: Application) = app.safeCacheDir().resolve("NIP95")
}
}
@@ -31,7 +31,6 @@ import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.HttpStatusMessages import com.vitorpamplona.amethyst.service.HttpStatusMessages
import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.okhttp.HttpClientManager
import com.vitorpamplona.amethyst.service.uploads.MediaUploadResult import com.vitorpamplona.amethyst.service.uploads.MediaUploadResult
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip36SensitiveContent.ContentWarningTag import com.vitorpamplona.quartz.nip36SensitiveContent.ContentWarningTag
@@ -44,6 +43,7 @@ import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody import okhttp3.MultipartBody
import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody import okhttp3.RequestBody
import okio.BufferedSink import okio.BufferedSink
@@ -62,7 +62,7 @@ class Nip96Uploader {
alt: String?, alt: String?,
sensitiveContent: String?, sensitiveContent: String?,
serverBaseUrl: String, serverBaseUrl: String,
forceProxy: (String) -> Boolean, okHttpClient: (String) -> OkHttpClient,
onProgress: (percentage: Float) -> Unit, onProgress: (percentage: Float) -> Unit,
httpAuth: suspend (String, String, ByteArray?) -> HTTPAuthorizationEvent?, httpAuth: suspend (String, String, ByteArray?) -> HTTPAuthorizationEvent?,
context: Context, context: Context,
@@ -72,8 +72,8 @@ class Nip96Uploader {
size, size,
alt, alt,
sensitiveContent, sensitiveContent,
ServerInfoRetriever().loadInfo(serverBaseUrl, forceProxy(serverBaseUrl)), ServerInfoRetriever().loadInfo(serverBaseUrl, okHttpClient),
forceProxy, okHttpClient,
onProgress, onProgress,
httpAuth, httpAuth,
context, context,
@@ -95,7 +95,7 @@ class Nip96Uploader {
alt: String?, alt: String?,
sensitiveContent: String?, sensitiveContent: String?,
server: ServerInfo, server: ServerInfo,
forceProxy: (String) -> Boolean, okHttpClient: (String) -> OkHttpClient,
onProgress: (percentage: Float) -> Unit, onProgress: (percentage: Float) -> Unit,
httpAuth: suspend (String, String, ByteArray?) -> HTTPAuthorizationEvent?, httpAuth: suspend (String, String, ByteArray?) -> HTTPAuthorizationEvent?,
context: Context, context: Context,
@@ -117,7 +117,7 @@ class Nip96Uploader {
alt, alt,
sensitiveContent, sensitiveContent,
server, server,
forceProxy, okHttpClient,
onProgress, onProgress,
httpAuth, httpAuth,
context, context,
@@ -131,7 +131,7 @@ class Nip96Uploader {
alt: String?, alt: String?,
sensitiveContent: String?, sensitiveContent: String?,
server: ServerInfo, server: ServerInfo,
forceProxy: (String) -> Boolean, okHttpClient: (String) -> OkHttpClient,
onProgress: (percentage: Float) -> Unit, onProgress: (percentage: Float) -> Unit,
httpAuth: suspend (String, String, ByteArray?) -> HTTPAuthorizationEvent?, httpAuth: suspend (String, String, ByteArray?) -> HTTPAuthorizationEvent?,
context: Context, context: Context,
@@ -141,7 +141,7 @@ class Nip96Uploader {
val fileName = randomChars() val fileName = randomChars()
val extension = contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: "" val extension = contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
val client = HttpClientManager.getHttpClient(forceProxy(server.apiUrl)) val client = okHttpClient(server.apiUrl)
val requestBuilder = Request.Builder() val requestBuilder = Request.Builder()
val requestBody: RequestBody = val requestBody: RequestBody =
@@ -182,7 +182,7 @@ class Nip96Uploader {
response.body.use { body -> response.body.use { body ->
val result = UploadResult.parse(body.string()) val result = UploadResult.parse(body.string())
if (!result.processingUrl.isNullOrBlank()) { if (!result.processingUrl.isNullOrBlank()) {
return waitProcessing(result, server, forceProxy, onProgress) return waitProcessing(result, server, okHttpClient, onProgress)
} else if (result.status == "success") { } else if (result.status == "success") {
val event = result.nip94Event val event = result.nip94Event
if (event != null) { if (event != null) {
@@ -263,14 +263,14 @@ class Nip96Uploader {
hash: String, hash: String,
contentType: String?, contentType: String?,
server: ServerInfo, server: ServerInfo,
forceProxy: (String) -> Boolean, okHttpClient: (String) -> OkHttpClient,
httpAuth: (String, String, ByteArray?) -> HTTPAuthorizationEvent?, httpAuth: (String, String, ByteArray?) -> HTTPAuthorizationEvent?,
context: Context, context: Context,
): Boolean { ): Boolean {
val extension = val extension =
contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: "" contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
val client = HttpClientManager.getHttpClient(forceProxy(server.apiUrl)) val client = okHttpClient(server.apiUrl)
val requestBuilder = Request.Builder() val requestBuilder = Request.Builder()
@@ -303,7 +303,7 @@ class Nip96Uploader {
private suspend fun waitProcessing( private suspend fun waitProcessing(
result: UploadResult, result: UploadResult,
server: ServerInfo, server: ServerInfo,
forceProxy: (String) -> Boolean, okHttpClient: (String) -> OkHttpClient,
onProgress: (percentage: Float) -> Unit, onProgress: (percentage: Float) -> Unit,
): MediaUploadResult { ): MediaUploadResult {
var currentResult = result var currentResult = result
@@ -320,7 +320,7 @@ class Nip96Uploader {
.url(procUrl) .url(procUrl)
.build() .build()
val client = HttpClientManager.getHttpClient(forceProxy(procUrl)) val client = okHttpClient(procUrl)
client.newCall(request).execute().use { client.newCall(request).execute().use {
if (it.isSuccessful) { if (it.isSuccessful) {
it.body.use { currentResult = UploadResult.parse(it.string()) } it.body.use { currentResult = UploadResult.parse(it.string()) }
@@ -22,10 +22,10 @@ package com.vitorpamplona.amethyst.service.uploads.nip96
import android.util.Log import android.util.Log
import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.okhttp.HttpClientManager
import com.vitorpamplona.quartz.nip96FileStorage.info.ServerInfo import com.vitorpamplona.quartz.nip96FileStorage.info.ServerInfo
import com.vitorpamplona.quartz.nip96FileStorage.info.ServerInfoParser import com.vitorpamplona.quartz.nip96FileStorage.info.ServerInfoParser
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
class ServerInfoRetriever { class ServerInfoRetriever {
@@ -33,7 +33,7 @@ class ServerInfoRetriever {
suspend fun loadInfo( suspend fun loadInfo(
baseUrl: String, baseUrl: String,
forceProxy: Boolean, okHttpClient: (String) -> OkHttpClient,
): ServerInfo { ): ServerInfo {
val request: Request = val request: Request =
Request Request
@@ -42,7 +42,10 @@ class ServerInfoRetriever {
.url(parser.assembleUrl(baseUrl)) .url(parser.assembleUrl(baseUrl))
.build() .build()
HttpClientManager.getHttpClient(forceProxy).newCall(request).execute().use { response -> println("AABBCC $baseUrl Request ${parser.assembleUrl(baseUrl)}")
okHttpClient(baseUrl).newCall(request).execute().use { response ->
println("AABBCC $baseUrl Response")
checkNotInMainThread() checkNotInMainThread()
response.use { response.use {
val body = it.body.string() val body = it.body.string()
@@ -20,9 +20,8 @@
*/ */
package com.vitorpamplona.amethyst.ui package com.vitorpamplona.amethyst.ui
import android.net.ConnectivityManager import android.app.PendingIntent
import android.net.Network import android.content.Intent
import android.net.NetworkCapabilities
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.util.Log import android.util.Log
@@ -31,20 +30,18 @@ import androidx.activity.enableEdgeToEdge
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf import androidx.core.net.toUri
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.debugState import com.vitorpamplona.amethyst.debugState
import com.vitorpamplona.amethyst.model.LocalCache 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.notifications.PushNotificationUtils
import com.vitorpamplona.amethyst.service.okhttp.HttpClientManager
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.Route import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.screen.AccountScreen import com.vitorpamplona.amethyst.ui.screen.AccountScreen
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.prepareSharedViewModel
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
@@ -65,27 +62,19 @@ import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
import java.util.Timer
import kotlin.concurrent.schedule
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
val isOnMobileDataState = mutableStateOf(false)
private val isOnWifiDataState = mutableStateOf(false)
private var shouldPauseService = true
@RequiresApi(Build.VERSION_CODES.R) @RequiresApi(Build.VERSION_CODES.R)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge() enableEdgeToEdge()
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
Log.d("Lifetime Event", "MainActivity.onCreate") Log.d("ActivityLifecycle", "MainActivity.onCreate $this")
setContent { setContent {
val sharedPreferencesViewModel = prepareSharedViewModel(act = this) StringResSetup()
val sharedPreferencesViewModel = prepareSharedViewModel()
AmethystTheme(sharedPreferencesViewModel) { AmethystTheme(sharedPreferencesViewModel) {
val accountStateViewModel: AccountStateViewModel = viewModel() val accountStateViewModel: AccountStateViewModel = viewModel()
@@ -98,74 +87,26 @@ class MainActivity : AppCompatActivity() {
} }
} }
fun prepareToLaunchSigner() {
shouldPauseService = false
}
@OptIn(DelicateCoroutinesApi::class) @OptIn(DelicateCoroutinesApi::class)
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
val locales = this.applicationContext.resources.configuration.locales Log.d("ActivityLifecycle", "MainActivity.onResume $this")
if (!locales.isEmpty) {
checkLanguage(locales.get(0).language)
}
Log.d("Lifetime Event", "MainActivity.onResume")
// starts muted every time // starts muted every time
DEFAULT_MUTED_SETTING.value = true DEFAULT_MUTED_SETTING.value = true
// Keep connection alive if it's calling the signer app
Log.d("shouldPauseService", "shouldPauseService onResume: $shouldPauseService")
if (shouldPauseService) {
GlobalScope.launch(Dispatchers.IO) { Amethyst.instance.serviceManager.justStart() }
}
GlobalScope.launch(Dispatchers.IO) {
PushNotificationUtils.init(LocalPreferences.allSavedAccounts())
}
val connectivityManager =
(getSystemService(ConnectivityManager::class.java) as ConnectivityManager)
connectivityManager.registerDefaultNetworkCallback(networkCallback)
connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork)?.let {
updateNetworkCapabilities(it)
}
// resets state until next External Signer Call
Timer().schedule(350) { shouldPauseService = true }
} }
override fun onPause() { override fun onPause() {
Log.d("Lifetime Event", "MainActivity.onPause") Log.d("ActivityLifecycle", "MainActivity.onPause $this")
GlobalScope.launch(Dispatchers.IO) { GlobalScope.launch(Dispatchers.IO) { LanguageTranslatorService.clear() }
LanguageTranslatorService.clear()
}
Amethyst.instance.serviceManager.cleanObservers()
// if (BuildConfig.DEBUG) {
GlobalScope.launch(Dispatchers.IO) { debugState(this@MainActivity) } GlobalScope.launch(Dispatchers.IO) { debugState(this@MainActivity) }
// }
Log.d("shouldPauseService", "shouldPauseService onPause: $shouldPauseService")
if (shouldPauseService) {
GlobalScope.launch(Dispatchers.IO) { Amethyst.instance.serviceManager.pauseForGood() }
}
(getSystemService(ConnectivityManager::class.java) as ConnectivityManager)
.unregisterNetworkCallback(networkCallback)
super.onPause() super.onPause()
} }
override fun onStart() {
super.onStart()
Log.d("Lifetime Event", "MainActivity.onStart")
}
override fun onStop() { override fun onStop() {
super.onStop() super.onStop()
@@ -174,117 +115,61 @@ class MainActivity : AppCompatActivity() {
// serviceManager.trimMemory() // serviceManager.trimMemory()
// } // }
Log.d("Lifetime Event", "MainActivity.onStop") Log.d("ActivityLifecycle", "MainActivity.onStop $this")
} }
override fun onDestroy() { override fun onDestroy() {
Log.d("Lifetime Event", "MainActivity.onDestroy") Log.d("ActivityLifecycle", "MainActivity.onDestroy $this")
BackgroundMedia.removeBackgroundControllerAndReleaseIt() BackgroundMedia.removeBackgroundControllerAndReleaseIt()
super.onDestroy() super.onDestroy()
} }
fun updateNetworkCapabilities(networkCapabilities: NetworkCapabilities): Boolean { companion object {
val unmetered = networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED) fun createIntent(callbackUri: String): PendingIntent =
PendingIntent.getActivity(
val isOnMobileData = !unmetered || networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) Amethyst.instance,
val isOnWifi = unmetered && networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) 0,
Intent(Intent.ACTION_VIEW, callbackUri.toUri(), Amethyst.instance, MainActivity::class.java),
var changedNetwork = false PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
if (isOnMobileDataState.value != isOnMobileData) {
isOnMobileDataState.value = isOnMobileData
changedNetwork = true
}
if (isOnWifiDataState.value != isOnWifi) {
isOnWifiDataState.value = isOnWifi
changedNetwork = true
}
if (changedNetwork) {
if (isOnMobileData) {
HttpClientManager.setDefaultTimeout(HttpClientManager.DEFAULT_TIMEOUT_ON_MOBILE)
} else {
HttpClientManager.setDefaultTimeout(HttpClientManager.DEFAULT_TIMEOUT_ON_WIFI)
}
}
return changedNetwork
} }
@OptIn(DelicateCoroutinesApi::class)
private val networkCallback =
object : ConnectivityManager.NetworkCallback() {
var lastNetwork: Network? = null
override fun onAvailable(network: Network) {
super.onAvailable(network)
Log.d("ServiceManager NetworkCallback", "onAvailable: $shouldPauseService")
if (shouldPauseService && lastNetwork != null && lastNetwork != network) {
GlobalScope.launch(Dispatchers.IO) { Amethyst.instance.serviceManager.forceRestart() }
}
lastNetwork = network
}
// Network capabilities have changed for the network
override fun onCapabilitiesChanged(
network: Network,
networkCapabilities: NetworkCapabilities,
) {
super.onCapabilitiesChanged(network, networkCapabilities)
GlobalScope.launch(Dispatchers.IO) {
Log.d(
"ServiceManager NetworkCallback",
"onCapabilitiesChanged: ${network.networkHandle} hasMobileData ${isOnMobileDataState.value} hasWifi ${isOnWifiDataState.value}",
)
if (updateNetworkCapabilities(networkCapabilities) && shouldPauseService) {
Amethyst.instance.serviceManager.forceRestart()
}
}
}
}
} }
fun uriToRoute(uri: String?): String? = fun uriToRoute(uri: String?): Route? =
if (uri?.startsWith("notifications", true) == true || uri?.startsWith("nostr:notifications", true) == true) { if (uri?.startsWith("notifications", true) == true || uri?.startsWith("nostr:notifications", true) == true) {
Route.Notification.route.replace("{scrollToTop}", "true") Route.Notification
} else { } else {
if (uri?.startsWith("hashtag?id=") == true || uri?.startsWith("nostr:hashtag?id=") == true) { if (uri?.startsWith("hashtag?id=") == true || uri?.startsWith("nostr:hashtag?id=") == true) {
Route.Hashtag.route.replace("{id}", uri.removePrefix("nostr:").removePrefix("hashtag?id=")) Route.Hashtag(uri.removePrefix("nostr:").removePrefix("hashtag?id="))
} else { } else {
val nip19 = Nip19Parser.uriToRoute(uri)?.entity val nip19 = Nip19Parser.uriToRoute(uri)?.entity
when (nip19) { when (nip19) {
is NPub -> "User/${nip19.hex}" is NPub -> Route.Profile(nip19.hex)
is NProfile -> "User/${nip19.hex}" is NProfile -> Route.Profile(nip19.hex)
is Note -> "Note/${nip19.hex}" is Note -> Route.Note(nip19.hex)
is NEvent -> { is NEvent -> {
if (nip19.kind == PrivateDmEvent.KIND) { if (nip19.kind == PrivateDmEvent.KIND) {
nip19.author?.let { "RoomByAuthor/$it" } nip19.author?.let { Route.RoomByAuthor(it) }
} else if ( } else if (
nip19.kind == ChannelMessageEvent.KIND || nip19.kind == ChannelMessageEvent.KIND ||
nip19.kind == ChannelCreateEvent.KIND || nip19.kind == ChannelCreateEvent.KIND ||
nip19.kind == ChannelMetadataEvent.KIND nip19.kind == ChannelMetadataEvent.KIND
) { ) {
"Channel/${nip19.hex}" Route.Channel(nip19.hex)
} else { } else {
"Event/${nip19.hex}" Route.EventRedirect(nip19.hex)
} }
} }
is NAddress -> { is NAddress -> {
if (nip19.kind == CommunityDefinitionEvent.KIND) { if (nip19.kind == CommunityDefinitionEvent.KIND) {
"Community/${nip19.aTag()}" Route.Community(nip19.aTag())
} else if (nip19.kind == LiveActivitiesEvent.KIND) { } else if (nip19.kind == LiveActivitiesEvent.KIND) {
"Channel/${nip19.aTag()}" Route.Channel(nip19.aTag())
} else { } else {
"Event/${nip19.aTag()}" Route.EventRedirect(nip19.aTag())
} }
} }
@@ -292,7 +177,7 @@ fun uriToRoute(uri: String?): String? =
if (LocalCache.getNoteIfExists(nip19.event.id) == null) { if (LocalCache.getNoteIfExists(nip19.event.id) == null) {
LocalCache.verifyAndConsume(nip19.event, null) LocalCache.verifyAndConsume(nip19.event, null)
} }
"Event/${nip19.event.id}" Route.EventRedirect(nip19.event.id)
} }
else -> null else -> null
@@ -301,8 +186,7 @@ fun uriToRoute(uri: String?): String? =
?: try { ?: try {
uri?.let { uri?.let {
Nip47WalletConnect.parse(it) Nip47WalletConnect.parse(it)
val encodedUri = URLEncoder.encode(it, StandardCharsets.UTF_8.toString()) Route.Nip47NWCSetup(it)
Route.NIP47Setup.base + "?nip47=" + encodedUri
} }
} catch (e: Exception) { } catch (e: Exception) {
if (e is CancellationException) throw e if (e is CancellationException) throw e
@@ -25,6 +25,7 @@ import android.util.LruCache
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.LifecycleResumeEffect
/** /**
* Cache for stringResource because it seems to be > 1ms function in some phones * Cache for stringResource because it seems to be > 1ms function in some phones
@@ -43,6 +44,19 @@ fun checkLanguage(currentLanguage: String) {
} }
} }
@Composable
fun StringResSetup() {
val config = LocalConfiguration.current
if (!config.locales.isEmpty) {
val language = config.locales.get(0).language
LifecycleResumeEffect(language) {
checkLanguage(language)
onPauseOrDispose { }
}
}
}
@Composable @Composable
fun stringRes(id: Int): String = resourceCache.get(id) ?: stringResource(id).also { resourceCache.put(id, it) } fun stringRes(id: Int): String = resourceCache.get(id) ?: stringResource(id).also { resourceCache.put(id, it) }
@@ -76,6 +76,7 @@ import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.note.SearchIcon import com.vitorpamplona.amethyst.ui.note.SearchIcon
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
@@ -349,7 +350,7 @@ private fun RenderSearchResults(
key = { _, item -> "u" + item.pubkeyHex }, key = { _, item -> "u" + item.pubkeyHex },
) { _, item -> ) { _, item ->
UserComposeForChat(item, accountViewModel) { UserComposeForChat(item, accountViewModel) {
accountViewModel.createChatRoomFor(item) { nav.nav("Room/$it") } accountViewModel.createChatRoomFor(item) { nav.nav(Route.Room(it)) }
searchBarViewModel.clear() searchBarViewModel.clear()
} }
@@ -368,7 +369,7 @@ private fun RenderSearchResults(
loadProfilePicture = accountViewModel.settings.showProfilePictures.value, loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE, loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE,
) { ) {
nav.nav("Channel/${item.idHex}") nav.nav(Route.Channel(item.idHex))
searchBarViewModel.clear() searchBarViewModel.clear()
} }
@@ -32,10 +32,10 @@ import androidx.annotation.RequiresApi
import androidx.core.net.toFile import androidx.core.net.toFile
import androidx.core.net.toUri import androidx.core.net.toUri
import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.service.okhttp.HttpClientManager
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import okhttp3.Call import okhttp3.Call
import okhttp3.Callback import okhttp3.Callback
import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
import okio.BufferedSource import okio.BufferedSource
@@ -49,7 +49,7 @@ import java.util.UUID
object MediaSaverToDisk { object MediaSaverToDisk {
fun saveDownloadingIfNeeded( fun saveDownloadingIfNeeded(
videoUri: String?, videoUri: String?,
forceProxy: Boolean, okHttpClient: (String) -> OkHttpClient,
mimeType: String?, mimeType: String?,
localContext: Context, localContext: Context,
onSuccess: () -> Any?, onSuccess: () -> Any?,
@@ -69,7 +69,7 @@ object MediaSaverToDisk {
downloadAndSave( downloadAndSave(
url = videoUri, url = videoUri,
mimeType = mimeType, mimeType = mimeType,
forceProxy = forceProxy, okHttpClient = okHttpClient,
context = localContext, context = localContext,
onSuccess = onSuccess, onSuccess = onSuccess,
onError = onError, onError = onError,
@@ -85,12 +85,12 @@ object MediaSaverToDisk {
fun downloadAndSave( fun downloadAndSave(
url: String, url: String,
mimeType: String?, mimeType: String?,
forceProxy: Boolean, okHttpClient: (String) -> OkHttpClient,
context: Context, context: Context,
onSuccess: () -> Any?, onSuccess: () -> Any?,
onError: (Throwable) -> Any?, onError: (Throwable) -> Any?,
) { ) {
val client = HttpClientManager.getHttpClient(forceProxy) val client = okHttpClient(url)
val request = val request =
Request Request
@@ -20,6 +20,7 @@
*/ */
package com.vitorpamplona.amethyst.ui.actions package com.vitorpamplona.amethyst.ui.actions
import android.R.attr.category
import android.content.Context import android.content.Context
import android.util.Log import android.util.Log
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
@@ -239,17 +240,27 @@ open class NewPostViewModel :
fun user(): User? = account?.userProfile() fun user(): User? = account?.userProfile()
open fun init(accountVM: AccountViewModel) {
this.accountViewModel = accountVM
this.account = accountVM.account
this.canAddInvoice = hasLnAddress()
this.canAddZapRaiser = hasLnAddress()
this.userSuggestions?.reset()
this.userSuggestions = UserSuggestionState(accountVM)
this.emojiSuggestions?.reset()
this.emojiSuggestions = EmojiSuggestionState(accountVM)
}
open fun load( open fun load(
accountViewModel: AccountViewModel,
replyingTo: Note?, replyingTo: Note?,
quote: Note?, quote: Note?,
fork: Note?, fork: Note?,
version: Note?, version: Note?,
draft: Note?, draft: Note?,
) { ) {
this.accountViewModel = accountViewModel val accountViewModel = accountViewModel ?: return
this.account = accountViewModel.account
val noteEvent = draft?.event val noteEvent = draft?.event
val noteAuthor = draft?.author val noteAuthor = draft?.author
@@ -1181,7 +1192,7 @@ open class NewPostViewModel :
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
iMetaAttachments.downloadAndPrepare( iMetaAttachments.downloadAndPrepare(
item.url.url, item.url.url,
accountViewModel?.account?.shouldUseTorForImageDownload() ?: false, { Amethyst.instance.okHttpClients.getHttpClient(accountViewModel?.account?.shouldUseTorForImageDownload() ?: false) },
) )
} }
@@ -26,6 +26,8 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import coil3.util.CoilUtils.result
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.uploads.CompressorQuality import com.vitorpamplona.amethyst.service.uploads.CompressorQuality
@@ -185,7 +187,7 @@ class NewUserMetadataViewModel : ViewModel() {
alt = null, alt = null,
sensitiveContent = null, sensitiveContent = null,
serverBaseUrl = account.settings.defaultFileServer.baseUrl, serverBaseUrl = account.settings.defaultFileServer.baseUrl,
forceProxy = account::shouldUseTorForNIP96, okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(account.shouldUseTorForNIP96(it)) },
onProgress = {}, onProgress = {},
httpAuth = account::createHTTPAuthorization, httpAuth = account::createHTTPAuthorization,
context = context, context = context,
@@ -198,7 +200,7 @@ class NewUserMetadataViewModel : ViewModel() {
alt = null, alt = null,
sensitiveContent = null, sensitiveContent = null,
serverBaseUrl = account.settings.defaultFileServer.baseUrl, serverBaseUrl = account.settings.defaultFileServer.baseUrl,
forceProxy = account::shouldUseTorForNIP96, okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(account.shouldUseTorForNIP96(it)) },
httpAuth = account::createBlossomUploadAuth, httpAuth = account::createBlossomUploadAuth,
context = context, context = context,
) )
@@ -53,18 +53,15 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat.startActivity import androidx.core.content.ContextCompat.startActivity
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.hashtags.Cashu import com.vitorpamplona.amethyst.commons.hashtags.Cashu
import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons
import com.vitorpamplona.amethyst.model.ThemeType
import com.vitorpamplona.amethyst.service.CachedCashuProcessor import com.vitorpamplona.amethyst.service.CachedCashuProcessor
import com.vitorpamplona.amethyst.service.CashuToken import com.vitorpamplona.amethyst.service.CashuToken
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.note.CopyIcon import com.vitorpamplona.amethyst.ui.note.CopyIcon
import com.vitorpamplona.amethyst.ui.note.OpenInNewIcon import com.vitorpamplona.amethyst.ui.note.OpenInNewIcon
import com.vitorpamplona.amethyst.ui.note.ZapIcon import com.vitorpamplona.amethyst.ui.note.ZapIcon
import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.CashuCardBorders import com.vitorpamplona.amethyst.ui.theme.CashuCardBorders
@@ -124,11 +121,6 @@ fun CashuPreview(
@Composable @Composable
@Preview() @Preview()
fun CashuPreviewPreview() { fun CashuPreviewPreview() {
val sharedPreferencesViewModel: SharedPreferencesViewModel = viewModel()
sharedPreferencesViewModel.init()
sharedPreferencesViewModel.updateTheme(ThemeType.DARK)
ThemeComparisonColumn { ThemeComparisonColumn {
CashuPreviewNew( CashuPreviewNew(
token = CashuToken("token", "mint", 32400, listOf()), token = CashuToken("token", "mint", 32400, listOf()),
@@ -23,13 +23,8 @@ package com.vitorpamplona.amethyst.ui.components
import android.content.ActivityNotFoundException import android.content.ActivityNotFoundException
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
@Composable @Composable
@@ -37,10 +32,9 @@ fun ClickableEmail(email: String) {
val stripped = email.replaceFirst("mailto:", "") val stripped = email.replaceFirst("mailto:", "")
val context = LocalContext.current val context = LocalContext.current
ClickableText( ClickableTextPrimary(
text = remember { AnnotatedString(stripped) }, text = stripped,
onClick = { runCatching { context.sendMail(stripped) } }, onClick = { runCatching { context.sendMail(stripped) } },
style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary),
) )
} }
@@ -23,22 +23,16 @@ package com.vitorpamplona.amethyst.ui.components
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
@Composable @Composable
fun ClickablePhone(phone: String) { fun ClickablePhone(phone: String) {
val context = LocalContext.current val context = LocalContext.current
ClickableText( ClickableTextPrimary(
text = remember { AnnotatedString(phone) }, text = phone,
onClick = { runCatching { context.dial(phone) } }, onClick = { context.dial(phone) },
style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary),
) )
} }
@@ -20,7 +20,6 @@
*/ */
package com.vitorpamplona.amethyst.ui.components package com.vitorpamplona.amethyst.ui.components
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.InlineTextContent import androidx.compose.foundation.text.InlineTextContent
@@ -40,18 +39,18 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.takeOrElse import androidx.compose.ui.graphics.takeOrElse
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.Placeholder import androidx.compose.ui.text.Placeholder
import androidx.compose.ui.text.PlaceholderVerticalAlign import androidx.compose.ui.text.PlaceholderVerticalAlign
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withLink
import androidx.compose.ui.text.withStyle import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -60,6 +59,8 @@ import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.navigation.routeFor
import com.vitorpamplona.amethyst.ui.note.LoadChannel import com.vitorpamplona.amethyst.ui.note.LoadChannel
import com.vitorpamplona.amethyst.ui.note.njumpLink import com.vitorpamplona.amethyst.ui.note.njumpLink
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -200,7 +201,7 @@ private fun DisplayNoteLink(
CreateClickableText( CreateClickableText(
clickablePart = noteIdDisplayNote, clickablePart = noteIdDisplayNote,
suffix = addedCharts, suffix = addedCharts,
route = remember(noteState) { "Channel/$hex" }, route = remember(noteState) { Route.Channel(hex) },
nav = nav, nav = nav,
) )
} else if (note.event is PrivateDmEvent || kind == PrivateDmEvent.KIND) { } else if (note.event is PrivateDmEvent || kind == PrivateDmEvent.KIND) {
@@ -208,7 +209,7 @@ private fun DisplayNoteLink(
clickablePart = noteIdDisplayNote, clickablePart = noteIdDisplayNote,
suffix = addedCharts, suffix = addedCharts,
route = route =
remember(noteState) { (note.author?.pubkeyHex ?: hex).let { "RoomByAuthor/$it" } }, remember(noteState) { (note.author?.pubkeyHex ?: hex).let { Route.RoomByAuthor(it) } },
nav = nav, nav = nav,
) )
} else if (channelHex != null) { } else if (channelHex != null) {
@@ -222,7 +223,7 @@ private fun DisplayNoteLink(
CreateClickableText( CreateClickableText(
clickablePart = channelDisplayName, clickablePart = channelDisplayName,
suffix = addedCharts, suffix = addedCharts,
route = remember(noteState) { "Channel/${baseChannel.idHex}" }, route = remember(noteState) { Route.Channel(baseChannel.idHex) },
nav = nav, nav = nav,
) )
} }
@@ -230,7 +231,7 @@ private fun DisplayNoteLink(
CreateClickableText( CreateClickableText(
clickablePart = noteIdDisplayNote, clickablePart = noteIdDisplayNote,
suffix = addedCharts, suffix = addedCharts,
route = remember(noteState) { "Event/$hex" }, route = remember(noteState) { Route.EventRedirect(hex) },
nav = nav, nav = nav,
) )
} }
@@ -255,7 +256,7 @@ private fun DisplayAddress(
noteBase?.let { noteBase?.let {
val noteState by it.live().metadata.observeAsState() val noteState by it.live().metadata.observeAsState()
val route = remember(noteState) { "Note/${nip19.aTag()}" } val route = remember(noteState) { Route.Note(nip19.aTag()) }
val displayName = remember(noteState) { "@${noteState?.note?.idDisplayNote()}" } val displayName = remember(noteState) { "@${noteState?.note?.idDisplayNote()}" }
CreateClickableText( CreateClickableText(
@@ -329,7 +330,7 @@ public fun RenderUserAsClickableText(
clickablePart = userState?.bestName() ?: ("@" + baseUser.pubkeyDisplayHex()), clickablePart = userState?.bestName() ?: ("@" + baseUser.pubkeyDisplayHex()),
suffix = additionalChars?.ifBlank { null }, suffix = additionalChars?.ifBlank { null },
maxLines = 1, maxLines = 1,
route = "User/${baseUser.pubkeyHex}", route = remember(baseUser) { routeFor(baseUser) },
nav = nav, nav = nav,
tags = userState?.tags ?: EmptyTagList, tags = userState?.tags ?: EmptyTagList,
) )
@@ -343,7 +344,7 @@ fun CreateClickableText(
overrideColor: Color? = null, overrideColor: Color? = null,
fontWeight: FontWeight? = null, fontWeight: FontWeight? = null,
fontSize: TextUnit = TextUnit.Unspecified, fontSize: TextUnit = TextUnit.Unspecified,
route: String, route: Route,
nav: INav, nav: INav,
) { ) {
CreateClickableText( CreateClickableText(
@@ -364,7 +365,7 @@ fun CreateClickableText(
overrideColor: Color? = null, overrideColor: Color? = null,
fontWeight: FontWeight? = null, fontWeight: FontWeight? = null,
fontSize: TextUnit = TextUnit.Unspecified, fontSize: TextUnit = TextUnit.Unspecified,
onClick: (Int) -> Unit, onClick: () -> Unit,
) { ) {
val primaryColor = MaterialTheme.colorScheme.primary val primaryColor = MaterialTheme.colorScheme.primary
val onBackgroundColor = MaterialTheme.colorScheme.onBackground val onBackgroundColor = MaterialTheme.colorScheme.onBackground
@@ -378,61 +379,34 @@ fun CreateClickableText(
fontWeight = fontWeight, fontWeight = fontWeight,
) )
val nonClickablePartStyle =
SpanStyle(
fontSize = fontSize,
color = overrideColor ?: onBackgroundColor,
fontWeight = fontWeight,
)
buildAnnotatedString { buildAnnotatedString {
withStyle(clickablePartStyle) { append(clickablePart) } withLink(
if (!suffix.isNullOrBlank()) { LinkAnnotation.Clickable(
withStyle(nonClickablePartStyle) { append(suffix) } tag = "clickable",
styles = TextLinkStyles(clickablePartStyle),
) {
onClick()
},
) {
append(clickablePart)
} }
} if (!suffix.isNullOrBlank()) {
} val nonClickablePartStyle =
SpanStyle(
fontSize = fontSize,
color = overrideColor ?: onBackgroundColor,
fontWeight = fontWeight,
)
ClickableText( withStyle(nonClickablePartStyle) { append(suffix) }
text = text,
maxLines = maxLines,
overflow = TextOverflow.Ellipsis,
onClick = onClick,
)
}
@Composable
fun ClickableText(
text: AnnotatedString,
modifier: Modifier = Modifier,
style: TextStyle = LocalTextStyle.current,
softWrap: Boolean = true,
overflow: TextOverflow = TextOverflow.Clip,
maxLines: Int = Int.MAX_VALUE,
onTextLayout: (TextLayoutResult) -> Unit = {},
onClick: (Int) -> Unit,
) {
val layoutResult = remember { mutableStateOf<TextLayoutResult?>(null) }
val pressIndicator =
Modifier.pointerInput(onClick) {
detectTapGestures { pos ->
layoutResult.value?.let { layoutResult ->
onClick(layoutResult.getOffsetForPosition(pos))
} }
} }
} }
Text( Text(
text = text, text = text,
modifier = modifier.then(pressIndicator),
style = style,
softWrap = softWrap,
overflow = overflow,
maxLines = maxLines, maxLines = maxLines,
onTextLayout = { overflow = TextOverflow.Ellipsis,
layoutResult.value = it
onTextLayout(it)
},
) )
} }
@@ -608,21 +582,29 @@ fun CreateClickableTextWithEmoji(
maxLines: Int = Int.MAX_VALUE, maxLines: Int = Int.MAX_VALUE,
tags: ImmutableListOfLists<String>?, tags: ImmutableListOfLists<String>?,
style: TextStyle, style: TextStyle,
onClick: (Int) -> Unit, onClick: () -> Unit,
) { ) {
CustomEmojiChecker( CustomEmojiChecker(
text = clickablePart, text = clickablePart,
tags = tags, tags = tags,
onRegularText = { onRegularText = {
ClickableText( Text(
text = AnnotatedString(clickablePart), text =
buildAnnotatedString {
withLink(
LinkAnnotation.Clickable("me") {
onClick()
},
) {
append(clickablePart)
}
},
style = style, style = style,
maxLines = maxLines, maxLines = maxLines,
onClick = onClick,
) )
}, },
onEmojiText = { onEmojiText = {
ClickableInLineIconRenderer(it, maxLines, style.toSpanStyle()) { onClick(it) } ClickableInLineIconRenderer(it, maxLines, style.toSpanStyle(), onClick = onClick)
}, },
) )
} }
@@ -635,7 +617,7 @@ fun CreateClickableTextWithEmoji(
overrideColor: Color? = null, overrideColor: Color? = null,
fontWeight: FontWeight = FontWeight.Normal, fontWeight: FontWeight = FontWeight.Normal,
fontSize: TextUnit = TextUnit.Unspecified, fontSize: TextUnit = TextUnit.Unspecified,
route: String, route: Route,
nav: INav, nav: INav,
tags: ImmutableListOfLists<String>?, tags: ImmutableListOfLists<String>?,
) { ) {
@@ -680,15 +662,11 @@ fun ClickableInLineIconRenderer(
style: SpanStyle, style: SpanStyle,
suffix: String? = null, suffix: String? = null,
nonClickableStype: SpanStyle? = null, nonClickableStype: SpanStyle? = null,
onClick: (Int) -> Unit, onClick: () -> Unit,
) { ) {
val placeholderSize = val placeholderSize =
remember(style) { remember(style) {
if (style.fontSize == TextUnit.Unspecified) { if (style.fontSize == TextUnit.Unspecified) 22.sp else style.fontSize.times(1.1f)
22.sp
} else {
style.fontSize.times(1.1f)
}
} }
val inlineContent = val inlineContent =
@@ -722,8 +700,10 @@ fun ClickableInLineIconRenderer(
val annotatedText = val annotatedText =
buildAnnotatedString { buildAnnotatedString {
wordsInOrder.forEachIndexed { idx, value -> wordsInOrder.forEachIndexed { idx, value ->
withStyle( withLink(
style, LinkAnnotation.Clickable("link", TextLinkStyles(style)) {
onClick()
},
) { ) {
if (value is CustomEmoji.TextType) { if (value is CustomEmoji.TextType) {
append(value.text) append(value.text)
@@ -740,20 +720,10 @@ fun ClickableInLineIconRenderer(
} }
} }
val layoutResult = remember { mutableStateOf<TextLayoutResult?>(null) }
val pressIndicator =
Modifier.pointerInput(onClick) {
detectTapGestures { pos ->
layoutResult.value?.let { layoutResult -> onClick(layoutResult.getOffsetForPosition(pos)) }
}
}
Text( Text(
text = annotatedText, text = annotatedText,
modifier = pressIndicator,
inlineContent = inlineContent, inlineContent = inlineContent,
maxLines = maxLines, maxLines = maxLines,
onTextLayout = { layoutResult.value = it },
) )
} }
@@ -768,11 +738,7 @@ fun InLineIconRenderer(
) { ) {
val placeholderSize = val placeholderSize =
remember(fontSize) { remember(fontSize) {
if (fontSize == TextUnit.Unspecified) { if (fontSize == TextUnit.Unspecified) 22.sp else fontSize.times(1.1f)
22.sp
} else {
fontSize.times(1.1f)
}
} }
val inlineContent = val inlineContent =
@@ -0,0 +1,151 @@
/**
* Copyright (c) 2024 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.components
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.AnnotatedString.Builder
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withLink
@Composable
fun ClickableTextPrimary(
text: String,
modifier: Modifier = Modifier,
style: TextStyle = LocalTextStyle.current,
softWrap: Boolean = true,
overflow: TextOverflow = TextOverflow.Ellipsis,
maxLines: Int = Int.MAX_VALUE,
onClick: () -> Unit,
) {
ClickableTextColor(
text,
modifier,
style,
softWrap,
overflow,
maxLines,
MaterialTheme.colorScheme.primary,
onClick,
)
}
@Composable
fun ClickableTextColor(
text: String,
modifier: Modifier = Modifier,
style: TextStyle = LocalTextStyle.current,
softWrap: Boolean = true,
overflow: TextOverflow = TextOverflow.Ellipsis,
maxLines: Int = Int.MAX_VALUE,
linkColor: Color = MaterialTheme.colorScheme.primary,
onClick: () -> Unit,
) {
Text(
text =
remember(text) {
buildAnnotatedString {
appendLink(text, linkColor, onClick)
}
},
modifier = modifier,
style = style,
softWrap = softWrap,
overflow = overflow,
maxLines = maxLines,
)
}
@Composable
fun ClickableTextNormal(
text: String,
modifier: Modifier = Modifier,
style: TextStyle = LocalTextStyle.current,
softWrap: Boolean = true,
overflow: TextOverflow = TextOverflow.Ellipsis,
maxLines: Int = Int.MAX_VALUE,
onClick: () -> Unit,
) {
Text(
text =
remember(text) {
buildAnnotatedString {
appendLink(text, onClick)
}
},
modifier = modifier,
style = style,
softWrap = softWrap,
overflow = overflow,
maxLines = maxLines,
)
}
inline fun Builder.appendLink(
text: String,
color: Color,
crossinline onClick: () -> Unit,
) = withLink(
LinkAnnotation.Clickable(
"clickable",
TextLinkStyles(SpanStyle(color)),
) {
onClick()
},
) {
append(text)
}
inline fun Builder.appendLink(
text: String,
crossinline onClick: () -> Unit,
) = withLink(
LinkAnnotation.Clickable("clickable") {
onClick()
},
) {
append(text)
}
inline fun buildLinkString(
text: String,
crossinline onClick: () -> Unit,
): AnnotatedString =
buildAnnotatedString {
withLink(
LinkAnnotation.Clickable("link") {
onClick()
},
) {
append(text)
}
}
@@ -20,13 +20,10 @@
*/ */
package com.vitorpamplona.amethyst.ui.components package com.vitorpamplona.amethyst.ui.components
import androidx.compose.foundation.text.ClickableText import android.R.attr.maxLines
import androidx.compose.material3.LocalTextStyle import android.R.attr.onClick
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
@Composable @Composable
@@ -36,10 +33,8 @@ fun ClickableUrl(
) { ) {
val uri = LocalUriHandler.current val uri = LocalUriHandler.current
val text = remember(urlText) { AnnotatedString(urlText) } ClickableTextPrimary(
text = urlText,
ClickableText(
text = text,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
onClick = { onClick = {
@@ -48,6 +43,5 @@ fun ClickableUrl(
uri.openUri(doubleCheckedUrl) uri.openUri(doubleCheckedUrl)
} }
}, },
style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary),
) )
} }
@@ -20,9 +20,7 @@
*/ */
package com.vitorpamplona.amethyst.ui.components package com.vitorpamplona.amethyst.ui.components
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
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.LaunchedEffect
@@ -31,7 +29,6 @@ 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 androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.text.style.TextDirection
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
@@ -70,8 +67,6 @@ fun MayBeWithdrawal(
fun ClickableWithdrawal(withdrawalString: String) { fun ClickableWithdrawal(withdrawalString: String) {
val context = LocalContext.current val context = LocalContext.current
val withdraw = remember(withdrawalString) { AnnotatedString("$withdrawalString ") }
var showErrorMessageDialog by remember { mutableStateOf<String?>(null) } var showErrorMessageDialog by remember { mutableStateOf<String?>(null) }
if (showErrorMessageDialog != null) { if (showErrorMessageDialog != null) {
@@ -82,9 +77,8 @@ fun ClickableWithdrawal(withdrawalString: String) {
) )
} }
ClickableText( ClickableTextPrimary(
text = withdraw, text = "$withdrawalString ",
onClick = { payViaIntent(withdrawalString, context, { }) { showErrorMessageDialog = it } }, onClick = { payViaIntent(withdrawalString, context, { }) { showErrorMessageDialog = it } },
style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary),
) )
} }
@@ -90,6 +90,8 @@ import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.components.markdown.RenderContentAsMarkdown import com.vitorpamplona.amethyst.ui.components.markdown.RenderContentAsMarkdown
import com.vitorpamplona.amethyst.ui.navigation.EmptyNav import com.vitorpamplona.amethyst.ui.navigation.EmptyNav
import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.navigation.routeFor
import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.creators.invoice.MayBeInvoicePreview import com.vitorpamplona.amethyst.ui.note.creators.invoice.MayBeInvoicePreview
import com.vitorpamplona.amethyst.ui.note.toShortenHex import com.vitorpamplona.amethyst.ui.note.toShortenHex
@@ -183,7 +185,7 @@ fun RenderRegularPreview() {
word.segmentText.substring(0, 10), word.segmentText.substring(0, 10),
"", "",
1, 1,
route = "", route = Route.EventRedirect(word.segmentText),
nav = nav, nav = nav,
) )
} }
@@ -650,7 +652,7 @@ fun HashTag(
modifier = modifier =
remember { remember {
Modifier.clickable { Modifier.clickable {
nav.nav("Hashtag/${segment.hashtag}") nav.nav(Route.Hashtag(segment.hashtag))
} }
}, },
inlineContent = inlineContent =
@@ -760,7 +762,10 @@ private fun DisplayNoteFromTag(
nav = nav, nav = nav,
) )
} else { } else {
ClickableNoteTag(baseNote, accountViewModel, nav) ClickableTextPrimary(
text = "@${baseNote.idNote().toShortenHex()}",
onClick = { routeFor(baseNote, accountViewModel.userProfile())?.let { nav.nav(it) } },
)
} }
addedChars?.ifBlank { null }?.let { Text(text = it) } addedChars?.ifBlank { null }?.let { Text(text = it) }
@@ -779,7 +784,7 @@ private fun DisplayUserFromTag(
CreateClickableTextWithEmoji( CreateClickableTextWithEmoji(
clickablePart = remember(meta) { it?.bestName() ?: baseUser.pubkeyDisplayHex() }, clickablePart = remember(meta) { it?.bestName() ?: baseUser.pubkeyDisplayHex() },
maxLines = 1, maxLines = 1,
route = "User/${baseUser.pubkeyHex}", route = remember(baseUser) { routeFor(baseUser) },
nav = nav, nav = nav,
tags = it?.tags, tags = it?.tags,
) )
@@ -308,17 +308,16 @@ private fun saveMediaToGallery(
val failure = if (isImage) R.string.failed_to_save_the_image else R.string.failed_to_save_the_video val failure = if (isImage) R.string.failed_to_save_the_image else R.string.failed_to_save_the_video
if (content is MediaUrlContent) { if (content is MediaUrlContent) {
val useTor =
if (isImage) {
accountViewModel.account.shouldUseTorForImageDownload()
} else {
accountViewModel.account.shouldUseTorForVideoDownload()
}
MediaSaverToDisk.downloadAndSave( MediaSaverToDisk.downloadAndSave(
content.url, content.url,
mimeType = content.mimeType, mimeType = content.mimeType,
forceProxy = useTor, okHttpClient = {
if (isImage) {
accountViewModel.okHttpClientForImage(it)
} else {
accountViewModel.okHttpClientForVideo(it)
}
},
localContext, localContext,
onSuccess = { onSuccess = {
accountViewModel.toastManager.toast(success, success) accountViewModel.toastManager.toast(success, success)
@@ -79,7 +79,7 @@ import com.vitorpamplona.amethyst.commons.richtext.MediaUrlContent
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
import com.vitorpamplona.amethyst.model.MediaAspectRatioCache import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
import com.vitorpamplona.amethyst.service.Blurhash import com.vitorpamplona.amethyst.service.images.BlurhashWrapper
import com.vitorpamplona.amethyst.service.playback.composable.VideoView import com.vitorpamplona.amethyst.service.playback.composable.VideoView
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.actions.InformationDialog import com.vitorpamplona.amethyst.ui.actions.InformationDialog
@@ -637,7 +637,7 @@ fun DisplayBlurHash(
if (blurhash == null) return if (blurhash == null) return
AsyncImage( AsyncImage(
model = Blurhash(blurhash), model = BlurhashWrapper(blurhash),
contentDescription = description, contentDescription = description,
contentScale = contentScale, contentScale = contentScale,
modifier = modifier, modifier = modifier,
@@ -739,7 +739,7 @@ fun ShareImageAction(
private suspend fun verifyHash(content: MediaUrlContent): Boolean? { private suspend fun verifyHash(content: MediaUrlContent): Boolean? {
if (content.hash == null) return null if (content.hash == null) return null
Amethyst.instance.coilCache.openSnapshot(content.url)?.use { snapshot -> Amethyst.instance.diskCache.openSnapshot(content.url)?.use { snapshot ->
val hash = sha256(snapshot.data.toFile().readBytes()).toHexKey() val hash = sha256(snapshot.data.toFile().readBytes()).toHexKey()
Log.d("Image Hash Verification", "$hash == ${content.hash}") Log.d("Image Hash Verification", "$hash == ${content.hash}")
@@ -27,7 +27,6 @@ import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import com.vitorpamplona.amethyst.ui.navigation.Route
import kotlin.math.roundToInt import kotlin.math.roundToInt
private val savedScrollStates = mutableMapOf<String, ScrollState>() private val savedScrollStates = mutableMapOf<String, ScrollState>()
@@ -40,17 +39,17 @@ private data class ScrollState(
object ScrollStateKeys { object ScrollStateKeys {
const val NOTIFICATION_SCREEN = "NotificationsFeed" const val NOTIFICATION_SCREEN = "NotificationsFeed"
const val VIDEO_SCREEN = "VideoFeed" const val VIDEO_SCREEN = "VideoFeed"
val HOME_FOLLOWS = Route.Home.base + "FollowsFeed" const val HOME_FOLLOWS = "HomeFollowsFeed"
val HOME_REPLIES = Route.Home.base + "FollowsRepliesFeed" const val HOME_REPLIES = "HomeFollowsRepliesFeed"
val PROFILE_GALLERY = Route.Home.base + "ProfileGalleryFeed" const val PROFILE_GALLERY = "ProfileGalleryFeed"
val DRAFTS = Route.Home.base + "DraftsFeed" const val DRAFTS = "DraftsFeed"
val DISCOVER_CONTENT = Route.Home.base + "DiscoverContentFeed" const val DISCOVER_CONTENT = "DiscoverDiscoverContentFeed"
val DISCOVER_MARKETPLACE = Route.Home.base + "MarketplaceFeed" const val DISCOVER_MARKETPLACE = "DiscoverMarketplaceFeed"
val DISCOVER_LIVE = Route.Home.base + "LiveFeed" const val DISCOVER_LIVE = "DiscoverLiveFeed"
val DISCOVER_COMMUNITY = Route.Home.base + "CommunitiesFeed" const val DISCOVER_COMMUNITY = "DiscoverCommunitiesFeed"
val DISCOVER_CHATS = Route.Home.base + "ChatsFeed" const val DISCOVER_CHATS = "DiscoverChatsFeed"
} }
object PagerStateKeys { object PagerStateKeys {
@@ -30,6 +30,7 @@ import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.BottomAppBarDefaults.windowInsets import androidx.compose.material3.BottomAppBarDefaults.windowInsets
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
@@ -50,20 +51,23 @@ import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.Size0dp import com.vitorpamplona.amethyst.ui.theme.Size0dp
import com.vitorpamplona.amethyst.ui.theme.Size10Modifier import com.vitorpamplona.amethyst.ui.theme.Size10Modifier
import com.vitorpamplona.amethyst.ui.theme.Size24dp
import com.vitorpamplona.amethyst.ui.theme.Size25dp
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
val bottomNavigationItems = val bottomNavigationItems =
persistentListOf( persistentListOf(
Route.Home, BottomBarRoute(Route.Home, R.drawable.ic_home, R.string.route_home, Modifier.size(Size25dp), Modifier.size(Size24dp)),
Route.Message, BottomBarRoute(Route.Message, R.drawable.ic_dm, R.string.route_messages),
Route.Video, BottomBarRoute(Route.Video, R.drawable.ic_video, R.string.route_video),
Route.Discover, BottomBarRoute(Route.Discover, R.drawable.ic_sensors, R.string.route_discover),
Route.Notification, BottomBarRoute(Route.Notification, R.drawable.ic_notifications, R.string.route_notifications),
) )
enum class Keyboard { enum class Keyboard {
@@ -119,7 +123,7 @@ fun IfKeyboardClosed(inner: @Composable () -> Unit) {
fun AppBottomBar( fun AppBottomBar(
selectedRoute: Route?, selectedRoute: Route?,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: (Route, Boolean) -> Unit, nav: (Route) -> Unit,
) { ) {
IfKeyboardClosed { RenderBottomMenu(selectedRoute, accountViewModel, nav) } IfKeyboardClosed { RenderBottomMenu(selectedRoute, accountViewModel, nav) }
} }
@@ -128,7 +132,7 @@ fun AppBottomBar(
private fun RenderBottomMenu( private fun RenderBottomMenu(
selectedRoute: Route?, selectedRoute: Route?,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: (Route, Boolean) -> Unit, nav: (Route) -> Unit,
) { ) {
Column( Column(
modifier = modifier =
@@ -146,7 +150,7 @@ private fun RenderBottomMenu(
tonalElevation = Size0dp, tonalElevation = Size0dp,
) { ) {
bottomNavigationItems.forEach { item -> bottomNavigationItems.forEach { item ->
HasNewItemsIcon(item == selectedRoute, item, accountViewModel, nav) HasNewItemsIcon(item.route == selectedRoute, item, accountViewModel, nav)
} }
} }
} }
@@ -155,28 +159,28 @@ private fun RenderBottomMenu(
@Composable @Composable
private fun RowScope.HasNewItemsIcon( private fun RowScope.HasNewItemsIcon(
selected: Boolean, selected: Boolean,
route: Route, bottomNav: BottomBarRoute,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: (Route, Boolean) -> Unit, nav: (Route) -> Unit,
) { ) {
NavigationBarItem( NavigationBarItem(
alwaysShowLabel = false, alwaysShowLabel = false,
icon = { icon = {
NotifiableIcon( NotifiableIcon(
selected, selected,
route, bottomNav,
accountViewModel, accountViewModel,
) )
}, },
selected = selected, selected = selected,
onClick = { nav(route, selected) }, onClick = { nav(bottomNav.route) },
) )
} }
@Composable @Composable
private fun NotifiableIcon( private fun NotifiableIcon(
selected: Boolean, selected: Boolean,
route: Route, route: BottomBarRoute,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
) { ) {
Box(route.notifSize) { Box(route.notifSize) {
@@ -187,7 +191,7 @@ private fun NotifiableIcon(
tint = if (selected) MaterialTheme.colorScheme.primary else Color.Unspecified, tint = if (selected) MaterialTheme.colorScheme.primary else Color.Unspecified,
) )
AddNotifIconIfNeeded(route, accountViewModel, Modifier.align(Alignment.TopEnd)) AddNotifIconIfNeeded(route.route, accountViewModel, Modifier.align(Alignment.TopEnd))
} }
} }
@@ -43,7 +43,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.core.util.Consumer import androidx.core.util.Consumer
import androidx.navigation.NavBackStackEntry
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
@@ -87,24 +86,6 @@ import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.net.URI import java.net.URI
import java.net.URLDecoder
fun NavBackStackEntry.id(): String? = arguments?.getString("id")
fun NavBackStackEntry.message(): String? =
arguments?.getString("message")?.let {
URLDecoder.decode(it, "utf-8")
}
fun NavBackStackEntry.replyId(): String? =
arguments?.getString("replyId")?.let {
URLDecoder.decode(it, "utf-8")
}
fun NavBackStackEntry.draftId(): String? =
arguments?.getString("draftId")?.let {
URLDecoder.decode(it, "utf-8")
}
@Composable @Composable
fun AppNavigation( fun AppNavigation(
@@ -117,34 +98,15 @@ fun AppNavigation(
AccountSwitcherAndLeftDrawerLayout(accountViewModel, accountStateViewModel, nav) { AccountSwitcherAndLeftDrawerLayout(accountViewModel, accountStateViewModel, nav) {
NavHost( NavHost(
navController = nav.controller, navController = nav.controller,
startDestination = Route.Home.route, startDestination = Route.Home,
enterTransition = { fadeIn(animationSpec = tween(200)) }, enterTransition = { fadeIn(animationSpec = tween(200)) },
exitTransition = { fadeOut(animationSpec = tween(200)) }, exitTransition = { fadeOut(animationSpec = tween(200)) },
) { ) {
composable(Route.Home.route) { HomeScreen(accountViewModel, nav) } composable<Route.Home> { HomeScreen(accountViewModel, nav) }
composable(Route.Message.route) { MessagesScreen(accountViewModel, nav) } composable<Route.Message> { MessagesScreen(accountViewModel, nav) }
composable(Route.Video.route) { VideoScreen(accountViewModel, nav) } composable<Route.Video> { VideoScreen(accountViewModel, nav) }
composable(Route.Discover.route) { DiscoverScreen(accountViewModel, nav) } composable<Route.Discover> { DiscoverScreen(accountViewModel, nav) }
composable(Route.Notification.route) { NotificationScreen(sharedPreferencesViewModel, accountViewModel, nav) } composable<Route.Notification> { NotificationScreen(sharedPreferencesViewModel, accountViewModel, nav) }
composable(Route.EditProfile.route) { NewUserMetadataScreen(nav, accountViewModel) }
composable(Route.Search.route) { SearchScreen(accountViewModel, nav) }
composable(
Route.BlockedUsers.route,
enterTransition = { slideInHorizontallyFromEnd },
exitTransition = { scaleOut },
popEnterTransition = { scaleIn },
popExitTransition = { slideOutHorizontallyToEnd },
) { SecurityFiltersScreen(accountViewModel, nav) }
composable(
Route.Bookmarks.route,
enterTransition = { slideInHorizontallyFromEnd },
exitTransition = { scaleOut },
popEnterTransition = { scaleIn },
popExitTransition = { slideOutHorizontallyToEnd },
) { BookmarkListScreen(accountViewModel, nav) }
composable( composable(
Route.Lists.route, Route.Lists.route,
@@ -154,245 +116,42 @@ fun AppNavigation(
popExitTransition = { slideOutHorizontallyToEnd }, popExitTransition = { slideOutHorizontallyToEnd },
) { ListsScreen(accountViewModel, nav) } ) { ListsScreen(accountViewModel, nav) }
composable(
Route.Drafts.route,
enterTransition = { slideInHorizontallyFromEnd },
exitTransition = { scaleOut },
popEnterTransition = { scaleIn },
popExitTransition = { slideOutHorizontallyToEnd },
) { DraftListScreen(accountViewModel, nav) }
composable( composable<Route.EditProfile> { NewUserMetadataScreen(nav, accountViewModel) }
Route.ContentDiscovery.route, composable<Route.Search> { SearchScreen(accountViewModel, nav) }
Route.ContentDiscovery.arguments,
enterTransition = { slideInHorizontallyFromEnd },
exitTransition = { scaleOut },
popEnterTransition = { scaleIn },
popExitTransition = { slideOutHorizontallyToEnd },
) {
DvmContentDiscoveryScreen(it.id(), accountViewModel, nav)
}
composable( composableFromEnd<Route.SecurityFilters> { SecurityFiltersScreen(accountViewModel, nav) }
Route.Profile.route, composableFromEnd<Route.Bookmarks> { BookmarkListScreen(accountViewModel, nav) }
Route.Profile.arguments, composableFromEnd<Route.Drafts> { DraftListScreen(accountViewModel, nav) }
enterTransition = { slideInHorizontallyFromEnd }, composableFromEnd<Route.Settings> { SettingsScreen(sharedPreferencesViewModel, accountViewModel, nav) }
exitTransition = { scaleOut }, composableFromBottomArgs<Route.Nip47NWCSetup> { NIP47SetupScreen(accountViewModel, nav, it.nip47) }
popEnterTransition = { scaleIn }, composableFromEndArgs<Route.EditRelays> { AllRelayListScreen(it.toAdd, accountViewModel, nav) }
popExitTransition = { slideOutHorizontallyToEnd },
) {
ProfileScreen(it.id(), accountViewModel, nav)
}
composable( composableFromEndArgs<Route.ContentDiscovery> { DvmContentDiscoveryScreen(it.id, accountViewModel, nav) }
Route.Note.route, composableFromEndArgs<Route.Profile> { ProfileScreen(it.id, accountViewModel, nav) }
Route.Note.arguments, composableFromEndArgs<Route.Note> { ThreadScreen(it.id, accountViewModel, nav) }
enterTransition = { slideInHorizontallyFromEnd }, composableFromEndArgs<Route.Hashtag> { HashtagScreen(it.id, accountViewModel, nav) }
exitTransition = { scaleOut }, composableFromEndArgs<Route.Geohash> { GeoHashScreen(it.id, accountViewModel, nav) }
popEnterTransition = { scaleIn }, composableFromEndArgs<Route.Community> { CommunityScreen(it.id, accountViewModel, nav) }
popExitTransition = { slideOutHorizontallyToEnd }, composableFromEndArgs<Route.Room> { ChatroomScreen(it.id.toString(), it.message, it.replyId, it.draftId, accountViewModel, nav) }
) { composableFromEndArgs<Route.RoomByAuthor> { ChatroomByAuthorScreen(it.id, null, accountViewModel, nav) }
ThreadScreen(it.id(), accountViewModel, nav) composableFromEndArgs<Route.Channel> { ChannelScreen(it.id, accountViewModel, nav) }
}
composable( composableFromBottomArgs<Route.ChannelMetadataEdit> { ChannelMetadataScreen(it.id, accountViewModel, nav) }
Route.Hashtag.route, composableFromBottomArgs<Route.NewGroupDM> { NewGroupDMScreen(it.message, it.attachment, accountViewModel, nav) }
Route.Hashtag.arguments,
enterTransition = { slideInHorizontallyFromEnd },
exitTransition = { scaleOut },
popEnterTransition = { scaleIn },
popExitTransition = { slideOutHorizontallyToEnd },
) {
HashtagScreen(it.id(), accountViewModel, nav)
}
composable( composableArgs<Route.EventRedirect> { LoadRedirectScreen(it.id, accountViewModel, nav) }
Route.Geohash.route,
Route.Geohash.arguments,
enterTransition = { slideInHorizontallyFromEnd },
exitTransition = { scaleOut },
popEnterTransition = { scaleIn },
popExitTransition = { slideOutHorizontallyToEnd },
) {
GeoHashScreen(it.id(), accountViewModel, nav)
}
composable(
Route.Community.route,
Route.Community.arguments,
enterTransition = { slideInHorizontallyFromEnd },
exitTransition = { scaleOut },
popEnterTransition = { scaleIn },
popExitTransition = { slideOutHorizontallyToEnd },
) {
CommunityScreen(it.id(), accountViewModel, nav)
}
composable(
Route.Room.route,
Route.Room.arguments,
enterTransition = { slideInHorizontallyFromEnd },
exitTransition = { scaleOut },
popEnterTransition = { scaleIn },
popExitTransition = { slideOutHorizontallyToEnd },
) {
ChatroomScreen(
roomId = it.id(),
draftMessage = it.message(),
replyToNote = it.replyId(),
editFromDraft = it.draftId(),
accountViewModel = accountViewModel,
nav = nav,
)
}
composable(
Route.RoomByAuthor.route,
Route.RoomByAuthor.arguments,
enterTransition = { slideInHorizontallyFromEnd },
exitTransition = { scaleOut },
popEnterTransition = { scaleIn },
popExitTransition = { slideOutHorizontallyToEnd },
) {
ChatroomByAuthorScreen(it.id(), null, accountViewModel, nav)
}
composable(
Route.Channel.route,
Route.Channel.arguments,
enterTransition = { slideInHorizontallyFromEnd },
exitTransition = { scaleOut },
popEnterTransition = { scaleIn },
popExitTransition = { slideOutHorizontallyToEnd },
) {
ChannelScreen(
channelId = it.id(),
accountViewModel = accountViewModel,
nav = nav,
)
}
composable(
Route.ChannelMetadataEdit.route,
Route.ChannelMetadataEdit.arguments,
enterTransition = { slideInVerticallyFromBottom },
exitTransition = { scaleOut },
popEnterTransition = { scaleIn },
popExitTransition = { slideOutVerticallyToBottom },
content = {
ChannelMetadataScreen(
channelId = it.id(),
accountViewModel = accountViewModel,
nav = nav,
)
},
)
composable(
Route.NewGroupDM.route,
Route.NewGroupDM.arguments,
enterTransition = { slideInVerticallyFromBottom },
exitTransition = { scaleOut },
popEnterTransition = { scaleIn },
popExitTransition = { slideOutVerticallyToBottom },
content = {
val draftMessage = it.message()?.ifBlank { null }
val attachment =
it.arguments
?.getString("attachment")
?.ifBlank { null }
?.toUri()
NewGroupDMScreen(
draftMessage,
attachment,
accountViewModel = accountViewModel,
nav = nav,
)
},
)
composable(
Route.Event.route,
Route.Event.arguments,
) {
LoadRedirectScreen(
eventId = it.id(),
accountViewModel = accountViewModel,
nav = nav,
)
}
composable(
Route.Settings.route,
Route.Settings.arguments,
enterTransition = { slideInHorizontallyFromEnd },
exitTransition = { scaleOut },
popEnterTransition = { scaleIn },
popExitTransition = { slideOutHorizontallyToEnd },
) {
SettingsScreen(
sharedPreferencesViewModel,
accountViewModel,
nav,
)
}
composable(
Route.NIP47Setup.route,
Route.NIP47Setup.arguments,
enterTransition = { slideInVerticallyFromBottom },
exitTransition = { scaleOut },
popEnterTransition = { scaleIn },
popExitTransition = { slideOutVerticallyToBottom },
) {
val nip47 = it.arguments?.getString("nip47")
NIP47SetupScreen(accountViewModel, nav, nip47)
}
composable(
Route.EditRelays.route,
content = {
val relayToAdd = it.arguments?.getString("toAdd")
AllRelayListScreen(
relayToAdd = relayToAdd,
accountViewModel = accountViewModel,
nav = nav,
)
},
)
composable(
Route.NewPost.route,
Route.NewPost.arguments,
enterTransition = { slideInVerticallyFromBottom },
exitTransition = { scaleOut },
popEnterTransition = { scaleIn },
popExitTransition = { slideOutVerticallyToBottom },
) {
val draftMessage = it.message()?.ifBlank { null }
val attachment =
it.arguments?.getString("attachment")?.ifBlank { null }?.let {
Uri.parse(it)
}
val baseReplyTo = it.arguments?.getString("baseReplyTo")
val quote = it.arguments?.getString("quote")
val fork = it.arguments?.getString("fork")
val version = it.arguments?.getString("version")
val draft = it.arguments?.getString("draft")
val enableGeolocation = it.arguments?.getBoolean("enableGeolocation") == true
composableFromBottomArgs<Route.NewPost> {
NewPostScreen( NewPostScreen(
message = draftMessage, message = it.message,
attachment = attachment, attachment = it.attachment?.ifBlank { null }?.toUri(),
baseReplyTo = baseReplyTo?.let { hex -> accountViewModel.getNoteIfExists(hex) }, baseReplyTo = it.baseReplyTo?.let { hex -> accountViewModel.getNoteIfExists(hex) },
quote = quote?.let { hex -> accountViewModel.getNoteIfExists(hex) }, quote = it.quote?.let { hex -> accountViewModel.getNoteIfExists(hex) },
fork = fork?.let { hex -> accountViewModel.getNoteIfExists(hex) }, fork = it.fork?.let { hex -> accountViewModel.getNoteIfExists(hex) },
version = version?.let { hex -> accountViewModel.getNoteIfExists(hex) }, version = it.version?.let { hex -> accountViewModel.getNoteIfExists(hex) },
draft = draft?.let { hex -> accountViewModel.getNoteIfExists(hex) }, draft = it.draft?.let { hex -> accountViewModel.getNoteIfExists(hex) },
enableGeolocation = enableGeolocation, enableGeolocation = it.enableGeolocation,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
nav = nav, nav = nav,
) )
@@ -425,7 +184,7 @@ private fun NavigateIfIntentRequested(
if (activity.intent.action == Intent.ACTION_SEND) { if (activity.intent.action == Intent.ACTION_SEND) {
// avoids restarting the new Post screen when the intent is for the screen. // avoids restarting the new Post screen when the intent is for the screen.
// Microsoft's swift key sends Gifs as new actions // Microsoft's swift key sends Gifs as new actions
if (isBaseRoute(nav.controller, Route.NewPost.base)) return if (isBaseRoute<Route.NewPost>(nav.controller)) return
// saves the intent to avoid processing again // saves the intent to avoid processing again
var message by remember { var message by remember {
@@ -442,7 +201,7 @@ private fun NavigateIfIntentRequested(
) )
} }
nav.newStack(buildNewPostRoute(draftMessage = message, attachment = media)) nav.newStack(Route.NewPost(message = message, attachment = media.toString()))
media = null media = null
message = null message = null
@@ -504,13 +263,13 @@ private fun NavigateIfIntentRequested(
if (intent.action == Intent.ACTION_SEND) { if (intent.action == Intent.ACTION_SEND) {
// avoids restarting the new Post screen when the intent is for the screen. // avoids restarting the new Post screen when the intent is for the screen.
// Microsoft's swift key sends Gifs as new actions // Microsoft's swift key sends Gifs as new actions
if (!isBaseRoute(nav.controller, Route.NewPost.base)) { if (!isBaseRoute<Route.NewPost>(nav.controller)) {
intent.getStringExtra(Intent.EXTRA_TEXT)?.let { intent.getStringExtra(Intent.EXTRA_TEXT)?.let {
nav.newStack(buildNewPostRoute(draftMessage = it)) nav.newStack(Route.NewPost(message = it))
} }
(intent.getParcelableExtra<Parcelable>(Intent.EXTRA_STREAM) as? Uri)?.let { (intent.getParcelableExtra<Parcelable>(Intent.EXTRA_STREAM) as? Uri)?.let {
nav.newStack(buildNewPostRoute(attachment = it)) nav.newStack(Route.NewPost(attachment = it.toString()))
} }
} }
} else { } else {
@@ -561,8 +320,8 @@ private fun NavigateIfIntentRequested(
} }
private fun isSameRoute( private fun isSameRoute(
currentRoute: String?, currentRoute: Route?,
newRoute: String, newRoute: Route,
): Boolean { ): Boolean {
if (currentRoute == null) return false if (currentRoute == null) return false
@@ -570,9 +329,11 @@ private fun isSameRoute(
return true return true
} }
if (newRoute.startsWith("Event/") && currentRoute.contains("/")) { if (newRoute is Route.EventRedirect) {
if (newRoute.split("/")[1] == currentRoute.split("/")[1]) { return when (currentRoute) {
return true is Route.Note -> newRoute.id == currentRoute.id
is Route.Channel -> newRoute.id == currentRoute.id
else -> false
} }
} }
@@ -76,7 +76,7 @@ fun GenericMainTopBar(
LoggedInUserPictureDrawer(accountViewModel, nav::openDrawer) LoggedInUserPictureDrawer(accountViewModel, nav::openDrawer)
}, },
actions = { actions = {
IconButton(onClick = { nav.nav(Route.Search.route) }) { IconButton(onClick = { nav.nav(Route.Search) }) {
SearchIcon(modifier = Size22Modifier, MaterialTheme.colorScheme.placeholderText) SearchIcon(modifier = Size22Modifier, MaterialTheme.colorScheme.placeholderText)
} }
}, },
@@ -72,13 +72,15 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle import androidx.compose.ui.text.withLink
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -89,7 +91,6 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.FeatureSetType import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.actions.mediaServers.MediaServersListView import com.vitorpamplona.amethyst.ui.actions.mediaServers.MediaServersListView
import com.vitorpamplona.amethyst.ui.components.ClickableText
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.note.LoadStatuses import com.vitorpamplona.amethyst.ui.note.LoadStatuses
@@ -232,7 +233,7 @@ fun ProfileContentTemplate(
.width(100.dp) .width(100.dp)
.height(100.dp) .height(100.dp)
.clip(shape = CircleShape) .clip(shape = CircleShape)
.border(3.dp, MaterialTheme.colorScheme.background, CircleShape) .border(3.dp, MaterialTheme.colorScheme.onBackground, CircleShape)
.clickable(onClick = onClick), .clickable(onClick = onClick),
loadProfilePicture = accountViewModel.settings.showProfilePictures.value, loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE, loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE,
@@ -422,7 +423,7 @@ fun WatchFollower(
.observeAsState() .observeAsState()
LaunchedEffect(key1 = accountUserFollowersState) { LaunchedEffect(key1 = accountUserFollowersState) {
onReady(baseAccountUser.followerCount().toString() ?: "--") onReady(baseAccountUser.followerCount().toString())
} }
} }
@@ -433,8 +434,6 @@ fun ListContent(
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: INav, nav: INav,
) { ) {
val route = remember(accountViewModel) { "User/${accountViewModel.userProfile().pubkeyHex}" }
var editMediaServers by remember { mutableStateOf(false) } var editMediaServers by remember { mutableStateOf(false) }
var backupDialogOpen by remember { mutableStateOf(false) } var backupDialogOpen by remember { mutableStateOf(false) }
@@ -445,11 +444,11 @@ fun ListContent(
Column(modifier) { Column(modifier) {
NavigationRow( NavigationRow(
title = stringRes(R.string.profile), title = R.string.profile,
icon = Route.Profile.icon, icon = R.drawable.ic_profile,
tint = MaterialTheme.colorScheme.primary, tint = MaterialTheme.colorScheme.primary,
nav = nav, nav = nav,
route = route, route = remember { Route.Profile(accountViewModel.userProfile().pubkeyHex) },
) )
NavigationRow( NavigationRow(
@@ -461,31 +460,31 @@ fun ListContent(
) )
NavigationRow( NavigationRow(
title = stringRes(R.string.bookmarks), title = R.string.bookmarks,
icon = Route.Bookmarks.icon, icon = R.drawable.ic_bookmarks,
tint = MaterialTheme.colorScheme.onBackground, tint = MaterialTheme.colorScheme.onBackground,
nav = nav, nav = nav,
route = Route.Bookmarks.route, route = Route.Bookmarks,
) )
NavigationRow( NavigationRow(
title = stringRes(R.string.drafts), title = R.string.drafts,
icon = Route.Drafts.icon, icon = R.drawable.ic_topics,
tint = MaterialTheme.colorScheme.onBackground, tint = MaterialTheme.colorScheme.onBackground,
nav = nav, nav = nav,
route = Route.Drafts.route, route = Route.Drafts,
) )
IconRowRelays( IconRowRelays(
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
onClick = { onClick = {
nav.closeDrawer() nav.closeDrawer()
nav.nav(Route.EditRelays.base) nav.nav(Route.EditRelays())
}, },
) )
IconRow( IconRow(
title = stringRes(R.string.media_servers), title = R.string.media_servers,
icon = Icons.Outlined.CloudUpload, icon = Icons.Outlined.CloudUpload,
tint = MaterialTheme.colorScheme.onBackground, tint = MaterialTheme.colorScheme.onBackground,
onClick = { onClick = {
@@ -495,15 +494,15 @@ fun ListContent(
) )
NavigationRow( NavigationRow(
title = stringRes(R.string.security_filters), title = R.string.security_filters,
icon = Route.BlockedUsers.icon, icon = R.drawable.ic_security,
tint = MaterialTheme.colorScheme.onBackground, tint = MaterialTheme.colorScheme.onBackground,
nav = nav, nav = nav,
route = Route.BlockedUsers.route, route = Route.SecurityFilters,
) )
IconRow( IconRow(
title = stringRes(R.string.privacy_options), title = R.string.privacy_options,
icon = R.drawable.ic_tor, icon = R.drawable.ic_tor,
tint = MaterialTheme.colorScheme.onBackground, tint = MaterialTheme.colorScheme.onBackground,
onClick = { onClick = {
@@ -514,7 +513,7 @@ fun ListContent(
accountViewModel.account.settings.keyPair.privKey?.let { accountViewModel.account.settings.keyPair.privKey?.let {
IconRow( IconRow(
title = stringRes(R.string.backup_keys), title = R.string.backup_keys,
icon = R.drawable.ic_key, icon = R.drawable.ic_key,
tint = MaterialTheme.colorScheme.onBackground, tint = MaterialTheme.colorScheme.onBackground,
onClick = { onClick = {
@@ -525,17 +524,17 @@ fun ListContent(
} }
NavigationRow( NavigationRow(
title = stringRes(R.string.preferences), title = R.string.preferences,
icon = Route.Settings.icon, icon = R.drawable.ic_settings,
tint = MaterialTheme.colorScheme.onBackground, tint = MaterialTheme.colorScheme.onBackground,
nav = nav, nav = nav,
route = Route.Settings.route, route = Route.Settings,
) )
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.weight(1f))
IconRow( IconRow(
title = stringRes(R.string.drawer_accounts), title = R.string.drawer_accounts,
icon = Icons.Outlined.GroupAdd, icon = Icons.Outlined.GroupAdd,
tint = MaterialTheme.colorScheme.onBackground, tint = MaterialTheme.colorScheme.onBackground,
onClick = openSheet, onClick = openSheet,
@@ -602,11 +601,11 @@ private fun RenderRelayStatus(relayPool: RelayPoolStatus) {
@Composable @Composable
fun NavigationRow( fun NavigationRow(
title: String, title: Int,
icon: Int, icon: Int,
tint: Color, tint: Color,
nav: INav, nav: INav,
route: String, route: Route,
) { ) {
IconRow( IconRow(
title, title,
@@ -621,7 +620,7 @@ fun NavigationRow(
@Composable @Composable
fun IconRow( fun IconRow(
title: String, title: Int,
icon: Int, icon: Int,
tint: Color, tint: Color,
onClick: () -> Unit, onClick: () -> Unit,
@@ -640,13 +639,13 @@ fun IconRow(
) { ) {
Icon( Icon(
painter = painterResource(icon), painter = painterResource(icon),
null, contentDescription = stringRes(title),
modifier = Size22Modifier, modifier = Size22Modifier,
tint = tint, tint = tint,
) )
Text( Text(
modifier = IconRowTextModifier, modifier = IconRowTextModifier,
text = title, text = stringRes(title),
fontSize = Font18SP, fontSize = Font18SP,
) )
} }
@@ -655,7 +654,7 @@ fun IconRow(
@Composable @Composable
fun IconRow( fun IconRow(
title: String, title: Int,
icon: ImageVector, icon: ImageVector,
tint: Color, tint: Color,
onClick: () -> Unit, onClick: () -> Unit,
@@ -665,7 +664,7 @@ fun IconRow(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable( .clickable(
onClickLabel = title, onClickLabel = stringRes(title),
onClick = onClick, onClick = onClick,
), ),
) { ) {
@@ -675,13 +674,13 @@ fun IconRow(
) { ) {
Icon( Icon(
imageVector = icon, imageVector = icon,
null, contentDescription = stringRes(title),
modifier = Size22Modifier, modifier = Size22Modifier,
tint = tint, tint = tint,
) )
Text( Text(
modifier = IconRowTextModifier, modifier = IconRowTextModifier,
text = title, text = stringRes(title),
fontSize = Font18SP, fontSize = Font18SP,
) )
} }
@@ -749,23 +748,33 @@ fun BottomContent(
.padding(horizontal = 15.dp), .padding(horizontal = 15.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
ClickableText( val string =
text = remember {
buildAnnotatedString { buildAnnotatedString {
withStyle( withLink(
SpanStyle( LinkAnnotation.Clickable(
fontSize = 12.sp, "clickable",
fontWeight = FontWeight.Bold, TextLinkStyles(
), SpanStyle(
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
),
),
) {
nav.nav(Route.Note(BuildConfig.RELEASE_NOTES_ID))
nav.closeDrawer()
},
) { ) {
append("v" + BuildConfig.VERSION_NAME + "-" + BuildConfig.FLAVOR.uppercase()) append("v12" + BuildConfig.VERSION_NAME + "-" + BuildConfig.FLAVOR.uppercase())
} }
}, }
onClick = { }
nav.nav("Note/${BuildConfig.RELEASE_NOTES_ID}")
nav.closeDrawer() Text(
}, text = string,
modifier = Modifier.padding(start = 16.dp), modifier = Modifier.padding(start = 16.dp),
overflow = TextOverflow.Ellipsis,
maxLines = 1,
) )
Box(modifier = Modifier.weight(1F)) Box(modifier = Modifier.weight(1F))
IconButton( IconButton(
@@ -20,6 +20,7 @@
*/ */
package com.vitorpamplona.amethyst.ui.navigation package com.vitorpamplona.amethyst.ui.navigation
import android.annotation.SuppressLint
import androidx.compose.material3.DrawerState import androidx.compose.material3.DrawerState
import androidx.compose.material3.DrawerValue import androidx.compose.material3.DrawerValue
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -31,6 +32,7 @@ import androidx.navigation.compose.rememberNavController
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlin.reflect.KClass
@Composable @Composable
fun rememberNav(): Nav { fun rememberNav(): Nav {
@@ -52,17 +54,17 @@ fun rememberExtendedNav(
interface INav { interface INav {
val drawerState: DrawerState val drawerState: DrawerState
fun nav(route: String) fun nav(route: Route)
fun nav(computeRoute: suspend () -> String) fun nav(computeRoute: suspend () -> Route)
fun newStack(route: String) fun newStack(route: Route)
fun popBack() fun popBack()
fun popUpTo( fun <T : Route> popUpTo(
route: String, route: Route,
upTo: String, klass: KClass<T>,
) )
fun closeDrawer() fun closeDrawer()
@@ -85,7 +87,7 @@ class Nav(
scope.launch { drawerState.open() } scope.launch { drawerState.open() }
} }
override fun nav(route: String) { override fun nav(route: Route) {
scope.launch { scope.launch {
if (getRouteWithArguments(controller) != route) { if (getRouteWithArguments(controller) != route) {
controller.navigate(route) controller.navigate(route)
@@ -93,7 +95,7 @@ class Nav(
} }
} }
override fun nav(computeRoute: suspend () -> String) { override fun nav(computeRoute: suspend () -> Route) {
scope.launch { scope.launch {
val route = computeRoute() val route = computeRoute()
if (getRouteWithArguments(controller) != route) { if (getRouteWithArguments(controller) != route) {
@@ -102,10 +104,10 @@ class Nav(
} }
} }
override fun newStack(route: String) { override fun newStack(route: Route) {
scope.launch { scope.launch {
controller.navigate(route) { controller.navigate(route) {
popUpTo(Route.Home.route) popUpTo(Route.Home)
launchSingleTop = true launchSingleTop = true
} }
} }
@@ -117,12 +119,15 @@ class Nav(
} }
} }
override fun popUpTo( @SuppressLint("RestrictedApi")
route: String, override fun <T : Route> popUpTo(
upTo: String, route: Route,
upToClass: KClass<T>,
) { ) {
scope.launch { scope.launch {
controller.navigate(route) { popUpTo(upTo) { inclusive = true } } controller.navigate(route) {
popUpTo<T>(upToClass) { inclusive = true }
}
} }
} }
} }
@@ -132,34 +137,25 @@ object EmptyNav : INav {
override val drawerState = DrawerState(DrawerValue.Closed) override val drawerState = DrawerState(DrawerValue.Closed)
override fun closeDrawer() { override fun closeDrawer() {
runBlocking { runBlocking { drawerState.close() }
drawerState.close()
}
} }
override fun openDrawer() { override fun openDrawer() {
runBlocking { runBlocking { drawerState.open() }
drawerState.open()
}
} }
override fun nav(route: String) { override fun nav(route: Route) {}
}
override fun nav(computeRoute: suspend () -> String) { override fun nav(computeRoute: suspend () -> Route) {}
}
override fun newStack(route: String) { override fun newStack(route: Route) {}
}
override fun popBack() { override fun popBack() {}
}
override fun popUpTo( override fun <T : Route> popUpTo(
route: String, route: Route,
upTo: String, upToClass: KClass<T>,
) { ) {}
}
} }
fun INav.onNavigate(runOnNavigate: () -> Unit): INav = ObservableNavigate(this, runOnNavigate) fun INav.onNavigate(runOnNavigate: () -> Unit): INav = ObservableNavigate(this, runOnNavigate)
@@ -170,17 +166,25 @@ class ObservableNavigate(
) : INav { ) : INav {
override val drawerState: DrawerState = nav.drawerState override val drawerState: DrawerState = nav.drawerState
override fun nav(route: String) { override fun closeDrawer() {
nav.closeDrawer()
}
override fun openDrawer() {
nav.openDrawer()
}
override fun nav(route: Route) {
onNavigate() onNavigate()
nav.nav(route) nav.nav(route)
} }
override fun nav(computeRoute: suspend () -> String) { override fun nav(computeRoute: suspend () -> Route) {
onNavigate() onNavigate()
nav.nav(computeRoute) nav.nav(computeRoute)
} }
override fun newStack(route: String) { override fun newStack(route: Route) {
onNavigate() onNavigate()
nav.newStack(route) nav.newStack(route)
} }
@@ -190,19 +194,11 @@ class ObservableNavigate(
nav.popBack() nav.popBack()
} }
override fun popUpTo( override fun <T : Route> popUpTo(
route: String, route: Route,
upTo: String, upToClass: KClass<T>,
) { ) {
onNavigate() onNavigate()
nav.popUpTo(route, upTo) nav.popUpTo(route, upToClass)
}
override fun closeDrawer() {
nav.closeDrawer()
}
override fun openDrawer() {
nav.openDrawer()
} }
} }
@@ -0,0 +1,66 @@
/**
* Copyright (c) 2024 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.navigation
import androidx.compose.animation.AnimatedContentScope
import androidx.compose.runtime.Composable
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import androidx.navigation.toRoute
inline fun <reified T : Any> NavGraphBuilder.composableFromEnd(noinline content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit) {
composable<T>(
enterTransition = { slideInHorizontallyFromEnd },
exitTransition = { scaleOut },
popEnterTransition = { scaleIn },
popExitTransition = { slideOutHorizontallyToEnd },
content = content,
)
}
inline fun <reified T : Any> NavGraphBuilder.composableFromEndArgs(noinline content: @Composable AnimatedContentScope.(T) -> Unit) {
composableFromEnd<T> {
content(it.toRoute<T>())
}
}
inline fun <reified T : Any> NavGraphBuilder.composableFromBottom(noinline content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit) {
composable<T>(
enterTransition = { slideInVerticallyFromBottom },
exitTransition = { scaleOut },
popEnterTransition = { scaleIn },
popExitTransition = { slideOutVerticallyToBottom },
content = content,
)
}
inline fun <reified T : Any> NavGraphBuilder.composableFromBottomArgs(noinline content: @Composable AnimatedContentScope.(T) -> Unit) {
composableFromBottom<T> {
content(it.toRoute())
}
}
inline fun <reified T : Any> NavGraphBuilder.composableArgs(noinline content: @Composable AnimatedContentScope.(T) -> Unit) {
composable<T> {
content(it.toRoute())
}
}
@@ -38,13 +38,12 @@ import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessa
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
import java.net.URLEncoder
fun routeFor( fun routeFor(
note: Note, note: Note,
loggedIn: User, loggedIn: User,
): String? { ): Route? {
val noteEvent = note.event ?: return "Note/${URLEncoder.encode(note.idHex, "utf-8")}" val noteEvent = note.event ?: return Route.Note(note.idHex)
return routeFor(noteEvent, loggedIn) return routeFor(noteEvent, loggedIn)
} }
@@ -52,57 +51,57 @@ fun routeFor(
fun routeFor( fun routeFor(
noteEvent: Event, noteEvent: Event,
loggedIn: User, loggedIn: User,
): String? { ): Route? {
if (noteEvent is DraftEvent) { if (noteEvent is DraftEvent) {
val innerEvent = noteEvent.preCachedDraft(loggedIn.pubkeyHex) val innerEvent = noteEvent.preCachedDraft(loggedIn.pubkeyHex)
if (innerEvent is IsInPublicChatChannel) { if (innerEvent is IsInPublicChatChannel) {
innerEvent.channelId()?.let { innerEvent.channelId()?.let {
return "Channel/$it" return Route.Channel(it)
} }
} else if (innerEvent is LiveActivitiesEvent) { } else if (innerEvent is LiveActivitiesEvent) {
innerEvent.aTag().toTag().let { innerEvent.aTag().toTag().let {
return "Channel/${URLEncoder.encode(it, "utf-8")}" return Route.Channel(it)
} }
} else if (innerEvent is LiveActivitiesChatMessageEvent) { } else if (innerEvent is LiveActivitiesChatMessageEvent) {
innerEvent.activity()?.toTag()?.let { innerEvent.activity()?.toTag()?.let {
return "Channel/${URLEncoder.encode(it, "utf-8")}" return Route.Channel(it)
} }
} else if (innerEvent is ChatroomKeyable) { } else if (innerEvent is ChatroomKeyable) {
val room = innerEvent.chatroomKey(loggedIn.pubkeyHex) val room = innerEvent.chatroomKey(loggedIn.pubkeyHex)
loggedIn.createChatroom(room) loggedIn.createChatroom(room)
return "Room/${room.hashCode()}" return Route.Room(room.hashCode())
} else if (innerEvent is AddressableEvent) { } else if (innerEvent is AddressableEvent) {
return "Note/${URLEncoder.encode(noteEvent.aTag().toTag(), "utf-8")}" return Route.Note(noteEvent.aTag().toTag())
} else { } else {
return "Note/${URLEncoder.encode(noteEvent.id, "utf-8")}" return Route.Note(noteEvent.id)
} }
} else if (noteEvent is AppDefinitionEvent) { } else if (noteEvent is AppDefinitionEvent) {
return "ContentDiscovery/${noteEvent.id}" return Route.ContentDiscovery(noteEvent.id)
} else if (noteEvent is IsInPublicChatChannel) { } else if (noteEvent is IsInPublicChatChannel) {
noteEvent.channelId()?.let { noteEvent.channelId()?.let {
return "Channel/$it" return Route.Channel(it)
} }
} else if (noteEvent is ChannelCreateEvent) { } else if (noteEvent is ChannelCreateEvent) {
return "Channel/${noteEvent.id}" return Route.Channel(noteEvent.id)
} else if (noteEvent is LiveActivitiesEvent) { } else if (noteEvent is LiveActivitiesEvent) {
noteEvent.aTag().toTag().let { noteEvent.aTag().toTag().let {
return "Channel/${URLEncoder.encode(it, "utf-8")}" return Route.Channel(it)
} }
} else if (noteEvent is LiveActivitiesChatMessageEvent) { } else if (noteEvent is LiveActivitiesChatMessageEvent) {
noteEvent.activity()?.toTag()?.let { noteEvent.activity()?.toTag()?.let {
return "Channel/${URLEncoder.encode(it, "utf-8")}" return Route.Channel(it)
} }
} else if (noteEvent is ChatroomKeyable) { } else if (noteEvent is ChatroomKeyable) {
val room = noteEvent.chatroomKey(loggedIn.pubkeyHex) val room = noteEvent.chatroomKey(loggedIn.pubkeyHex)
loggedIn.createChatroom(room) loggedIn.createChatroom(room)
return "Room/${room.hashCode()}" return Route.Room(room.hashCode())
} else if (noteEvent is CommunityDefinitionEvent) { } else if (noteEvent is CommunityDefinitionEvent) {
return "Community/${URLEncoder.encode(noteEvent.aTag().toTag(), "utf-8")}" return Route.Community(noteEvent.aTag().toTag())
} else if (noteEvent is AddressableEvent) { } else if (noteEvent is AddressableEvent) {
return "Note/${URLEncoder.encode(noteEvent.aTag().toTag(), "utf-8")}" return Route.Note(noteEvent.aTag().toTag())
} else { } else {
return "Note/${URLEncoder.encode(noteEvent.id, "utf-8")}" return Route.Note(noteEvent.id)
} }
return null return null
@@ -112,14 +111,14 @@ fun routeToMessage(
user: HexKey, user: HexKey,
draftMessage: String?, draftMessage: String?,
replyId: HexKey? = null, replyId: HexKey? = null,
quoteId: HexKey? = null, draftId: HexKey? = null,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
): String = ): Route =
routeToMessage( routeToMessage(
setOf(user), setOf(user),
draftMessage, draftMessage,
replyId, replyId,
quoteId, draftId,
accountViewModel, accountViewModel,
) )
@@ -127,13 +126,13 @@ fun routeToMessage(
users: Set<HexKey>, users: Set<HexKey>,
draftMessage: String?, draftMessage: String?,
replyId: HexKey? = null, replyId: HexKey? = null,
quoteId: HexKey? = null, draftId: HexKey? = null,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
) = routeToMessage( ) = routeToMessage(
ChatroomKey(users), ChatroomKey(users),
draftMessage, draftMessage,
replyId, replyId,
quoteId, draftId,
accountViewModel, accountViewModel,
) )
@@ -141,44 +140,24 @@ fun routeToMessage(
room: ChatroomKey, room: ChatroomKey,
draftMessage: String?, draftMessage: String?,
replyId: HexKey? = null, replyId: HexKey? = null,
quoteId: HexKey? = null, draftId: HexKey? = null,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
): String { ): Route {
accountViewModel.account.userProfile().createChatroom(room) accountViewModel.account.userProfile().createChatroom(room)
val params = return Route.Room(room.hashCode(), draftMessage, replyId, draftId)
listOfNotNull(
draftMessage?.let {
"message=${URLEncoder.encode(it, "utf-8")}"
},
replyId?.let {
"replyId=${URLEncoder.encode(it, "utf-8")}"
},
quoteId?.let {
"quoteId=${URLEncoder.encode(it, "utf-8")}"
},
)
return buildString {
append("Room/")
append(room.hashCode().toString())
if (params.isNotEmpty()) {
append("?")
append(params.joinToString("&"))
}
}
} }
fun routeToMessage( fun routeToMessage(
user: User, user: User,
draftMessage: String?, draftMessage: String?,
replyId: HexKey? = null, replyId: HexKey? = null,
quoteId: HexKey? = null, draftId: HexKey? = null,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
): String = routeToMessage(user.pubkeyHex, draftMessage, replyId, quoteId, accountViewModel) ): Route = routeToMessage(user.pubkeyHex, draftMessage, replyId, draftId, accountViewModel)
fun routeFor(note: Channel): String = "Channel/${note.idHex}" fun routeFor(note: Channel): Route = Route.Channel(note.idHex)
fun routeFor(user: User): String = "User/${user.pubkeyHex}" fun routeFor(user: User): Route.Profile = Route.Profile(user.pubkeyHex)
fun authorRouteFor(note: Note): String = "User/${note.author?.pubkeyHex}" fun authorRouteFor(note: Note): Route.Profile? = note.author?.pubkeyHex?.let { Route.Profile(it) }
@@ -20,124 +20,46 @@
*/ */
package com.vitorpamplona.amethyst.ui.navigation package com.vitorpamplona.amethyst.ui.navigation
import android.R.attr.type
import android.net.Uri
import android.os.Bundle
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.State
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.navigation.NamedNavArgument import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavDestination
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.navigation.NavType import androidx.navigation.toRoute
import androidx.navigation.navArgument
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.theme.Size20dp import com.vitorpamplona.amethyst.ui.theme.Size20dp
import com.vitorpamplona.amethyst.ui.theme.Size23dp import com.vitorpamplona.amethyst.ui.theme.Size23dp
import com.vitorpamplona.amethyst.ui.theme.Size24dp import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.amethyst.ui.theme.Size25dp import kotlinx.serialization.Serializable
import kotlinx.collections.immutable.ImmutableList import kotlin.String
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import java.net.URLEncoder
@Immutable class BottomBarRoute(
sealed class Route( val route: Route,
val route: String,
val base: String = route.substringBefore("?"),
val icon: Int, val icon: Int,
val contentDescriptor: Int = R.string.route,
val notifSize: Modifier = Modifier.size(Size23dp), val notifSize: Modifier = Modifier.size(Size23dp),
val iconSize: Modifier = Modifier.size(Size20dp), val iconSize: Modifier = Modifier.size(Size20dp),
val contentDescriptor: Int = R.string.route, )
val arguments: ImmutableList<NamedNavArgument> = persistentListOf(),
) {
object Home :
Route(
route = "Home",
icon = R.drawable.ic_home,
notifSize = Modifier.size(Size25dp),
iconSize = Modifier.size(Size24dp),
contentDescriptor = R.string.route_home,
)
object Global : sealed class Route {
Route( @Serializable object Home : Route()
route = "Global",
icon = R.drawable.ic_globe,
contentDescriptor = R.string.route_global,
)
object Search : @Serializable object Message : Route()
Route(
route = "Search",
icon = R.drawable.ic_moments,
contentDescriptor = R.string.route_search,
)
object Video : @Serializable object Video : Route()
Route(
route = "Video",
icon = R.drawable.ic_video,
contentDescriptor = R.string.route_video,
)
object Discover : @Serializable object Discover : Route()
Route(
route = "Discover",
icon = R.drawable.ic_sensors,
contentDescriptor = R.string.route_discover,
)
object Notification : @Serializable object Notification : Route()
Route(
route = "Notification",
icon = R.drawable.ic_notifications,
contentDescriptor = R.string.route_notifications,
)
object Message : @Serializable object Search : Route()
Route(
route = "Message",
icon = R.drawable.ic_dm,
contentDescriptor = R.string.route_messages,
)
object NewGroupDM : @Serializable object SecurityFilters : Route()
Route(
route = "NewGroupDM?message={message}&attachment={attachment}",
icon = R.drawable.ic_dm,
contentDescriptor = R.string.route_messages,
arguments =
listOf(
navArgument("message") {
type = NavType.StringType
nullable = true
defaultValue = null
},
navArgument("attachment") {
type = NavType.StringType
nullable = true
defaultValue = null
},
).toImmutableList(),
)
object BlockedUsers : @Serializable object Bookmarks : Route()
Route(
route = "BlockedUsers",
icon = R.drawable.ic_security,
contentDescriptor = R.string.route_security_filters,
)
object Bookmarks : @Serializable object Drafts : Route()
Route(
route = "Bookmarks",
icon = R.drawable.ic_bookmarks,
contentDescriptor = R.string.route_home,
)
@Serializable object Settings : Route()
object Lists : object Lists :
Route( Route(
route = "Lists", route = "Lists",
@@ -145,249 +67,119 @@ sealed class Route(
contentDescriptor = R.string.my_lists, contentDescriptor = R.string.my_lists,
) )
object ContentDiscovery : @Serializable object EditProfile : Route()
Route(
icon = R.drawable.ic_bookmarks,
contentDescriptor = R.string.discover_content,
route = "ContentDiscovery/{id}",
arguments = listOf(navArgument("id") { type = NavType.StringType }).toImmutableList(),
)
object Drafts : @Serializable data class EditRelays(
Route( val toAdd: String? = null,
route = "Drafts", ) : Route()
icon = R.drawable.ic_topics,
contentDescriptor = R.string.drafts,
)
object Profile : @Serializable data class Nip47NWCSetup(
Route( val nip47: String? = null,
route = "User/{id}", ) : Route()
icon = R.drawable.ic_profile,
arguments = listOf(navArgument("id") { type = NavType.StringType }).toImmutableList(),
)
object Note : @Serializable data class Profile(
Route( val id: String,
route = "Note/{id}", ) : Route()
icon = R.drawable.ic_moments,
arguments = listOf(navArgument("id") { type = NavType.StringType }).toImmutableList(),
)
object Hashtag : @Serializable data class ContentDiscovery(
Route( val id: String,
route = "Hashtag/{id}", ) : Route()
icon = R.drawable.ic_moments,
arguments = listOf(navArgument("id") { type = NavType.StringType }).toImmutableList(),
)
object Geohash : @Serializable data class Note(
Route( val id: String,
route = "Geohash/{id}", ) : Route()
icon = R.drawable.ic_moments,
arguments = listOf(navArgument("id") { type = NavType.StringType }).toImmutableList(),
)
object Community : @Serializable data class Hashtag(
Route( val id: String,
route = "Community/{id}", ) : Route()
icon = R.drawable.ic_moments,
arguments = listOf(navArgument("id") { type = NavType.StringType }).toImmutableList(),
)
object Room : @Serializable data class Geohash(
Route( val id: String,
route = "Room/{id}?message={message}&replyId={replyId}&draftId={draftId}", ) : Route()
icon = R.drawable.ic_moments,
arguments =
listOf(
navArgument("id") { type = NavType.StringType },
navArgument("message") {
type = NavType.StringType
nullable = true
defaultValue = null
},
navArgument("replyId") {
type = NavType.StringType
nullable = true
defaultValue = null
},
navArgument("draftId") {
type = NavType.StringType
nullable = true
defaultValue = null
},
).toImmutableList(),
)
object RoomByAuthor : @Serializable data class Community(
Route( val id: String,
route = "RoomByAuthor/{id}", ) : Route()
icon = R.drawable.ic_moments,
arguments = listOf(navArgument("id") { type = NavType.StringType }).toImmutableList(),
)
object Channel : @Serializable data class Channel(
Route( val id: String,
route = "Channel/{id}", ) : Route()
icon = R.drawable.ic_moments,
arguments = listOf(navArgument("id") { type = NavType.StringType }).toImmutableList(),
)
object ChannelMetadataEdit : @Serializable data class ChannelMetadataEdit(
Route( val id: String? = null,
route = "ChannelMetadataEdit?id={id}", ) : Route()
icon = R.drawable.ic_moments,
arguments =
listOf(
navArgument("id") {
type = NavType.StringType
nullable = true
defaultValue = null
},
).toImmutableList(),
)
object Event : @Serializable data class NewGroupDM(
Route( val message: String? = null,
route = "Event/{id}", val attachment: String? = null,
icon = R.drawable.ic_moments, ) : Route()
arguments = listOf(navArgument("id") { type = NavType.StringType }).toImmutableList(),
)
object Settings : @Serializable data class Room(
Route( val id: Int,
route = "Settings", val message: String? = null,
icon = R.drawable.ic_settings, val replyId: HexKey? = null,
) val draftId: HexKey? = null,
) : Route()
object EditProfile : @Serializable data class RoomByAuthor(
Route( val id: String,
route = "EditProfile", ) : Route()
icon = R.drawable.ic_settings,
)
object EditRelays : @Serializable data class EventRedirect(
Route( val id: String,
route = "EditRelays?toAdd={toAdd}", ) : Route()
icon = R.drawable.ic_globe,
contentDescriptor = R.string.relays,
arguments =
listOf(
navArgument("toAdd") {
type = NavType.StringType
nullable = true
defaultValue = null
},
).toImmutableList(),
)
object NIP47Setup : @Serializable
Route( data class NewPost(
route = "NIP47Setup?nip47={nip47}", val message: String? = null,
icon = R.drawable.ic_home, val attachment: String? = null,
arguments = val baseReplyTo: String? = null,
listOf( val quote: String? = null,
navArgument("nip47") { val fork: String? = null,
type = NavType.StringType val version: String? = null,
nullable = true val draft: String? = null,
defaultValue = null val enableGeolocation: Boolean = false,
}, ) : Route()
).toImmutableList(),
)
object NewPost :
Route(
route = "NewPost?message={message}&attachment={attachment}&baseReplyTo={baseReplyTo}&quote={quote}&fork={fork}&version={version}&draft={draft}&enableGeolocation={enableGeolocation}",
icon = R.drawable.ic_moments,
arguments =
listOf(
navArgument("message") { type = NavType.StringType },
navArgument("attachment") { type = NavType.StringType },
navArgument("baseReplyTo") { type = NavType.StringType },
navArgument("quote") { type = NavType.StringType },
navArgument("fork") { type = NavType.StringType },
navArgument("version") { type = NavType.StringType },
navArgument("draft") { type = NavType.StringType },
navArgument("enableGeolocation") { type = NavType.BoolType },
).toImmutableList(),
)
} }
fun isBaseRoute( inline fun <reified T : Route> isBaseRoute(navController: NavHostController): Boolean = navController.currentBackStackEntry?.destination?.hasRoute<T>() == true
navController: NavHostController,
startsWith: String,
): Boolean =
navController.currentBackStackEntry
?.destination
?.route
?.startsWith(startsWith) ?: false
fun getRouteWithArguments(navController: NavHostController): String? { fun getRouteWithArguments(navController: NavHostController): Route? {
val currentEntry = navController.currentBackStackEntry ?: return null val entry = navController.currentBackStackEntry ?: return null
return getRouteWithArguments(currentEntry.destination, currentEntry.arguments) val dest = entry.destination
}
fun getRouteWithArguments(navState: State<NavBackStackEntry?>): String? = navState.value?.let { getRouteWithArguments(it.destination, it.arguments) } return when {
dest.hasRoute<Route.Home>() -> entry.toRoute<Route.Home>()
dest.hasRoute<Route.Message>() -> entry.toRoute<Route.Message>()
dest.hasRoute<Route.Video>() -> entry.toRoute<Route.Video>()
dest.hasRoute<Route.Discover>() -> entry.toRoute<Route.Discover>()
dest.hasRoute<Route.Notification>() -> entry.toRoute<Route.Notification>()
private fun getRouteWithArguments( dest.hasRoute<Route.Search>() -> entry.toRoute<Route.Search>()
destination: NavDestination, dest.hasRoute<Route.SecurityFilters>() -> entry.toRoute<Route.SecurityFilters>()
arguments: Bundle?, dest.hasRoute<Route.Bookmarks>() -> entry.toRoute<Route.Bookmarks>()
): String? { dest.hasRoute<Route.ContentDiscovery>() -> entry.toRoute<Route.ContentDiscovery>()
var route = destination.route ?: return null dest.hasRoute<Route.Drafts>() -> entry.toRoute<Route.Drafts>()
arguments?.let { bundle -> dest.hasRoute<Route.Settings>() -> entry.toRoute<Route.Settings>()
destination.arguments.forEach { dest.hasRoute<Route.EditProfile>() -> entry.toRoute<Route.EditProfile>()
val key = it.key
val value = it.value.type[bundle, key]?.toString() dest.hasRoute<Route.Profile>() -> entry.toRoute<Route.Profile>()
if (value == null) { dest.hasRoute<Route.Note>() -> entry.toRoute<Route.Note>()
val keyStart = route.indexOf("{$key}") dest.hasRoute<Route.Hashtag>() -> entry.toRoute<Route.Hashtag>()
// if it is a parameter, removes the complete segment `var={key}` and adjust connectors `#`, dest.hasRoute<Route.Geohash>() -> entry.toRoute<Route.Geohash>()
// `&` or `&` dest.hasRoute<Route.Community>() -> entry.toRoute<Route.Community>()
if (keyStart > 0 && route[keyStart - 1] == '=') {
val end = keyStart + "{$key}".length dest.hasRoute<Route.RoomByAuthor>() -> entry.toRoute<Route.RoomByAuthor>()
var start = keyStart dest.hasRoute<Route.Channel>() -> entry.toRoute<Route.Channel>()
for (i in keyStart downTo 0) { dest.hasRoute<Route.ChannelMetadataEdit>() -> entry.toRoute<Route.ChannelMetadataEdit>()
if (route[i] == '#' || route[i] == '?' || route[i] == '&') { dest.hasRoute<Route.EventRedirect>() -> entry.toRoute<Route.EventRedirect>()
start = i + 1 dest.hasRoute<Route.EditRelays>() -> entry.toRoute<Route.EditRelays>()
break dest.hasRoute<Route.Nip47NWCSetup>() -> entry.toRoute<Route.Nip47NWCSetup>()
} dest.hasRoute<Route.Room>() -> entry.toRoute<Route.Room>()
} dest.hasRoute<Route.NewPost>() -> entry.toRoute<Route.NewPost>()
if (end < route.length && route[end] == '&') {
route = route.removeRange(start, end + 1) else -> {
} else if (end < route.length && route[end] == '#') { null
route = route.removeRange(start - 1, end)
} else if (end == route.length) {
route = route.removeRange(start - 1, end)
} else {
route = route.removeRange(start, end)
}
} else {
route = route.replaceFirst("{$key}", "")
}
} else {
route = route.replaceFirst("{$key}", value)
}
} }
} }
return route
} }
fun buildNewPostRoute(
draftMessage: String? = null,
attachment: Uri? = null,
baseReplyTo: String? = null,
quote: String? = null,
fork: String? = null,
version: String? = null,
draft: String? = null,
enableGeolocation: Boolean = false,
): String =
"NewPost?" +
"message=${draftMessage?.let { URLEncoder.encode(it, "utf-8") } ?: ""}&" +
"attachment=${attachment?.let { URLEncoder.encode(it.toString(), "utf-8") } ?: ""}&" +
"baseReplyTo=${baseReplyTo ?: ""}&" +
"quote=${quote ?: ""}&" +
"fork=${fork ?: ""}&" +
"version=${version ?: ""}&" +
"draft=${draft ?: ""}&" +
"enableGeolocation=$enableGeolocation&"
@@ -570,7 +570,7 @@ private fun BoxedAuthor(
nav: INav, nav: INav,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
) { ) {
Box(modifier = Size35Modifier.clickable(onClick = { nav.nav(authorRouteFor(note)) })) { Box(modifier = Size35Modifier.clickable(onClick = { authorRouteFor(note)?.let { nav.nav(it) } })) {
WatchAuthorWithBlank(note, Size35Modifier, accountViewModel) { author -> WatchAuthorWithBlank(note, Size35Modifier, accountViewModel) { author ->
WatchUserMetadataAndFollowsAndRenderUserProfilePictureOrDefaultAuthor( WatchUserMetadataAndFollowsAndRenderUserProfilePictureOrDefaultAuthor(
author, author,
@@ -24,7 +24,6 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.OpenInNew import androidx.compose.material.icons.filled.OpenInNew
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@@ -390,8 +389,8 @@ fun DisplayNIP05(
NIP05VerifiedSymbol(nip05Verified, NIP05IconSize, accountViewModel) NIP05VerifiedSymbol(nip05Verified, NIP05IconSize, accountViewModel)
ClickableText( ClickableTextPrimary(
text = remember(nip05) { AnnotatedString(domain) }, text = domain,
onClick = { runCatching { uri.openUri("https://$domain") } }, onClick = { runCatching { uri.openUri("https://$domain") } },
style = style =
LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.nip05, fontSize = Font14SP), LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.nip05, fontSize = Font14SP),
@@ -441,7 +440,7 @@ fun DisplayNip05ProfileStatus(
if (user != "_") { if (user != "_") {
Text( Text(
text = remember { AnnotatedString(user + "@") }, text = "$user@",
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(top = 1.dp, bottom = 1.dp, start = 5.dp), modifier = Modifier.padding(top = 1.dp, bottom = 1.dp, start = 5.dp),
maxLines = 1, maxLines = 1,
@@ -450,10 +449,9 @@ fun DisplayNip05ProfileStatus(
domainPadStart = 0.dp domainPadStart = 0.dp
} }
ClickableText( ClickableTextPrimary(
text = AnnotatedString(domain), text = domain,
onClick = { nip05.let { runCatching { uri.openUri("https://${it.split("@")[1]}") } } }, onClick = { nip05.let { runCatching { uri.openUri("https://${it.split("@")[1]}") } } },
style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary),
modifier = Modifier.padding(top = 1.dp, bottom = 1.dp, start = domainPadStart), modifier = Modifier.padding(top = 1.dp, bottom = 1.dp, start = domainPadStart),
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
@@ -88,7 +88,7 @@ import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.components.SelectTextDialog import com.vitorpamplona.amethyst.ui.components.SelectTextDialog
import com.vitorpamplona.amethyst.ui.navigation.EmptyNav import com.vitorpamplona.amethyst.ui.navigation.EmptyNav
import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.navigation.buildNewPostRoute import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ReportNoteDialog import com.vitorpamplona.amethyst.ui.screen.loggedIn.ReportNoteDialog
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
@@ -173,11 +173,11 @@ fun NoteQuickActionMenu(
note = note, note = note,
onDismiss = onDismiss, onDismiss = onDismiss,
onWantsToEditDraft = { onWantsToEditDraft = {
val route = nav.nav(
buildNewPostRoute( Route.NewPost(
draft = note.idHex, draft = note.idHex,
) ),
nav.nav(route) )
}, },
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
nav = nav, nav = nav,

Some files were not shown because too many files have changed in this diff Show More