diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index eebabdfba..08282a725 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -25,8 +25,9 @@ If applicable, add a video and/or screenshots to help explain your problem. **Device (please complete the following information):** - Phone Brand/Model [e.g. Pixel 7 Pro]: - - Android Version [e.g. 33]: - - App Version [e.g. v0.20.3]: + - Android Version [e.g. 34]: + - App Version [e.g. v0.94.3]: + - App Flavour [e.g. Google Play or FDroid]: - Amber Version (if using it to sign): **Bounty (in Bitcoin sats) offered for a solution** diff --git a/amethyst/build.gradle b/amethyst/build.gradle index 75d9ca054..8a22cb0f6 100644 --- a/amethyst/build.gradle +++ b/amethyst/build.gradle @@ -108,7 +108,6 @@ android { release { proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), 'proguard-rules.pro' minifyEnabled true - resValue "string", "app_name", "@string/app_name_release" } debug { applicationIdSuffix '.debug' diff --git a/amethyst/proguard-rules.pro b/amethyst/proguard-rules.pro index c84569d50..830dc4248 100644 --- a/amethyst/proguard-rules.pro +++ b/amethyst/proguard-rules.pro @@ -61,7 +61,5 @@ # JSON parsing -keep class com.vitorpamplona.quartz.** { *; } --keep class com.vitorpamplona.amethyst.model.** { *; } --keep class com.vitorpamplona.amethyst.service.** { *; } --keep class com.vitorpamplona.ammolite.service.** { *; } --keep class com.vitorpamplona.ammolite.relays.** { *; } +-keep class com.vitorpamplona.amethyst.** { *; } +-keep class com.vitorpamplona.ammolite.** { *; } diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/DMDecryptionTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/DMFileDecryptionTest.kt similarity index 79% rename from amethyst/src/androidTest/java/com/vitorpamplona/amethyst/DMDecryptionTest.kt rename to amethyst/src/androidTest/java/com/vitorpamplona/amethyst/DMFileDecryptionTest.kt index ed80e2f77..0c53bf220 100644 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/DMDecryptionTest.kt +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/DMFileDecryptionTest.kt @@ -21,17 +21,20 @@ package com.vitorpamplona.amethyst 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.nip17Dm.files.encryption.AESGCM import junit.framework.TestCase.assertEquals +import okhttp3.OkHttpClient import okhttp3.Request import org.junit.Test import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) -class DMDecryptionTest { - val okHttp = HttpClientManager.getHttpClient(false) +class DMFileDecryptionTest { + // Key cache to download and decrypt encrypted files before caching them. + val keyCache = EncryptionKeyCache() val url = "https://cdn.satellite.earth/812fd4cf9d4d4b59c141ecd6a6c08c7571b5872237ad6477916cb2d119b5cacd" val cipher = @@ -44,7 +47,13 @@ class DMDecryptionTest { @Test fun runDownloadAndDecryptVideo() { - HttpClientManager.addCipherToCache(url, cipher, "video/mp4") + val client = + OkHttpClient + .Builder() + .addNetworkInterceptor(EncryptedBlobInterceptor(keyCache)) + .build() + + keyCache.add(url, cipher, "video/mp4") val request = Request @@ -54,7 +63,7 @@ class DMDecryptionTest { .get() .build() - okHttp.newCall(request).execute().use { + client.newCall(request).execute().use { assertEquals(decryptedSize, it.body.bytes().size) assertEquals(expectedMimeType, it.body.contentType().toString()) } diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt index bf76e2ab6..90eca1f85 100644 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt @@ -26,6 +26,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import com.vitorpamplona.amethyst.model.Account 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.ImageDownloader import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader @@ -43,6 +44,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.runBlocking +import okhttp3.OkHttpClient import org.junit.Assert import org.junit.Ignore import org.junit.Test @@ -52,11 +54,21 @@ import kotlin.random.Random @RunWith(AndroidJUnit4::class) class ImageUploadTesting { - val account = - Account( - AccountSettings(KeyPair()), - scope = CoroutineScope(Dispatchers.IO + SupervisorJob()), - ) + companion object { + val account = + Account( + 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 { val bitmap = Bitmap.createBitmap(200, 300, Bitmap.Config.ARGB_8888) @@ -94,7 +106,7 @@ class ImageUploadTesting { alt = null, sensitiveContent = null, serverBaseUrl = server.baseUrl, - forceProxy = { false }, + okHttpClient = { client }, httpAuth = account::createBlossomUploadAuth, context = InstrumentationRegistry.getInstrumentation().targetContext, ) @@ -105,7 +117,7 @@ class ImageUploadTesting { assertEquals("${server.baseUrl}/$initialHash", result.url?.removeSuffix(".png")) val imageData: ByteArray = - ImageDownloader().waitAndGetImage(result.url!!, false)?.bytes + ImageDownloader().waitAndGetImage(result.url!!, { client })?.bytes ?: run { fail("${server.name}: Should not be null") return @@ -120,7 +132,7 @@ class ImageUploadTesting { ServerInfoRetriever() .loadInfo( server.baseUrl, - false, + { client }, ) val paylod = getBitmap() @@ -134,7 +146,7 @@ class ImageUploadTesting { alt = null, sensitiveContent = null, server = serverInfo, - forceProxy = { false }, + okHttpClient = { client }, onProgress = {}, httpAuth = account::createHTTPAuthorization, context = InstrumentationRegistry.getInstrumentation().targetContext, @@ -148,7 +160,7 @@ class ImageUploadTesting { Assert.assertTrue("${server.name}: Invalid result url", url.startsWith("http")) val imageData: ByteArray = - ImageDownloader().waitAndGetImage(url, false)?.bytes + ImageDownloader().waitAndGetImage(url, { client })?.bytes ?: run { fail("${server.name}: Should not be null") return @@ -255,6 +267,7 @@ class ImageUploadTesting { } @Test() + @Ignore("Not Working anymore/ Timeout") fun testNostrCheckBlossom() = runBlocking { testBase(ServerName("nostrcheck", "https://cdn.nostrcheck.me", ServerType.Blossom)) diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/OkHttpOtsTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/OkHttpOtsTest.kt index 956ae15ad..d6503b45b 100644 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/OkHttpOtsTest.kt +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/OkHttpOtsTest.kt @@ -21,17 +21,17 @@ package com.vitorpamplona.amethyst 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.OtsBlockHeightCache import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent import com.vitorpamplona.quartz.nip03Timestamp.OtsResolver -import com.vitorpamplona.quartz.nip03Timestamp.ots.OpenTimestamps import junit.framework.TestCase.assertEquals +import okhttp3.OkHttpClient import org.junit.Assert -import org.junit.Before import org.junit.Test import org.junit.runner.RunWith 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 otsEvent2Digest = "a8634f5368e17789e8fb3836cad0e4b9fe4f2288afafc28723c04bebc68a18d6" + val otsCache = OtsBlockHeightCache() - @Before - fun setup() { - OtsResolver.ots = OpenTimestamps(OkHttpBlockstreamExplorer(forceProxy = { false }), OkHttpCalendarBuilder(forceProxy = { false })) - } + val resolver = + OtsResolver( + OkHttpBitcoinExplorer( + OkHttpBitcoinExplorer.MEMPOOL_API_URL, + client = OkHttpClient.Builder().build(), + otsCache, + ), + OkHttpCalendarBuilder { OkHttpClient.Builder().build() }, + ) @Test fun verifyNostrEvent() { val ots = EventMapper.fromJson(otsEvent) as OtsEvent - println(OtsResolver.info(ots.otsByteArray())) - assertEquals(1707688818L, ots.verify()) + println(resolver.info(ots.otsByteArray())) + assertEquals(1707688818L, ots.verify(resolver)) } @Test fun verifyNostrEvent2() { val ots = EventMapper.fromJson(otsEvent2) as OtsEvent - println(OtsResolver.info(ots.otsByteArray())) - assertEquals(1706322179L, ots.verify()) + println(resolver.info(ots.otsByteArray())) + assertEquals(1706322179L, ots.verify(resolver)) } @Test fun verifyNostrPendingEvent() { val ots = EventMapper.fromJson(otsPendingEvent) as OtsEvent - println(OtsResolver.info(ots.otsByteArray())) - assertEquals(null, ots.verify()) + println(resolver.info(ots.otsByteArray())) + assertEquals(null, ots.verify(resolver)) } @Test @@ -78,7 +84,7 @@ class OkHttpOtsTest { val countDownLatch = CountDownLatch(1) - val otsFile = OtsEvent.stamp(otsEvent2Digest) + val otsFile = OtsEvent.stamp(otsEvent2Digest, resolver) signer.sign(OtsEvent.build(otsEvent2Digest, otsFile)) { ots = it @@ -88,9 +94,9 @@ class OkHttpOtsTest { Assert.assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) println(ots!!.toJson()) - println(OtsResolver.info(ots!!.otsByteArray())) + println(resolver.info(ots.otsByteArray())) // Should not be valid because we need to wait for confirmations - assertEquals(null, ots!!.verify()) + assertEquals(null, ots.verify(resolver)) } } diff --git a/amethyst/src/androidTest/res/values/strings.xml b/amethyst/src/androidTest/res/values/strings.xml index 024194479..7e708a999 100644 --- a/amethyst/src/androidTest/res/values/strings.xml +++ b/amethyst/src/androidTest/res/values/strings.xml @@ -1,5 +1,5 @@ - Amethyst + Amethyst Amy Debug Amy Benchmark \ No newline at end of file diff --git a/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushMessageReceiver.kt b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushMessageReceiver.kt index 20d697e8b..837138d01 100644 --- a/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushMessageReceiver.kt +++ b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushMessageReceiver.kt @@ -89,7 +89,9 @@ class PushMessageReceiver : MessagingReceiver() { Log.d(TAG, "New endpoint provided:- $endpoint for Instance: $instance ${pushHandler.getSavedEndpoint()} $sanitizedEndpoint") pushHandler.setEndpoint(sanitizedEndpoint) 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().getOrCreateDMChannel(appContext) } diff --git a/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationUtils.kt b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationUtils.kt index 21ca41b1f..6deb400c6 100644 --- a/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationUtils.kt +++ b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationUtils.kt @@ -20,28 +20,53 @@ */ package com.vitorpamplona.amethyst.service.notifications -import android.util.Log import com.vitorpamplona.amethyst.AccountInfo -import kotlinx.coroutines.CancellationException +import com.vitorpamplona.amethyst.service.retryIfException +import kotlinx.coroutines.Dispatchers +import okhttp3.OkHttpClient object PushNotificationUtils { - var hasInit: Boolean = false + var lastToken: String? = null + var hasInit: List? = null + private val pushHandler = PushDistributorHandler - suspend fun init(accounts: List) { - if (hasInit) { - return - } - try { - if (pushHandler.savedDistributorExists()) { - val currentDistributor = PushDistributorHandler.getSavedDistributor() - PushDistributorHandler.saveDistributor(currentDistributor) + suspend fun checkAndInit( + accounts: List, + okHttpClient: (String) -> OkHttpClient, + ) = with(Dispatchers.IO) { + if (!pushHandler.savedDistributorExists()) return - RegisterAccounts(accounts).go(pushHandler.getSavedEndpoint()) - } - } catch (e: Exception) { - if (e is CancellationException) throw e - Log.d("Amethyst-OSSPushUtils", "Failed to get endpoint.") + val currentDistributor = PushDistributorHandler.getSavedDistributor() + PushDistributorHandler.saveDistributor(currentDistributor) + val token = pushHandler.getSavedEndpoint() + + if (hasInit?.equals(accounts) == true && lastToken == token) { + return@with } + + registerToken(token, accounts, okHttpClient) + } + + suspend fun checkAndInit( + token: String, + accounts: List, + 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, + okHttpClient: (String) -> OkHttpClient, + ) = retryIfException("RegisterAccounts") { + RegisterAccounts(accounts, okHttpClient).go(token) + lastToken = token + hasInit = accounts.toList() } } diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml index af1a6163c..7daf6a19e 100644 --- a/amethyst/src/main/AndroidManifest.xml +++ b/amethyst/src/main/AndroidManifest.xml @@ -66,7 +66,7 @@ + Log.e("AmethystCoroutine", "Caught exception: ${throwable.message}", throwable) + } - // Service Manager is only active when the activity is active. - val serviceManager = ServiceManager(client, applicationIOScope) + val applicationIOScope = CoroutineScope(Dispatchers.IO + SupervisorJob() + exceptionHandler) + + // 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 torManager = TorManager(this, applicationIOScope) + val connManager = ConnectivityManager(this, applicationIOScope) + // Service that will run at all times. 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() { super.onTerminate() - unregisterReceiver(pokeyReceiver) - applicationIOScope.cancel() - } + Log.d("AmethystApp", "onTerminate $this") - fun nip95cache() = safeCacheDir.resolve("NIP95") - - 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() + pokeyReceiver.unregister(this) + applicationIOScope.cancel("Application onTerminate $this") } fun contentResolverFn(): ContentResolver = contentResolver - override fun onCreate() { - super.onCreate() + fun isDebug() = BuildConfig.DEBUG || BuildConfig.BUILD_TYPE == "benchmark" - instance = this - - HttpClientManager.setDefaultUserAgent("Amethyst/${BuildConfig.VERSION_NAME}") - - 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() + fun setImageLoader(shouldUseTor: Boolean?) = + ImageLoaderSetup.setup(this, diskCache, memoryCache, isDebug()) { + shouldUseTor?.let { okHttpClients.getHttpClient(it) } ?: okHttpClients.getHttpClient(false) } - 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) /** @@ -167,32 +138,16 @@ class Amethyst : Application() { * * @param level the memory-related event that was raised. */ - @OptIn(DelicateCoroutinesApi::class) override fun onTrimMemory(level: Int) { super.onTrimMemory(level) - println("Trim Memory $level") - GlobalScope.launch(Dispatchers.Default) { - println("Trim Memory Inside $level") + Log.d("AmethystApp", "onTrimMemory $level") + applicationIOScope.launch(Dispatchers.Default) { 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 { lateinit var instance: Amethyst private set } } - -internal val Context.safeCacheDir: File - get() { - val cacheDir = checkNotNull(cacheDir) { "cacheDir == null" } - return cacheDir.apply { mkdirs() } - } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/DebugUtils.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/DebugUtils.kt index e075cb8e6..f0001431c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/DebugUtils.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/DebugUtils.kt @@ -91,7 +91,7 @@ fun debugState(context: Context) { Log.d( "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( "STATE DUMP", diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 793881ef2..6ef2fe673 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -25,6 +25,7 @@ import android.content.Context import android.content.SharedPreferences import android.util.Log import androidx.compose.runtime.Immutable +import androidx.core.content.edit import com.fasterxml.jackson.module.kotlin.readValue import com.vitorpamplona.amethyst.model.AccountLanguagePreferencesInternal import com.vitorpamplona.amethyst.model.AccountReactionPreferencesInternal @@ -151,13 +152,13 @@ object LocalPreferences { if (info == null) { currentAccount = null withContext(Dispatchers.IO) { - encryptedPreferences().edit().clear().apply() + encryptedPreferences().edit { clear() } } } else if (currentAccount != info.npub) { currentAccount = info.npub if (!info.isTransient) { 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) - 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) encryptedPreferences() - .edit() - .apply { + .edit { putString( PrefKeys.ALL_ACCOUNT_INFO, EventMapper.mapper.writeValueAsString(accounts.filter { !it.isTransient }), ) - }.apply() + } } } @@ -280,7 +280,7 @@ object LocalPreferences { suspend fun updatePrefsForLogout(accountInfo: AccountInfo) { Log.d("LocalPreferences", "Saving to encrypted storage updatePrefsForLogout ${accountInfo.npub}") withContext(Dispatchers.IO) { - encryptedPreferences(accountInfo.npub).edit().clear().commit() + encryptedPreferences(accountInfo.npub).edit(commit = true) { clear() } removeAccount(accountInfo) deleteUserPreferenceFile(accountInfo.npub) @@ -304,142 +304,140 @@ object LocalPreferences { if (!settings.transientAccount) { withContext(Dispatchers.IO) { val prefs = encryptedPreferences(settings.keyPair.pubKey.toNpub()) - prefs - .edit() - .apply { - putBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, settings.externalSignerPackageName != null) - if (settings.externalSignerPackageName != null) { - remove(PrefKeys.NOSTR_PRIVKEY) - putString(PrefKeys.SIGNER_PACKAGE_NAME, settings.externalSignerPackageName) - } else { - 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)) + prefs.edit { + putBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, settings.externalSignerPackageName != null) + if (settings.externalSignerPackageName != null) { + remove(PrefKeys.NOSTR_PRIVKEY) + putString(PrefKeys.SIGNER_PACKAGE_NAME, settings.externalSignerPackageName) + } else { + 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)) + 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( - PrefKeys.DEFAULT_FILE_SERVER, - EventMapper.mapper.writeValueAsString(settings.defaultFileServer), + PrefKeys.LATEST_CONTACT_LIST, + EventMapper.mapper.writeValueAsString(settings.backupContactList), ) - putString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, settings.defaultHomeFollowList.value) - putString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, settings.defaultStoriesFollowList.value) + } else { + remove(PrefKeys.LATEST_CONTACT_LIST) + } + + if (settings.backupUserMetadata != null) { putString( - PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, - settings.defaultNotificationFollowList.value, + PrefKeys.LATEST_USER_METADATA, + EventMapper.mapper.writeValueAsString(settings.backupUserMetadata), ) + } else { + remove(PrefKeys.LATEST_USER_METADATA) + } + + if (settings.backupDMRelayList != null) { putString( - PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, - settings.defaultDiscoveryFollowList.value, + PrefKeys.LATEST_DM_RELAY_LIST, + EventMapper.mapper.writeValueAsString(settings.backupDMRelayList), ) + } else { + remove(PrefKeys.LATEST_DM_RELAY_LIST) + } + + if (settings.backupNIP65RelayList != null) { putString( - PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, - EventMapper.mapper.writeValueAsString(settings.zapPaymentRequest), + PrefKeys.LATEST_NIP65_RELAY_LIST, + EventMapper.mapper.writeValueAsString(settings.backupNIP65RelayList), ) - if (settings.backupContactList != null) { - putString( - 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 - } + } else { + remove(PrefKeys.LATEST_NIP65_RELAY_LIST) + } + if (settings.backupSearchRelayList != null) { putString( - PrefKeys.LAST_READ_PER_ROUTE, - EventMapper.mapper.writeValueAsString(regularMap), + PrefKeys.LATEST_SEARCH_RELAY_LIST, + 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( - PrefKeys.PENDING_ATTESTATIONS, - EventMapper.mapper.writeValueAsString(settings.pendingAttestations.value), + PrefKeys.LATEST_MUTE_LIST, + 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") @@ -501,161 +499,163 @@ object LocalPreferences { private suspend fun innerLoadCurrentAccountFromEncryptedStorage(npub: String?): AccountSettings? { Log.d("LocalPreferences", "Load account from file $npub") + val result = + withContext(Dispatchers.IO) { + checkNotInMainThread() - return withContext(Dispatchers.IO) { - checkNotInMainThread() + return@withContext with(encryptedPreferences(npub)) { + 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 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 + val defaultHomeFollowList = + getString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, null) ?: KIND3_FOLLOWS + val defaultStoriesFollowList = + getString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS + val defaultNotificationFollowList = + getString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS + val defaultDiscoveryFollowList = + getString(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS - val defaultHomeFollowList = - getString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, null) ?: KIND3_FOLLOWS - val defaultStoriesFollowList = - getString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS - val defaultNotificationFollowList = - getString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS - val defaultDiscoveryFollowList = - getString(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS + val defaultZapType = + getString(PrefKeys.DEFAULT_ZAPTYPE, "")?.let { serverName -> + LnZapEvent.ZapType.entries.firstOrNull { it.name == serverName } + } ?: LnZapEvent.ZapType.PUBLIC - val defaultZapType = - getString(PrefKeys.DEFAULT_ZAPTYPE, "")?.let { serverName -> - LnZapEvent.ZapType.entries.firstOrNull { it.name == serverName } - } ?: LnZapEvent.ZapType.PUBLIC + val localRelays = parseOrNull>(PrefKeys.RELAYS) ?: emptySet() - val localRelays = parseOrNull>(PrefKeys.RELAYS) ?: emptySet() + val zapPaymentRequestServer = parseOrNull(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER) + val defaultFileServer = parseOrNull(PrefKeys.DEFAULT_FILE_SERVER) ?: DEFAULT_MEDIA_SERVERS[0] - val zapPaymentRequestServer = parseOrNull(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER) - val defaultFileServer = parseOrNull(PrefKeys.DEFAULT_FILE_SERVER) ?: DEFAULT_MEDIA_SERVERS[0] + val pendingAttestations = parseOrNull>(PrefKeys.PENDING_ATTESTATIONS) ?: mapOf() + val localRelayServers = getStringSet(PrefKeys.LOCAL_RELAY_SERVERS, null) ?: setOf() - val pendingAttestations = parseOrNull>(PrefKeys.PENDING_ATTESTATIONS) ?: mapOf() - val localRelayServers = getStringSet(PrefKeys.LOCAL_RELAY_SERVERS, null) ?: setOf() + val latestUserMetadata = parseEventOrNull(PrefKeys.LATEST_USER_METADATA) + val latestContactList = parseEventOrNull(PrefKeys.LATEST_CONTACT_LIST) + val latestDmRelayList = parseEventOrNull(PrefKeys.LATEST_DM_RELAY_LIST) + val latestNip65RelayList = parseEventOrNull(PrefKeys.LATEST_NIP65_RELAY_LIST) + val latestSearchRelayList = parseEventOrNull(PrefKeys.LATEST_SEARCH_RELAY_LIST) + val latestMuteList = parseEventOrNull(PrefKeys.LATEST_MUTE_LIST) + val latestPrivateHomeRelayList = parseEventOrNull(PrefKeys.LATEST_PRIVATE_HOME_RELAY_LIST) + val latestAppSpecificData = parseEventOrNull(PrefKeys.LATEST_APP_SPECIFIC_DATA) - val latestUserMetadata = parseEventOrNull(PrefKeys.LATEST_USER_METADATA) - val latestContactList = parseEventOrNull(PrefKeys.LATEST_CONTACT_LIST) - val latestDmRelayList = parseEventOrNull(PrefKeys.LATEST_DM_RELAY_LIST) - val latestNip65RelayList = parseEventOrNull(PrefKeys.LATEST_NIP65_RELAY_LIST) - val latestSearchRelayList = parseEventOrNull(PrefKeys.LATEST_SEARCH_RELAY_LIST) - val latestMuteList = parseEventOrNull(PrefKeys.LATEST_MUTE_LIST) - val latestPrivateHomeRelayList = parseEventOrNull(PrefKeys.LATEST_PRIVATE_HOME_RELAY_LIST) - val latestAppSpecificData = parseEventOrNull(PrefKeys.LATEST_APP_SPECIFIC_DATA) + val syncedSettings = + if (latestAppSpecificData != null) { + null + } else { + // previous version. Delete this when ready. + val reactionChoices = parseOrNull>(PrefKeys.REACTION_CHOICES)?.ifEmpty { DefaultReactions } ?: DefaultReactions + val zapAmountChoices = parseOrNull>(PrefKeys.ZAP_AMOUNTS)?.ifEmpty { DefaultZapAmounts } ?: DefaultZapAmounts - val syncedSettings = - if (latestAppSpecificData != null) { - null - } else { - // previous version. Delete this when ready. - val reactionChoices = parseOrNull>(PrefKeys.REACTION_CHOICES)?.ifEmpty { DefaultReactions } ?: DefaultReactions - val zapAmountChoices = parseOrNull>(PrefKeys.ZAP_AMOUNTS)?.ifEmpty { DefaultZapAmounts } ?: DefaultZapAmounts + val languagePreferences = parseOrNull>(PrefKeys.LANGUAGE_PREFS) ?: mapOf() - val languagePreferences = parseOrNull>(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 = - 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 dontTranslateFrom = getStringSet(PrefKeys.DONT_TRANSLATE_FROM, null) ?: setOf() + val translateTo = getString(PrefKeys.TRANSLATE_TO, null) ?: Locale.getDefault().language - val dontTranslateFrom = getStringSet(PrefKeys.DONT_TRANSLATE_FROM, null) ?: setOf() - val translateTo = getString(PrefKeys.TRANSLATE_TO, null) ?: Locale.getDefault().language + AccountSyncedSettingsInternal( + 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( - 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, - ), - ) - } + val hideDeleteRequestDialog = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false) + val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false) + val hideNIP17WarningDialog = getBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, false) + val useProxy = getBoolean(PrefKeys.USE_PROXY, false) - val hideDeleteRequestDialog = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false) - val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false) - val hideNIP17WarningDialog = getBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, false) - val useProxy = getBoolean(PrefKeys.USE_PROXY, false) + val torSettings = + if (useProxy) { + // old settings, means Orbot + TorSettings( + TorType.EXTERNAL, + getInt(PrefKeys.PROXY_PORT, 9050), + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + ) + } else { + parseOrNull(PrefKeys.TOR_SETTINGS) ?: TorSettings() + } - val torSettings = - if (useProxy) { - // old settings, means Orbot - TorSettings( - TorType.EXTERNAL, - getInt(PrefKeys.PROXY_PORT, 9050), - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - ) - } else { - parseOrNull(PrefKeys.TOR_SETTINGS) ?: TorSettings() - } + val lastReadPerRoute = + parseOrNull>(PrefKeys.LAST_READ_PER_ROUTE)?.mapValues { + MutableStateFlow(it.value) + } ?: mapOf() - val lastReadPerRoute = - parseOrNull>(PrefKeys.LAST_READ_PER_ROUTE)?.mapValues { - MutableStateFlow(it.value) - } ?: mapOf() + val keyPair = KeyPair(privKey = privKey?.hexToByteArray(), pubKey = pubKey.hexToByteArray()) + val hasDonatedInVersion = getStringSet(PrefKeys.HAS_DONATED_IN_VERSION, null) ?: setOf() - val keyPair = KeyPair(privKey = privKey?.hexToByteArray(), pubKey = pubKey.hexToByteArray()) - val hasDonatedInVersion = getStringSet(PrefKeys.HAS_DONATED_IN_VERSION, null) ?: setOf() - - return@with AccountSettings( - keyPair = keyPair, - transientAccount = false, - externalSignerPackageName = externalSignerPackageName, - localRelays = localRelays, - localRelayServers = localRelayServers, - defaultFileServer = defaultFileServer, - defaultHomeFollowList = MutableStateFlow(defaultHomeFollowList), - defaultStoriesFollowList = MutableStateFlow(defaultStoriesFollowList), - defaultNotificationFollowList = MutableStateFlow(defaultNotificationFollowList), - defaultDiscoveryFollowList = MutableStateFlow(defaultDiscoveryFollowList), - zapPaymentRequest = zapPaymentRequestServer, - hideDeleteRequestDialog = hideDeleteRequestDialog, - hideBlockAlertDialog = hideBlockAlertDialog, - hideNIP17WarningDialog = hideNIP17WarningDialog, - backupUserMetadata = latestUserMetadata, - backupContactList = latestContactList, - backupNIP65RelayList = latestNip65RelayList, - backupDMRelayList = latestDmRelayList, - backupSearchRelayList = latestSearchRelayList, - backupPrivateHomeRelayList = latestPrivateHomeRelayList, - backupMuteList = latestMuteList, - backupAppSpecificData = latestAppSpecificData, - backupSyncedSettings = syncedSettings, - torSettings = TorSettingsFlow.build(torSettings), - lastReadPerRoute = MutableStateFlow(lastReadPerRoute), - hasDonatedInVersion = MutableStateFlow(hasDonatedInVersion), - pendingAttestations = MutableStateFlow(pendingAttestations), - ) + return@with AccountSettings( + keyPair = keyPair, + transientAccount = false, + externalSignerPackageName = externalSignerPackageName, + localRelays = localRelays, + localRelayServers = localRelayServers, + defaultFileServer = defaultFileServer, + defaultHomeFollowList = MutableStateFlow(defaultHomeFollowList), + defaultStoriesFollowList = MutableStateFlow(defaultStoriesFollowList), + defaultNotificationFollowList = MutableStateFlow(defaultNotificationFollowList), + defaultDiscoveryFollowList = MutableStateFlow(defaultDiscoveryFollowList), + zapPaymentRequest = zapPaymentRequestServer, + hideDeleteRequestDialog = hideDeleteRequestDialog, + hideBlockAlertDialog = hideBlockAlertDialog, + hideNIP17WarningDialog = hideNIP17WarningDialog, + backupUserMetadata = latestUserMetadata, + backupContactList = latestContactList, + backupNIP65RelayList = latestNip65RelayList, + backupDMRelayList = latestDmRelayList, + backupSearchRelayList = latestSearchRelayList, + backupPrivateHomeRelayList = latestPrivateHomeRelayList, + backupMuteList = latestMuteList, + backupAppSpecificData = latestAppSpecificData, + backupSyncedSettings = syncedSettings, + torSettings = TorSettingsFlow.build(torSettings), + lastReadPerRoute = MutableStateFlow(lastReadPerRoute), + hasDonatedInVersion = MutableStateFlow(hasDonatedInVersion), + pendingAttestations = MutableStateFlow(pendingAttestations), + ) + } } - } + Log.d("LocalPreferences", "Loaded account from file $npub") + return result } private inline fun SharedPreferences.parseOrNull(key: String): T? { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt index 6dbdc7564..4eebd0271 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt @@ -20,21 +20,11 @@ */ package com.vitorpamplona.amethyst -import android.os.Build import android.util.Log import androidx.compose.runtime.Stable -import coil3.SingletonImageLoader 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.LocalCache -import com.vitorpamplona.amethyst.service.Base64Fetcher -import com.vitorpamplona.amethyst.service.BlurHashFetcher import com.vitorpamplona.amethyst.service.NostrAccountDataSource import com.vitorpamplona.amethyst.service.NostrChannelDataSource 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.NostrUserProfileDataSource import com.vitorpamplona.amethyst.service.NostrVideoDataSource -import com.vitorpamplona.amethyst.service.okhttp.HttpClientManager -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.amethyst.service.eventCache.MemoryTrimmingService import com.vitorpamplona.ammolite.relays.NostrClient 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.decodePublicKeyAsHexOrNull import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -71,7 +54,6 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking -import java.util.concurrent.atomic.AtomicBoolean @Stable class ServiceManager( @@ -85,7 +67,7 @@ class ServiceManager( private var collectorJob: Job? = null - var isTrimmingMemoryMutex = AtomicBoolean(false) + private val trimmingService = MemoryTrimmingService() private fun start(account: Account) { this.account = account @@ -94,87 +76,17 @@ class ServiceManager( @OptIn(DelicateCoilApi::class) 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) { + Log.d("ServiceManager", "---- Restarting innactive relay Services with Tor: ${account?.settings?.torSettings?.torType?.value}") + client.reconnect() 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 - // Resets Proxy Use - 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() - } + Amethyst.instance.setImageLoader(myAccount?.shouldUseTorForImageDownload()) if (myAccount != null) { val relaySet = myAccount.connectToRelaysWithProxy.value @@ -230,7 +142,7 @@ class ServiceManager( } private fun pause() { - Log.d("ServiceManager", "Pausing Relay Services") + Log.d("ServiceManager", "-- Pausing Relay Services") collectorJob?.cancel() collectorJob = null @@ -262,27 +174,7 @@ class ServiceManager( } suspend fun trimMemory() { - if (isTrimmingMemoryMutex.compareAndSet(false, true)) { - 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) - } - } + trimmingService.run(account) } // This method keeps the pause/start in a Syncronized block to @@ -293,6 +185,7 @@ class ServiceManager( start: Boolean = true, pause: Boolean = true, ) { + Log.d("ServiceManager", "-- Force Restart (start:$start) (pause:$pause) for $account") if (pause) { pause() } @@ -306,25 +199,25 @@ class ServiceManager( } } - fun restartIfDifferentAccount(account: Account) { - if (this.account != account) { - forceRestart(account, true, true) - } + fun setAccountAndRestart(account: Account) { + forceRestart(account, true, true) } fun forceRestart() { forceRestart(null, true, true) } - fun justStart() { - forceRestart(null, true, false) + fun justStartIfItHasAccount() { + if (account != null) { + forceRestart(null, true, false) + } } fun pauseForGood() { forceRestart(null, false, true) } - fun pauseForGoodAndClearAccount() { + fun pauseAndLogOff() { account = null forceRestart(null, false, true) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index a77c7f864..74bc5c4f7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -36,6 +36,7 @@ import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.service.NostrLnZapPaymentResponseDataSource import com.vitorpamplona.amethyst.service.checkNotInMainThread 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.tryAndWait 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.tags.ContactTag 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.reply import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent @@ -1217,7 +1219,7 @@ class Account( filterSpam: Boolean, ): Boolean { if (settings.updateOptOutOptions(warnReports, filterSpam)) { - if (!settings.syncedSettings.security.filterSpamFromStrangers) { + if (!settings.syncedSettings.security.filterSpamFromStrangers.value) { transientHiddenUsers.update { emptySet() } @@ -1769,8 +1771,10 @@ class Account( suspend fun updateAttestations() { Log.d("Pending Attestations", "Updating ${settings.pendingAttestations.value.size} pending attestations") + val otsResolver = otsResolver() + 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) { val hint = LocalCache.getNoteIfExists(pair.key)?.toEventHint() @@ -1804,8 +1808,9 @@ class Account( if (note.isDraft()) return 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) { @@ -3770,6 +3775,13 @@ class Account( 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 { Log.d("AccountRegisterObservers", "Init") settings.backupContactList?.let { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index 7ea351443..4bd7f8bf0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -122,13 +122,13 @@ class AccountSettings( var hasDonatedInVersion: MutableStateFlow> = MutableStateFlow(setOf()), val pendingAttestations: MutableStateFlow> = MutableStateFlow>(mapOf()), ) { - val saveable = MutableStateFlow(AccountSettingsUpdater(this)) + val saveable = MutableStateFlow(AccountSettingsUpdater(null)) val syncedSettings: AccountSyncedSettings = backupSyncedSettings?.let { AccountSyncedSettings(it) } ?: AccountSyncedSettings(AccountSyncedSettingsInternal()) class AccountSettingsUpdater( - val accountSettings: AccountSettings, + val accountSettings: AccountSettings?, ) fun saveAccountSettings() { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt index 511b8a6bc..7aa0fef4b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt @@ -49,7 +49,7 @@ class AccountSyncedSettings( AccountSecurityPreferences( MutableStateFlow(internalSettings.security.showSensitiveContent), internalSettings.security.warnAboutPostsWithReports, - internalSettings.security.filterSpamFromStrangers, + MutableStateFlow(internalSettings.security.filterSpamFromStrangers), ) fun toInternal(): AccountSyncedSettingsInternal = @@ -70,7 +70,7 @@ class AccountSyncedSettings( AccountSecurityPreferencesInternal( security.showSensitiveContent.value, security.warnAboutPostsWithReports, - security.filterSpamFromStrangers, + security.filterSpamFromStrangers.value, ), ) @@ -105,8 +105,8 @@ class AccountSyncedSettings( security.showSensitiveContent.tryEmit(syncedSettingsInternal.security.showSensitiveContent) } - if (security.filterSpamFromStrangers != syncedSettingsInternal.security.filterSpamFromStrangers) { - security.filterSpamFromStrangers = syncedSettingsInternal.security.filterSpamFromStrangers + if (security.filterSpamFromStrangers.value != syncedSettingsInternal.security.filterSpamFromStrangers) { + security.filterSpamFromStrangers.tryEmit(syncedSettingsInternal.security.filterSpamFromStrangers) } if (security.warnAboutPostsWithReports != syncedSettingsInternal.security.warnAboutPostsWithReports) { @@ -180,7 +180,7 @@ class AccountLanguagePreferences( class AccountSecurityPreferences( val showSensitiveContent: MutableStateFlow = MutableStateFlow(null), var warnAboutPostsWithReports: Boolean = true, - var filterSpamFromStrangers: Boolean = true, + var filterSpamFromStrangers: MutableStateFlow = MutableStateFlow(true), ) { fun updateShowSensitiveContent(show: Boolean?): Boolean { if (showSensitiveContent.value != show) { @@ -197,9 +197,9 @@ class AccountSecurityPreferences( warnReports: Boolean, filterSpam: Boolean, ): Boolean = - if (warnAboutPostsWithReports != warnReports || filterSpam != filterSpamFromStrangers) { + if (warnAboutPostsWithReports != warnReports || filterSpam != filterSpamFromStrangers.value) { warnAboutPostsWithReports = warnReports - filterSpamFromStrangers = filterSpam + filterSpamFromStrangers.tryEmit(filterSpam) true } else { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/HashtagIcon.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/HashtagIcon.kt index 97cc8856e..f1cfd43fc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/HashtagIcon.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/HashtagIcon.kt @@ -33,6 +33,7 @@ import com.vitorpamplona.amethyst.commons.hashtags.Btc import com.vitorpamplona.amethyst.commons.hashtags.Cashu import com.vitorpamplona.amethyst.commons.hashtags.Coffee 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.Gamestr import com.vitorpamplona.amethyst.commons.hashtags.Grownostr @@ -57,7 +58,7 @@ import com.vitorpamplona.quartz.nip02FollowList.EmptyTagList fun RenderHashTagIconsPreview() { ThemeComparisonColumn { 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, ) { word, state -> when (word) { @@ -81,6 +82,7 @@ fun checkForHashtagWithIcon(tag: String): HashtagIcon? = "skullofsatoshi" -> skull "grownostr", "gardening", "garden" -> growstr "footstr" -> footstr + "flowerstr" -> flowerstr "tunestr", "music", "nowplaying" -> tunestr "mate", "matechain", "matestr" -> matestr "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 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 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 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)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index a32c70589..48cd97072 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.commons.data.DeletionIndex import com.vitorpamplona.amethyst.commons.data.LargeCache import com.vitorpamplona.amethyst.model.observables.LatestByKindAndAuthor import com.vitorpamplona.amethyst.model.observables.LatestByKindWithETag +import com.vitorpamplona.amethyst.service.NostrAccountDataSource.account import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.ammolite.relays.BundledInsert 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.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent +import com.vitorpamplona.quartz.nip03Timestamp.OtsResolver import com.vitorpamplona.quartz.nip03Timestamp.VerificationState import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent @@ -938,9 +940,6 @@ object LocalCache { // Already processed this event. 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) { version.loadEvent(event, author, emptyList()) version.liveSet?.innerOts?.invalidateData() @@ -1630,7 +1629,7 @@ object LocalCache { } try { - val cachePath = Amethyst.instance.nip95cache() + val cachePath = Amethyst.instance.nip95cache cachePath.mkdirs() val file = File(cachePath, event.id) if (!file.exists()) { @@ -1991,7 +1990,10 @@ object LocalCache { .toImmutableList() } - suspend fun findEarliestOtsForNote(note: Note): Long? { + suspend fun findEarliestOtsForNote( + note: Note, + resolverBuilder: () -> OtsResolver, + ): Long? { checkNotInMainThread() var minTime: Long? = null @@ -2000,7 +2002,7 @@ object LocalCache { notes.forEach { _, item -> val noteEvent = item.event 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)) { minTime = stampedTime } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UrlCachedPreviewer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UrlCachedPreviewer.kt index 2615ac2bf..933438498 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UrlCachedPreviewer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UrlCachedPreviewer.kt @@ -24,6 +24,7 @@ import android.util.LruCache import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.service.previews.UrlPreview import com.vitorpamplona.amethyst.ui.components.UrlPreviewState +import okhttp3.OkHttpClient @Stable object UrlCachedPreviewer { @@ -32,7 +33,7 @@ object UrlCachedPreviewer { suspend fun previewInfo( url: String, - forceProxy: Boolean, + okHttpClient: (String) -> OkHttpClient, onReady: suspend (UrlPreviewState) -> Unit, ) { cache[url]?.let { @@ -42,7 +43,7 @@ object UrlCachedPreviewer { UrlPreview().fetch( url, - forceProxy, + okHttpClient, onComplete = { urlInfo -> cache[url]?.let { if (it is UrlPreviewState.Loaded || it is UrlPreviewState.Empty) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ApplicationExt.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ApplicationExt.kt new file mode 100644 index 000000000..9fc034bd5 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ApplicationExt.kt @@ -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() } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/CashuProcessor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/CashuProcessor.kt index 165995c30..ba2bc569f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/CashuProcessor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/CashuProcessor.kt @@ -28,7 +28,6 @@ import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.readValue import com.vitorpamplona.amethyst.R 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.stringRes import com.vitorpamplona.quartz.nip01Core.core.toHexKey @@ -40,6 +39,7 @@ import kotlinx.serialization.cbor.ByteString import kotlinx.serialization.cbor.Cbor import kotlinx.serialization.decodeFromByteArray import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import java.util.Base64 @@ -218,7 +218,7 @@ class CashuProcessor { suspend fun melt( token: CashuToken, lud16: String, - forceProxy: (url: String) -> Boolean, + okHttpClient: (String) -> OkHttpClient, onSuccess: (String, String) -> Unit, onError: (String, String) -> Unit, context: Context, @@ -232,12 +232,12 @@ class CashuProcessor { // Make invoice and leave room for fees milliSats = token.totalAmount * 1000, message = "Calculate Fees for Cashu", - forceProxy = forceProxy, + okHttpClient = okHttpClient, onSuccess = { baseInvoice -> feeCalculator( token.mint, baseInvoice, - forceProxy = forceProxy, + okHttpClient = okHttpClient, onSuccess = { fees -> LightningAddressResolver() .lnAddressInvoice( @@ -245,9 +245,9 @@ class CashuProcessor { // Make invoice and leave room for fees milliSats = (token.totalAmount - fees) * 1000, message = "Redeem Cashu", - forceProxy = forceProxy, + okHttpClient = okHttpClient, onSuccess = { invoice -> - meltInvoice(token, invoice, fees, forceProxy, onSuccess, onError, context) + meltInvoice(token, invoice, fees, okHttpClient, onSuccess, onError, context) }, onProgress = {}, onError = onError, @@ -268,7 +268,7 @@ class CashuProcessor { fun feeCalculator( mintAddress: String, invoice: String, - forceProxy: (String) -> Boolean, + okHttpClient: (String) -> OkHttpClient, onSuccess: (Int) -> Unit, onError: (String, String) -> Unit, context: Context, @@ -277,7 +277,7 @@ class CashuProcessor { try { val url = "$mintAddress/checkfees" // Melt cashu tokens at Mint - val client = HttpClientManager.getHttpClient(forceProxy(url)) + val client = okHttpClient(url) val factory = JsonNodeFactory.instance @@ -334,14 +334,14 @@ class CashuProcessor { token: CashuToken, invoice: String, fees: Int, - forceProxy: (String) -> Boolean, + okHttpClient: (String) -> OkHttpClient, onSuccess: (String, String) -> Unit, onError: (String, String) -> Unit, context: Context, ) { try { val url = token.mint + "/melt" // Melt cashu tokens at Mint - val client = HttpClientManager.getHttpClient(forceProxy(url)) + val client = okHttpClient(url) val factory = JsonNodeFactory.instance diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableNoteTag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/CoroutinesExt.kt similarity index 53% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableNoteTag.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/service/CoroutinesExt.kt index d99e1ab51..e19df5073 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableNoteTag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/CoroutinesExt.kt @@ -18,28 +18,34 @@ * 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 +package com.vitorpamplona.amethyst.service -import androidx.compose.foundation.text.ClickableText -import androidx.compose.material3.LocalTextStyle -import androidx.compose.material3.MaterialTheme -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 +import android.util.Log +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.delay -@Composable -fun ClickableNoteTag( - baseNote: Note, - accountViewModel: AccountViewModel, - nav: INav, +suspend fun retryIfException( + debugTag: String = "RetryIfException", + maxRetries: Int = 10, + delayMs: Long = 1000, + func: suspend () -> T, ) { - ClickableText( - text = AnnotatedString("@${baseNote.idNote().toShortenHex()}"), - onClick = { routeFor(baseNote, accountViewModel.userProfile())?.let { nav.nav(it) } }, - style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary), - ) + var tentative = 0 + var currentDelay = delayMs + while (tentative < maxRetries) { + 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 } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip05NostrAddressVerifier.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip05NostrAddressVerifier.kt index 4269c3f80..98609bdd5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip05NostrAddressVerifier.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip05NostrAddressVerifier.kt @@ -21,17 +21,17 @@ package com.vitorpamplona.amethyst.service import com.vitorpamplona.amethyst.BuildConfig -import com.vitorpamplona.amethyst.service.okhttp.HttpClientManager import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05 import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient import okhttp3.Request class Nip05NostrAddressVerifier { suspend fun fetchNip05Json( nip05: String, - forceProxy: (String) -> Boolean, + okttpClient: (String) -> OkHttpClient, onSuccess: suspend (String) -> Unit, onError: (String) -> Unit, ) = withContext(Dispatchers.IO) { @@ -52,8 +52,7 @@ class Nip05NostrAddressVerifier { .url(url) .build() // Fetchers MUST ignore any HTTP redirects given by the /.well-known/nostr.json endpoint. - HttpClientManager - .getHttpClient(forceProxy(url)) + okttpClient(url) .newBuilder() .followRedirects(false) .build() @@ -78,7 +77,7 @@ class Nip05NostrAddressVerifier { suspend fun verifyNip05( nip05: String, - forceProxy: (String) -> Boolean, + okttpClient: (String) -> OkHttpClient, onSuccess: suspend (String) -> Unit, onError: (String) -> Unit, ) { @@ -87,7 +86,7 @@ class Nip05NostrAddressVerifier { fetchNip05Json( nip05, - forceProxy, + okttpClient, onSuccess = { checkNotInMainThread() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip11RelayInfoRetriever.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip11RelayInfoRetriever.kt index fea9d0a0b..ec9567f49 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip11RelayInfoRetriever.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip11RelayInfoRetriever.kt @@ -20,15 +20,16 @@ */ package com.vitorpamplona.amethyst.service +import android.content.ContentProviderOperation.newCall import android.util.Log import android.util.LruCache -import com.vitorpamplona.amethyst.service.okhttp.HttpClientManager import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation import com.vitorpamplona.quartz.nip65RelayList.RelayUrlFormatter import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.CancellationException import okhttp3.Call import okhttp3.Callback +import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response import java.io.IOException @@ -60,7 +61,7 @@ object Nip11CachedRetriever { suspend fun loadRelayInfo( dirtyUrl: String, - forceProxy: Boolean, + okHttpClient: (String) -> OkHttpClient, onInfo: (Nip11RelayInformation) -> Unit, onError: (String, Nip11Retriever.ErrorCode, String?) -> Unit, ) { @@ -75,24 +76,24 @@ object Nip11CachedRetriever { if (TimeUtils.now() - doc.time < TimeUtils.ONE_MINUTE) { // just wait. } else { - retrieve(url, dirtyUrl, forceProxy, onInfo, onError) + retrieve(url, dirtyUrl, okHttpClient, onInfo, onError) } } else if (doc is RetrieveResultError) { if (TimeUtils.now() - doc.time < TimeUtils.ONE_HOUR) { onError(dirtyUrl, doc.error, null) } else { - retrieve(url, dirtyUrl, forceProxy, onInfo, onError) + retrieve(url, dirtyUrl, okHttpClient, onInfo, onError) } } } else { - retrieve(url, dirtyUrl, forceProxy, onInfo, onError) + retrieve(url, dirtyUrl, okHttpClient, onInfo, onError) } } private suspend fun retrieve( url: String, dirtyUrl: String, - forceProxy: Boolean, + okHttpClient: (String) -> OkHttpClient, onInfo: (Nip11RelayInformation) -> Unit, onError: (String, Nip11Retriever.ErrorCode, String?) -> Unit, ) { @@ -100,7 +101,7 @@ object Nip11CachedRetriever { retriever.loadRelayInfo( url = url, dirtyUrl = dirtyUrl, - forceProxy = forceProxy, + okHttpClient = okHttpClient, onInfo = { checkNotInMainThread() relayInformationDocumentCache.put(url, RetrieveResultSuccess(it)) @@ -126,7 +127,7 @@ class Nip11Retriever { suspend fun loadRelayInfo( url: String, dirtyUrl: String, - forceProxy: Boolean, + okHttpClient: (String) -> OkHttpClient, onInfo: (Nip11RelayInformation) -> Unit, onError: (String, ErrorCode, String?) -> Unit, ) { @@ -139,8 +140,7 @@ class Nip11Retriever { .url(url) .build() - HttpClientManager - .getHttpClient(forceProxy) + okHttpClient(url) .newCall(request) .enqueue( object : Callback { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt index ff03bb559..46179fa34 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.service +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache 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.metadata.MetadataEvent import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent @@ -311,6 +313,11 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") { checkNotInMainThread() when (event) { + is OtsEvent -> { + // verifies new OTS upon arrival + Amethyst.instance.otsVerifCache.cacheVerify(event, account::otsResolver) + } + is PrivateOutboxRelayListEvent -> { val note = LocalCache.getAddressableNoteIfExists(event.addressTag()) val noteEvent = note?.event diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/OnlineCheck.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/OnlineCheck.kt index 1fd857049..78c7b92e2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/OnlineCheck.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/OnlineCheck.kt @@ -24,9 +24,9 @@ import android.util.Log import android.util.LruCache import androidx.compose.runtime.Immutable import com.vitorpamplona.amethyst.BuildConfig -import com.vitorpamplona.amethyst.service.okhttp.HttpClientManager import com.vitorpamplona.quartz.utils.RandomInstance import okhttp3.EventListener +import okhttp3.OkHttpClient import okhttp3.Protocol import okhttp3.Request import okio.ByteString.Companion.toByteString @@ -51,7 +51,7 @@ object OnlineChecker { fun isOnline( url: String?, - forceProxy: Boolean, + okttpClient: (String) -> OkHttpClient, ): Boolean { checkNotInMainThread() @@ -76,8 +76,7 @@ object OnlineChecker { .build() val client = - HttpClientManager - .getHttpClient(forceProxy) + okttpClient(url) .newBuilder() .eventListener(EventListener.NONE) .protocols(listOf(Protocol.HTTP_1_1)) @@ -96,7 +95,7 @@ object OnlineChecker { .get() .build() - HttpClientManager.getHttpClient(forceProxy).newCall(request).execute().use { + okttpClient(url).newCall(request).execute().use { checkNotInMainThread() it.isSuccessful } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt index b089043fb..30530b4a5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt @@ -44,6 +44,7 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient import kotlin.math.round class ZapPaymentHandler( @@ -64,7 +65,7 @@ class ZapPaymentHandler( message: String, context: Context, showErrorIfNoLnAddress: Boolean, - forceProxy: (String) -> Boolean, + okHttpClient: (String) -> OkHttpClient, onError: (String, String, User?) -> Unit, onProgress: (percent: Float) -> Unit, onPayViaIntent: (ImmutableList) -> Unit, @@ -130,7 +131,7 @@ class ZapPaymentHandler( 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. }, context) { payables -> if (payables.isEmpty()) { @@ -228,7 +229,7 @@ class ZapPaymentHandler( totalAmountMilliSats: Long, message: String, showErrorIfNoLnAddress: Boolean, - forceProxy: (String) -> Boolean, + okHttpClient: (String) -> OkHttpClient, onError: (String, String, User?) -> Unit, onProgress: (percent: Float) -> Unit, context: Context, @@ -247,7 +248,7 @@ class ZapPaymentHandler( zapValue = calculateZapValue(totalAmountMilliSats, splitZapRequestPair.inputSetup.weight, totalWeight), message = message, showErrorIfNoLnAddress = showErrorIfNoLnAddress, - forceProxy = forceProxy, + okHttpClient = okHttpClient, onError = onError, onProgressStep = { percentStepForThisPayment -> progressAllPayments += percentStepForThisPayment / requests.size @@ -319,7 +320,7 @@ class ZapPaymentHandler( zapValue: Long, message: String, showErrorIfNoLnAddress: Boolean = true, - forceProxy: (String) -> Boolean, + okHttpClient: (String) -> OkHttpClient, onError: (String, String, User?) -> Unit, onProgressStep: (percent: Float) -> Unit, context: Context, @@ -341,7 +342,7 @@ class ZapPaymentHandler( milliSats = zapValue, message = message, nostrRequest = nostrZapRequest, - forceProxy = forceProxy, + okHttpClient = okHttpClient, onError = { title, msg -> onError(title, msg, toUser) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/connectivity/ConnectivityFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/connectivity/ConnectivityFlow.kt new file mode 100644 index 000000000..3b120bced --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/connectivity/ConnectivityFlow.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/connectivity/ConnectivityManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/connectivity/ConnectivityManager.kt new file mode 100644 index 000000000..a9b49c7bb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/connectivity/ConnectivityManager.kt @@ -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 = + ConnectivityFlow(app).status.stateIn( + scope, + SharingStarted.WhileSubscribed(30000), + ConnectivityStatus.Off, + ) + + val isMobileOrNull: StateFlow = + status + .map { + (status.value as? ConnectivityStatus.Active)?.isMobile + }.stateIn( + scope, + SharingStarted.WhileSubscribed(2000), + (status.value as? ConnectivityStatus.Active)?.isMobile, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/connectivity/ConnectivityStatus.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/connectivity/ConnectivityStatus.kt new file mode 100644 index 000000000..017d01361 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/connectivity/ConnectivityStatus.kt @@ -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() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/eventCache/MemoryTrimmingService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/eventCache/MemoryTrimmingService.kt new file mode 100644 index 000000000..be8d891e8 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/eventCache/MemoryTrimmingService.kt @@ -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) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Base64Image.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/Base64Fetcher.kt similarity index 64% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/Base64Image.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/Base64Fetcher.kt index d6a5520a5..ab9c73862 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Base64Image.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/Base64Fetcher.kt @@ -18,10 +18,8 @@ * 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 +package com.vitorpamplona.amethyst.service.images -import android.content.Context -import android.graphics.BitmapFactory import androidx.compose.runtime.Stable import coil3.ImageLoader import coil3.Uri @@ -31,38 +29,22 @@ import coil3.fetch.FetchResult import coil3.fetch.Fetcher import coil3.fetch.ImageFetchResult import coil3.key.Keyer -import coil3.request.ImageRequest 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.utils.sha256.sha256 -import java.util.Base64 @Stable class Base64Fetcher( private val options: Options, private val data: Uri, ) : Fetcher { - override suspend fun fetch(): FetchResult { - checkNotInMainThread() - - val matcher = base64contentPattern.matcher(data.toString()) - - 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") - } - } + override suspend fun fetch(): FetchResult = + ImageFetchResult( + image = Base64Image.Companion.toBitmap(data.toString()).asImage(true), + isSampled = false, + dataSource = DataSource.MEMORY, + ) object Factory : Fetcher.Factory { 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() -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/BlurHashImage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/BlurHashFetcher.kt similarity index 88% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/BlurHashImage.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/BlurHashFetcher.kt index 96cc3044a..f0b6cad84 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/BlurHashImage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/BlurHashFetcher.kt @@ -18,7 +18,7 @@ * 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 +package com.vitorpamplona.amethyst.service.images import androidx.compose.runtime.Stable import coil3.ImageLoader @@ -31,18 +31,16 @@ import coil3.key.Keyer import coil3.request.Options import com.vitorpamplona.amethyst.commons.blurhash.BlurHashDecoder -class Blurhash( +data class BlurhashWrapper( val blurhash: String, ) @Stable class BlurHashFetcher( private val options: Options, - private val data: Blurhash, + private val data: BlurhashWrapper, ) : Fetcher { override suspend fun fetch(): FetchResult { - checkNotInMainThread() - val hash = data.blurhash val bitmap = BlurHashDecoder.decodeKeepAspectRatio(hash, 25) ?: throw Exception("Unable to convert Blurhash $data") @@ -54,17 +52,17 @@ class BlurHashFetcher( ) } - object Factory : Fetcher.Factory { + object Factory : Fetcher.Factory { override fun create( - data: Blurhash, + data: BlurhashWrapper, options: Options, imageLoader: ImageLoader, ): Fetcher = BlurHashFetcher(options, data) } - object BKeyer : Keyer { + object BKeyer : Keyer { override fun key( - data: Blurhash, + data: BlurhashWrapper, options: Options, ): String = data.blurhash } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ImageCacheFactory.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ImageCacheFactory.kt new file mode 100644 index 000000000..c95c6301f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ImageCacheFactory.kt @@ -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() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ImageLoaderSetup.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ImageLoaderSetup.kt new file mode 100644 index 000000000..6abadd5c7 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ImageLoaderSetup.kt @@ -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(), + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LightningAddressResolver.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LightningAddressResolver.kt index b212ccce1..44d5c0910 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LightningAddressResolver.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LightningAddressResolver.kt @@ -27,10 +27,10 @@ import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.service.HttpStatusMessages import com.vitorpamplona.amethyst.service.checkNotInMainThread -import com.vitorpamplona.amethyst.service.okhttp.HttpClientManager import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.lightning.LnInvoiceUtil import com.vitorpamplona.quartz.lightning.Lud06 +import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response import java.math.BigDecimal @@ -55,7 +55,7 @@ class LightningAddressResolver { private fun fetchLightningAddressJson( lnaddress: String, - forceProxy: (url: String) -> Boolean, + okttpClient: (String) -> OkHttpClient, onSuccess: (String) -> Unit, onError: (String, String) -> Unit, context: Context, @@ -76,7 +76,7 @@ class LightningAddressResolver { return } - val client = HttpClientManager.getHttpClient(forceProxy(url)) + val client = okttpClient(url) try { val request: Request = @@ -125,7 +125,7 @@ class LightningAddressResolver { milliSats: Long, message: String, nostrRequest: String? = null, - forceProxy: (url: String) -> Boolean, + okttpClient: (String) -> OkHttpClient, onSuccess: (String) -> Unit, onError: (String, String) -> Unit, context: Context, @@ -142,7 +142,7 @@ class LightningAddressResolver { url += "&nostr=$encodedNostrRequest" } - val client = HttpClientManager.getHttpClient(forceProxy(url)) + val client = okttpClient(url) val request: Request = Request @@ -208,7 +208,7 @@ class LightningAddressResolver { milliSats: Long, message: String, nostrRequest: String? = null, - forceProxy: (url: String) -> Boolean, + okHttpClient: (String) -> OkHttpClient, onSuccess: (String) -> Unit, onError: (String, String) -> Unit, onProgress: (percent: Float) -> Unit, @@ -218,7 +218,7 @@ class LightningAddressResolver { fetchLightningAddressJson( lnaddress, - forceProxy, + okHttpClient, onSuccess = { lnAddressJson -> onProgress(0.4f) @@ -259,7 +259,7 @@ class LightningAddressResolver { milliSats, message, if (allowsNostr) nostrRequest else null, - forceProxy, + okHttpClient, onSuccess = { onProgress(0.6f) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/logging/ChoreographerHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/logging/ChoreographerHelper.kt new file mode 100644 index 000000000..127fae2f0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/logging/ChoreographerHelper.kt @@ -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) + } + }, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LogMonitor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/logging/LogMonitor.kt similarity index 82% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/LogMonitor.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/service/logging/LogMonitor.kt index 4c5f4250a..c23f43c2a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LogMonitor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/logging/LogMonitor.kt @@ -18,14 +18,13 @@ * 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 +package com.vitorpamplona.amethyst.service.logging import android.os.Handler import android.os.HandlerThread import android.os.Looper import android.util.Log import android.util.Printer -import android.view.Choreographer import java.text.SimpleDateFormat import java.util.concurrent.atomic.AtomicBoolean @@ -168,31 +167,3 @@ class StackSampler( 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) - } - }, - ) - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/logging/Logging.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/logging/Logging.kt new file mode 100644 index 000000000..22a0dde20 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/logging/Logging.kt @@ -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() + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/PokeyReceiver.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/PokeyReceiver.kt index 168fb2e91..abdddba9e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/PokeyReceiver.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/PokeyReceiver.kt @@ -20,23 +20,39 @@ */ package com.vitorpamplona.amethyst.service.notifications +import android.app.Application import android.content.BroadcastReceiver import android.content.Context +import android.content.Context.RECEIVER_EXPORTED import android.content.Intent +import android.content.IntentFilter +import android.os.Build +import android.provider.LiveFolders.INTENT import android.util.Log +import androidx.core.content.ContextCompat.registerReceiver +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.quartz.nip01Core.core.Event -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch class PokeyReceiver : BroadcastReceiver() { companion object { - val POKEY_ACTION = "com.shared.NOSTR" - val TAG = "PokeyReceiver" + const val POKEY_ACTION = "com.shared.NOSTR" + 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( context: Context, @@ -48,9 +64,11 @@ class PokeyReceiver : BroadcastReceiver() { if (eventStr == null) return - scope.launch(Dispatchers.IO) { + val app = context.applicationContext as Amethyst + + app.applicationIOScope.launch { try { - EventNotificationConsumer(context.applicationContext).findAccountAndConsume( + EventNotificationConsumer(app).findAccountAndConsume( Event.fromJson(eventStr), ) } catch (e: Exception) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/RegisterAccounts.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/RegisterAccounts.kt index 7b684007b..59423fa84 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/RegisterAccounts.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/RegisterAccounts.kt @@ -27,20 +27,20 @@ import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.launchAndWaitAll import com.vitorpamplona.amethyst.model.AccountSettings -import com.vitorpamplona.amethyst.service.okhttp.HttpClientManager import com.vitorpamplona.amethyst.tryAndWait import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent import com.vitorpamplona.quartz.nip55AndroidSigner.NostrSignerExternal -import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import kotlin.coroutines.resume class RegisterAccounts( private val accounts: List, + private val client: (String) -> OkHttpClient, ) { val tag = if (BuildConfig.FLAVOR == "play") { @@ -133,33 +133,26 @@ class RegisterAccounts( } fun postRegistrationEvent(events: List) { - try { - val jsonObject = - """{ - "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 jsonObject = + """{ + "events": [ ${events.joinToString(", ") { it.toJson() }} ] } + """ + + 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) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/DualHttpClientManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/DualHttpClientManager.kt new file mode 100644 index 000000000..55963b05b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/DualHttpClientManager.kt @@ -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, + isMobileDataProvider: StateFlow, + keyCache: EncryptionKeyCache, + scope: CoroutineScope, +) { + val factory = OkHttpClientFactory(keyCache) + + private val defaultHttpClient: StateFlow = + 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 = + 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 + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/HttpClientManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/HttpClientManager.kt deleted file mode 100644 index 2cb3ee404..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/HttpClientManager.kt +++ /dev/null @@ -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) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactory.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactory.kt new file mode 100644 index 000000000..97ddc8f24 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactory.kt @@ -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)) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpWebSocket.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpWebSocket.kt index f2f48552d..94b6a4de2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpWebSocket.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpWebSocket.kt @@ -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.WebsocketBuilder import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilderFactory +import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response class OkHttpWebSocket( val url: String, val forceProxy: Boolean, + val httpClient: (url: String, forceProxy: Boolean) -> OkHttpClient, val out: WebSocketListener, ) : WebSocket { private val listener = OkHttpWebsocketListener() @@ -38,7 +40,7 @@ class OkHttpWebSocket( fun buildRequest() = Request.Builder().url(url.trim()).build() override fun connect() { - socket = HttpClientManager.getHttpClient(forceProxy).newWebSocket(buildRequest(), listener) + socket = httpClient(url, forceProxy).newWebSocket(buildRequest(), listener) } inner class OkHttpWebsocketListener : okhttp3.WebSocketListener() { @@ -76,15 +78,22 @@ class OkHttpWebSocket( class Builder( val forceProxy: Boolean, + val httpClient: (String, Boolean) -> OkHttpClient, ) : WebsocketBuilder { + // Called when connecting. override fun build( url: String, out: WebSocketListener, - ) = OkHttpWebSocket(url, forceProxy, out) + ) = OkHttpWebSocket(url, forceProxy, httpClient, out) } - class BuilderFactory : WebsocketBuilderFactory { - override fun build(forceProxy: Boolean) = Builder(forceProxy) + class BuilderFactory( + val httpClient: (String, Boolean) -> OkHttpClient, + ) : WebsocketBuilderFactory { + override fun build( + url: String, + forceProxy: Boolean, + ) = Builder(forceProxy, httpClient) } override fun cancel() { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpBlockstreamExplorer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpBitcoinExplorer.kt similarity index 71% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpBlockstreamExplorer.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpBitcoinExplorer.kt index 86b5957a5..f3d684f06 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpBlockstreamExplorer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpBitcoinExplorer.kt @@ -21,21 +21,19 @@ package com.vitorpamplona.amethyst.service.ots import android.util.Log -import android.util.LruCache import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper 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.BlockHeader import com.vitorpamplona.quartz.nip03Timestamp.ots.exceptions.UrlException +import okhttp3.OkHttpClient import okhttp3.Request -class OkHttpBlockstreamExplorer( - val forceProxy: (String) -> Boolean, +class OkHttpBitcoinExplorer( + val baseAPI: String, + val client: OkHttpClient, + val cache: OtsBlockHeightCache, ) : BitcoinExplorer { - private val cacheHeaders = LruCache(100) - private val cacheHeights = LruCache(100) - /** * Retrieve the block information from the block hash. * @@ -44,13 +42,11 @@ class OkHttpBlockstreamExplorer( * @throws Exception desc */ override fun block(hash: String): BlockHeader { - cacheHeaders.get(hash)?.let { + cache.cacheHeaders.get(hash)?.let { return it } - val usingTor = forceProxy(BLOCKSTREAM_API_URL) - val url = "${getAPI(usingTor)}/block/$hash" - val client = HttpClientManager.getHttpClient(forceProxy(url)) + val url = "$baseAPI/block/$hash" val request = Request @@ -63,17 +59,15 @@ class OkHttpBlockstreamExplorer( client.newCall(request).execute().use { if (it.isSuccessful) { + Log.d("OkHttpBlockstreamExplorer", "$baseAPI/block/$hash") + val jsonObject = jacksonObjectMapper().readTree(it.body.string()) - val blockHeader = - BlockHeader() + val blockHeader = BlockHeader() blockHeader.merkleroot = jsonObject["merkle_root"].asText() blockHeader.setTime(jsonObject["timestamp"].asInt().toString()) blockHeader.blockHash = hash - Log.d("OkHttpBlockstreamExplorer", "$BLOCKSTREAM_API_URL/block/$hash") - - cacheHeaders.put(hash, blockHeader) - + cache.cacheHeaders.put(hash, blockHeader) return blockHeader } else { throw UrlException( @@ -92,13 +86,11 @@ class OkHttpBlockstreamExplorer( */ @Throws(Exception::class) override fun blockHash(height: Int): String { - cacheHeights[height]?.let { + cache.cacheHeights[height]?.let { return it } - val usingTor = forceProxy(BLOCKSTREAM_API_URL) - val url = "${getAPI(usingTor)}/block-height/$height" - val client = HttpClientManager.getHttpClient(usingTor) + val url = "$baseAPI/block-height/$height" val request = Request @@ -114,25 +106,19 @@ class OkHttpBlockstreamExplorer( Log.d("OkHttpBlockstreamExplorer", "$url $blockHash") - cacheHeights.put(height, blockHash) + cache.cacheHeights.put(height, blockHash) return blockHash } else { - throw UrlException( - "Couldn't open $url: " + it.message + " " + it.code, - ) + throw UrlException("Couldn't open $url: " + it.message + " " + it.code) } } } companion object { - private const val BLOCKSTREAM_API_URL = "https://blockstream.info/api" - private const val MEMPOOL_API_URL = "https://mempool.space/api/" + // doesn't accept Tor + const val BLOCKSTREAM_API_URL = "https://blockstream.info/api" - fun getAPI(usingTor: Boolean) = - if (usingTor) { - MEMPOOL_API_URL - } else { - BLOCKSTREAM_API_URL - } + // accepts Tor + const val MEMPOOL_API_URL = "https://mempool.space/api/" } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpCalendar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpCalendar.kt index 475eeddfd..73a763a65 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpCalendar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpCalendar.kt @@ -21,7 +21,6 @@ package com.vitorpamplona.amethyst.service.ots 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.StreamDeserializationContext 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.utils.Hex import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient import okhttp3.RequestBody.Companion.toRequestBody /** @@ -38,7 +38,7 @@ import okhttp3.RequestBody.Companion.toRequestBody */ class OkHttpCalendar( val url: String, - val forceProxy: Boolean, + val client: OkHttpClient, ) : ICalendar { /** * 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) override fun submit(digest: ByteArray): Timestamp { try { - val client = HttpClientManager.getHttpClient(forceProxy) val url = "$url/digest" val mediaType = "application/x-www-form-urlencoded; charset=utf-8".toMediaType() @@ -108,7 +107,6 @@ class OkHttpCalendar( ) override fun getTimestamp(commitment: ByteArray): Timestamp { try { - val client = HttpClientManager.getHttpClient(forceProxy) val url = url + "/timestamp/" + Hex.encode(commitment) val request = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpCalendarAsyncSubmit.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpCalendarAsyncSubmit.kt index eab7db495..19e0cbae7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpCalendarAsyncSubmit.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpCalendarAsyncSubmit.kt @@ -21,14 +21,13 @@ package com.vitorpamplona.amethyst.service.ots 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.StreamDeserializationContext import com.vitorpamplona.quartz.nip03Timestamp.ots.Timestamp import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient import okhttp3.RequestBody.Companion.toRequestBody import java.util.Optional -import java.util.concurrent.BlockingQueue /** * For making async calls to a calendar server @@ -36,19 +35,10 @@ import java.util.concurrent.BlockingQueue class OkHttpCalendarAsyncSubmit( private val url: String, private val digest: ByteArray, - private val forceProxy: Boolean, + private val client: OkHttpClient, ) : ICalendarAsyncSubmit { - private var queue: BlockingQueue>? = null - - fun setQueue(queue: BlockingQueue>?) { - this.queue = queue - } - - @Throws(Exception::class) override fun call(): Optional { - val client = HttpClientManager.getHttpClient(forceProxy) val url = "$url/digest" - val mediaType = "application/x-www-form-urlencoded; charset=utf-8".toMediaType() val requestBody = digest.toRequestBody(mediaType) @@ -69,11 +59,8 @@ class OkHttpCalendarAsyncSubmit( it.body.bytes(), ) val timestamp = Timestamp.deserialize(ctx, digest) - val of = Optional.of(timestamp) - queue!!.add(of) - return of + return Optional.of(timestamp) } else { - queue!!.add(Optional.empty()) return Optional.empty() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpCalendarBuilder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpCalendarBuilder.kt index b792d5587..800560cd2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpCalendarBuilder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpCalendarBuilder.kt @@ -23,14 +23,15 @@ package com.vitorpamplona.amethyst.service.ots import com.vitorpamplona.quartz.nip03Timestamp.ots.CalendarBuilder import com.vitorpamplona.quartz.nip03Timestamp.ots.ICalendar import com.vitorpamplona.quartz.nip03Timestamp.ots.ICalendarAsyncSubmit +import okhttp3.OkHttpClient class OkHttpCalendarBuilder( - val forceProxy: (String) -> Boolean, + val clientFn: (url: String) -> OkHttpClient, ) : CalendarBuilder { - override fun newSyncCalendar(url: String): ICalendar = OkHttpCalendar(url, forceProxy(url)) + override fun newSyncCalendar(url: String): ICalendar = OkHttpCalendar(url, clientFn(url)) override fun newAsyncCalendar( url: String, digest: ByteArray, - ): ICalendarAsyncSubmit = OkHttpCalendarAsyncSubmit(url, digest, forceProxy(url)) + ): ICalendarAsyncSubmit = OkHttpCalendarAsyncSubmit(url, digest, clientFn(url)) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OtsBlockHeightCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OtsBlockHeightCache.kt new file mode 100644 index 000000000..1d8a614a5 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OtsBlockHeightCache.kt @@ -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(100) + val cacheHeights = LruCache(100) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OtsResolverBuilder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OtsResolverBuilder.kt new file mode 100644 index 000000000..7a872e534 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OtsResolverBuilder.kt @@ -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)) + }, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/GetVideoController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/GetVideoController.kt index a0fa2515c..5c49807d8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/GetVideoController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/GetVideoController.kt @@ -61,7 +61,7 @@ fun GetVideoController( // If there is a connection, don't wait. if (!onlyOnePreparing.getAndSet(true)) { scope.launch { - Log.d("PlaybackService", "Preparing Video ${controllerId.id} $mediaItem.src.videoUri") + Log.d("PlaybackService", "Preparing Video ${controllerId.id} ${mediaItem.src.videoUri}") PlaybackServiceClient.prepareController( controllerId, mediaItem.src.videoUri, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoViewInner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoViewInner.kt index e560bb155..1f808f36e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoViewInner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoViewInner.kt @@ -26,7 +26,6 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier 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.mediaitem.GetMediaItem import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -64,10 +63,7 @@ fun VideoViewInner( nostrUriCallback, mimeType, aspectRatio, - proxyPort = - HttpClientManager.getCurrentProxyPort( - accountViewModel.account.shouldUseTorForVideoDownload(videoUri), - ), + proxyPort = accountViewModel.proxyPortFor(videoUri), ) { mediaItem -> GetVideoController( mediaItem = mediaItem, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderControlButtons.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderControlButtons.kt index 4cd487f64..585b9a7fd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderControlButtons.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderControlButtons.kt @@ -91,7 +91,7 @@ private fun saveMediaToGalleryInner( ) { MediaSaverToDisk.saveDownloadingIfNeeded( videoUri = videoUri, - forceProxy = accountViewModel.account.shouldUseTorForVideoDownload(), + okHttpClient = accountViewModel::okHttpClientForVideo, mimeType = mimeType, localContext = localContext, onSuccess = { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/diskCache/VideoCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/diskCache/VideoCache.kt index 357f59288..1662a0453 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/diskCache/VideoCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/diskCache/VideoCache.kt @@ -32,26 +32,6 @@ import kotlinx.coroutines.withContext import okhttp3.OkHttpClient 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") class VideoCache { var exoPlayerCacheSize: Long = 150 * 1024 * 1024 // 150MB diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/diskCache/VideoCacheFactory.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/diskCache/VideoCacheFactory.kt new file mode 100644 index 000000000..e63c53a50 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/diskCache/VideoCacheFactory.kt @@ -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 + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/MediaSessionPool.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/MediaSessionPool.kt index e84cfff7a..7f5efa57a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/MediaSessionPool.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/MediaSessionPool.kt @@ -27,15 +27,18 @@ import androidx.annotation.OptIn import androidx.media3.common.MediaItem import androidx.media3.common.Player 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.session.MediaSession import com.google.common.util.concurrent.Futures 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 kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch +import okhttp3.OkHttpClient class SessionListener( val session: MediaSession, @@ -51,6 +54,7 @@ class SessionListener( */ class MediaSessionPool( val exoPlayerPool: ExoPlayerPool, + val okHttpClient: OkHttpClient, val reset: (MediaSession) -> Unit, ) { val globalCallback = MediaSessionCallback(this) @@ -79,6 +83,7 @@ class MediaSessionPool( } } + @OptIn(UnstableApi::class) fun newSession( id: String, context: Context, @@ -87,6 +92,12 @@ class MediaSessionPool( MediaSession .Builder(context, exoPlayerPool.acquirePlayer(context)) .apply { + setBitmapLoader( + DataSourceBitmapLoader( + DataSourceBitmapLoader.DEFAULT_EXECUTOR_SERVICE.get(), + OkHttpDataSource.Factory(okHttpClient), + ), + ) setId(id) setCallback(globalCallback) }.build() @@ -180,7 +191,7 @@ class MediaSessionPool( // set up return call when clicking on the Notification bar mediaItems.firstOrNull()?.mediaMetadata?.extras?.getString("callbackUri")?.let { - mediaSession.setSessionActivity(Amethyst.Companion.instance.createIntent(it)) + mediaSession.setSessionActivity(MainActivity.createIntent(it)) } return Futures.immediateFuture(mediaItems) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackService.kt index 0f0f5dfb7..342cba34d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackService.kt @@ -28,7 +28,7 @@ import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.ExoPlayer import androidx.media3.session.MediaSession 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.playerPool.ExoPlayerBuilder import com.vitorpamplona.amethyst.service.playback.playerPool.ExoPlayerPool @@ -43,6 +43,7 @@ class PlaybackService : MediaSessionService() { fun newPool(okHttp: OkHttpClient): MediaSessionPool = MediaSessionPool( ExoPlayerPool(ExoPlayerBuilder(okHttp)), + okHttpClient = okHttp, reset = { session -> (session.player as ExoPlayer).apply { repeatMode = Player.REPEAT_MODE_ONE @@ -59,12 +60,12 @@ class PlaybackService : MediaSessionService() { poolNoProxy?.let { return it } // creates new - return newPool(HttpClientManager.getHttpClient(false)).also { poolNoProxy = it } + return newPool(Amethyst.instance.okHttpClients.getHttpClient(false)).also { poolNoProxy = it } } else { poolWithProxy?.let { pool -> // with proxy, check if the port is the same. - val okHttp = HttpClientManager.getHttpClient(true) - if (okHttp.proxy == pool.exoPlayerPool.builder.okHttp.proxy) { + val okHttp = Amethyst.instance.okHttpClients.getHttpClient(true) + if (okHttp.proxy != null && okHttp.proxy == pool.exoPlayerPool.builder.okHttp.proxy) { return pool } @@ -73,12 +74,17 @@ class PlaybackService : MediaSessionService() { } // 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() { - Log.d("Lifetime Event", "PlaybackService.onDestroy") + Log.d("PlaybackService", "PlaybackService.onDestroy") poolWithProxy?.destroy() poolNoProxy?.destroy() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/UrlPreview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/UrlPreview.kt index 4f9c98c47..52f4332eb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/UrlPreview.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/UrlPreview.kt @@ -21,21 +21,21 @@ package com.vitorpamplona.amethyst.service.previews import com.vitorpamplona.amethyst.service.checkNotInMainThread -import com.vitorpamplona.amethyst.service.okhttp.HttpClientManager import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient import okhttp3.Request class UrlPreview { suspend fun fetch( url: String, - forceProxy: Boolean, + okHttpClient: (String) -> OkHttpClient, onComplete: suspend (urlInfo: UrlInfoItem) -> Unit, onFailed: suspend (t: Throwable) -> Unit, ) = try { - onComplete(getDocument(url, forceProxy)) + onComplete(getDocument(url, okHttpClient)) } catch (t: Throwable) { if (t is CancellationException) throw t onFailed(t) @@ -43,7 +43,7 @@ class UrlPreview { suspend fun getDocument( url: String, - forceProxy: Boolean, + okHttpClient: (String) -> OkHttpClient, ): UrlInfoItem = withContext(Dispatchers.IO) { val request = @@ -52,7 +52,7 @@ class UrlPreview { .url(url) .get() .build() - HttpClientManager.getHttpClient(forceProxy).newCall(request).execute().use { + okHttpClient(url).newCall(request).execute().use { checkNotInMainThread() if (it.isSuccessful) { val mimeType = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/proxyPort/ImageProxyFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/proxyPort/ImageProxyFlow.kt new file mode 100644 index 000000000..857b5e9f2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/proxyPort/ImageProxyFlow.kt @@ -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, +) { + val status = + image.onEach { + Amethyst.instance.setImageLoader(it) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/proxyPort/ProxyPortFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/proxyPort/ProxyPortFlow.kt new file mode 100644 index 000000000..3141a0a96 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/proxyPort/ProxyPortFlow.kt @@ -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, + externalSocksPort: MutableStateFlow, + torServiceStatus: StateFlow, +) { + @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 + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relays/RelayManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relays/RelayManager.kt new file mode 100644 index 000000000..6bcc65dca --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relays/RelayManager.kt @@ -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 = + RelayService(app).status.stateIn( + scope, + SharingStarted.WhileSubscribed(30000), + RelayServiceStatus.Off, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableUserTag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relays/RelayService.kt similarity index 52% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableUserTag.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/service/relays/RelayService.kt index 69509f49a..9f4503874 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableUserTag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relays/RelayService.kt @@ -18,34 +18,31 @@ * 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 +package com.vitorpamplona.amethyst.service.relays -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.getValue -import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.runtime.remember -import androidx.compose.ui.text.AnnotatedString -import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.ui.navigation.INav +import android.content.Context +import android.util.Log +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch -@Composable -fun ClickableUserTag( - user: User, - nav: INav, +class RelayService( + val context: Context, ) { - 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 = - remember(innerUserState) { AnnotatedString("@${innerUserState?.user?.toBestDisplayName()}") } - - ClickableText( - text = userName, - onClick = { nav.nav(route) }, - style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary), - ) + awaitClose { + Log.d("RelayService", "Stopping Relay Services") + launch { + // ServiceManager.pauseAndLogOff() + } + trySend(RelayServiceStatus.Off) + } + }.distinctUntilChanged() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relays/RelayServiceStatus.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relays/RelayServiceStatus.kt new file mode 100644 index 000000000..a4f6fad1a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relays/RelayServiceStatus.kt @@ -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() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/FileHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/FileHeader.kt index 10930e14e..b61da8bcf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/FileHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/FileHeader.kt @@ -26,11 +26,12 @@ import android.media.MediaDataSource import android.media.MediaMetadataRetriever import android.util.Log 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.nip94FileMetadata.tags.DimensionTag import com.vitorpamplona.quartz.utils.sha256.sha256 import kotlinx.coroutines.CancellationException +import okhttp3.OkHttpClient import java.io.IOException class FileHeader( @@ -38,7 +39,7 @@ class FileHeader( val hash: String, val size: Int, val dim: DimensionTag?, - val blurHash: Blurhash?, + val blurHash: BlurhashWrapper?, ) { class UnableToDownload( val fileUrl: String, @@ -49,10 +50,10 @@ class FileHeader( fileUrl: String, mimeType: String?, dimPrecomputed: DimensionTag?, - forceProxy: Boolean, + okHttpClient: (String) -> OkHttpClient, ): Result = try { - val imageData: ImageDownloader.Blob? = ImageDownloader().waitAndGetImage(fileUrl, forceProxy) + val imageData: ImageDownloader.Blob? = ImageDownloader().waitAndGetImage(fileUrl, okHttpClient) if (imageData != null) { prepare(imageData.bytes, mimeType ?: imageData.contentType, dimPrecomputed) @@ -79,13 +80,13 @@ class FileHeader( val opt = BitmapFactory.Options() opt.inPreferredConfig = Bitmap.Config.ARGB_8888 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) { val mediaMetadataRetriever = MediaMetadataRetriever() mediaMetadataRetriever.setDataSource(ByteArrayMediaDataSource(data)) 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) { Pair(blurhash, newDim) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/ImageDownloader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/ImageDownloader.kt index 065d2178d..a515e5939 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/ImageDownloader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/ImageDownloader.kt @@ -20,11 +20,11 @@ */ package com.vitorpamplona.amethyst.service.uploads -import com.vitorpamplona.amethyst.service.okhttp.HttpClientManager import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient import java.net.HttpURLConnection import java.net.URL @@ -36,7 +36,7 @@ class ImageDownloader { suspend fun waitAndGetImage( imageUrl: String, - forceProxy: Boolean, + okHttpClient: (url: String) -> OkHttpClient, ): Blob? = withContext(Dispatchers.IO) { var imageData: Blob? = null @@ -46,7 +46,7 @@ class ImageDownloader { while (imageData == null && tentatives < 15) { imageData = try { - tryGetTheImage(imageUrl, forceProxy) + tryGetTheImage(imageUrl, okHttpClient) } catch (e: Exception) { if (e is CancellationException) throw e null @@ -63,15 +63,16 @@ class ImageDownloader { private suspend fun tryGetTheImage( imageUrl: String, - forceProxy: Boolean, + okHttpClient: (url: String) -> OkHttpClient, ): Blob? = withContext(Dispatchers.IO) { // TODO: Migrate to OkHttp HttpURLConnection.setFollowRedirects(true) var url = URL(imageUrl) + var clientProxy = okHttpClient(imageUrl).proxy var huc = - if (forceProxy) { - url.openConnection(HttpClientManager.getCurrentProxy()) as HttpURLConnection + if (clientProxy != null) { + url.openConnection(clientProxy) as HttpURLConnection } else { url.openConnection() as HttpURLConnection } @@ -83,9 +84,10 @@ class ImageDownloader { // open the new connnection again url = URL(newUrl) + clientProxy = okHttpClient(newUrl).proxy huc = - if (forceProxy) { - url.openConnection(HttpClientManager.getCurrentProxy()) as HttpURLConnection + if (clientProxy != null) { + url.openConnection(clientProxy) as HttpURLConnection } else { url.openConnection() as HttpURLConnection } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt index bbb09c70f..d36dbd8c7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.service.uploads import android.content.Context import android.net.Uri +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Account 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 kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.map +import okhttp3.OkHttpClient import kotlin.coroutines.cancellation.CancellationException sealed class UploadingState { @@ -149,7 +151,7 @@ class UploadOrchestrator { alt = alt, sensitiveContent = contentWarningReason, serverBaseUrl = serverBaseUrl, - forceProxy = account::shouldUseTorForNIP96, + okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(account.shouldUseTorForNIP96(it)) }, onProgress = { percent: Float -> updateState(0.2 + (0.2 * percent), UploadingState.Uploading) }, @@ -162,7 +164,7 @@ class UploadOrchestrator { localContentType = contentType, originalContentType = contentTypeForResult, originalHash = originalHash, - forceProxy = account::shouldUseTorForNIP96, + okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(account.shouldUseTorForNIP96(it)) }, ) } catch (e: Exception) { if (e is CancellationException) throw e @@ -193,7 +195,7 @@ class UploadOrchestrator { alt = alt, sensitiveContent = contentWarningReason, serverBaseUrl = serverBaseUrl, - forceProxy = account::shouldUseTorForNIP96, + okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(account.shouldUseTorForNIP96(it)) }, httpAuth = account::createBlossomUploadAuth, context = context, ) @@ -201,7 +203,7 @@ class UploadOrchestrator { verifyHeader( uploadResult = result, localContentType = contentType, - forceProxy = account::shouldUseTorForNIP96, + okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(account.shouldUseTorForNIP96(it)) }, originalHash = originalHash, originalContentType = contentTypeForResult, ) @@ -216,7 +218,7 @@ class UploadOrchestrator { localContentType: String?, originalContentType: String?, originalHash: String?, - forceProxy: (String) -> Boolean, + okHttpClient: (String) -> OkHttpClient, ): UploadingFinalState { if (uploadResult.url.isNullOrBlank()) { return error(R.string.server_did_not_provide_a_url_after_uploading) @@ -224,7 +226,7 @@ class UploadOrchestrator { 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) { updateState(0.8, UploadingState.Hashing) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt index 8e76e1a1b..b92462802 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt @@ -31,7 +31,6 @@ import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.service.HttpStatusMessages 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.nip96.randomChars 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.utils.sha256.sha256 import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody import okio.BufferedSink @@ -70,7 +70,7 @@ class BlossomUploader { alt: String?, sensitiveContent: String?, serverBaseUrl: String, - forceProxy: (String) -> Boolean, + okHttpClient: (String) -> OkHttpClient, httpAuth: suspend (hash: HexKey, size: Long, alt: String) -> BlossomAuthorizationEvent?, context: Context, ): MediaUploadResult { @@ -103,7 +103,7 @@ class BlossomUploader { alt, sensitiveContent, serverBaseUrl, - forceProxy, + okHttpClient, httpAuth, context, ) @@ -123,7 +123,7 @@ class BlossomUploader { alt: String?, sensitiveContent: String?, serverBaseUrl: String, - forceProxy: (String) -> Boolean, + okHttpClient: (String) -> OkHttpClient, httpAuth: suspend (hash: HexKey, size: Long, alt: String) -> BlossomAuthorizationEvent?, context: Context, ): MediaUploadResult { @@ -135,7 +135,7 @@ class BlossomUploader { val apiUrl = serverBaseUrl.removeSuffix("/") + "/upload" - val client = HttpClientManager.getHttpClient(forceProxy(apiUrl)) + val client = okHttpClient(apiUrl) val requestBuilder = Request.Builder() val requestBody: RequestBody = @@ -191,7 +191,7 @@ class BlossomUploader { hash: String, contentType: String?, serverBaseUrl: String, - forceProxy: (String) -> Boolean, + okHttpClient: (String) -> OkHttpClient, httpAuth: (hash: HexKey, alt: String) -> BlossomAuthorizationEvent?, context: Context, ): Boolean { @@ -213,7 +213,7 @@ class BlossomUploader { .delete() .build() - HttpClientManager.getHttpClient(forceProxy(apiUrl)).newCall(request).execute().use { response -> + okHttpClient(apiUrl).newCall(request).execute().use { response -> if (response.isSuccessful) { return true } else { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip95/Nip95CacheFactory.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip95/Nip95CacheFactory.kt new file mode 100644 index 000000000..3709b37b1 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip95/Nip95CacheFactory.kt @@ -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") + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/Nip96Uploader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/Nip96Uploader.kt index 1ccc4b3a0..49cdab69b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/Nip96Uploader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/Nip96Uploader.kt @@ -31,7 +31,6 @@ import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.service.HttpStatusMessages import com.vitorpamplona.amethyst.service.checkNotInMainThread -import com.vitorpamplona.amethyst.service.okhttp.HttpClientManager import com.vitorpamplona.amethyst.service.uploads.MediaUploadResult import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.nip36SensitiveContent.ContentWarningTag @@ -44,6 +43,7 @@ import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent import kotlinx.coroutines.delay import okhttp3.MediaType.Companion.toMediaType import okhttp3.MultipartBody +import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody import okio.BufferedSink @@ -62,7 +62,7 @@ class Nip96Uploader { alt: String?, sensitiveContent: String?, serverBaseUrl: String, - forceProxy: (String) -> Boolean, + okHttpClient: (String) -> OkHttpClient, onProgress: (percentage: Float) -> Unit, httpAuth: suspend (String, String, ByteArray?) -> HTTPAuthorizationEvent?, context: Context, @@ -72,8 +72,8 @@ class Nip96Uploader { size, alt, sensitiveContent, - ServerInfoRetriever().loadInfo(serverBaseUrl, forceProxy(serverBaseUrl)), - forceProxy, + ServerInfoRetriever().loadInfo(serverBaseUrl, okHttpClient), + okHttpClient, onProgress, httpAuth, context, @@ -95,7 +95,7 @@ class Nip96Uploader { alt: String?, sensitiveContent: String?, server: ServerInfo, - forceProxy: (String) -> Boolean, + okHttpClient: (String) -> OkHttpClient, onProgress: (percentage: Float) -> Unit, httpAuth: suspend (String, String, ByteArray?) -> HTTPAuthorizationEvent?, context: Context, @@ -117,7 +117,7 @@ class Nip96Uploader { alt, sensitiveContent, server, - forceProxy, + okHttpClient, onProgress, httpAuth, context, @@ -131,7 +131,7 @@ class Nip96Uploader { alt: String?, sensitiveContent: String?, server: ServerInfo, - forceProxy: (String) -> Boolean, + okHttpClient: (String) -> OkHttpClient, onProgress: (percentage: Float) -> Unit, httpAuth: suspend (String, String, ByteArray?) -> HTTPAuthorizationEvent?, context: Context, @@ -141,7 +141,7 @@ class Nip96Uploader { val fileName = randomChars() 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 requestBody: RequestBody = @@ -182,7 +182,7 @@ class Nip96Uploader { response.body.use { body -> val result = UploadResult.parse(body.string()) if (!result.processingUrl.isNullOrBlank()) { - return waitProcessing(result, server, forceProxy, onProgress) + return waitProcessing(result, server, okHttpClient, onProgress) } else if (result.status == "success") { val event = result.nip94Event if (event != null) { @@ -263,14 +263,14 @@ class Nip96Uploader { hash: String, contentType: String?, server: ServerInfo, - forceProxy: (String) -> Boolean, + okHttpClient: (String) -> OkHttpClient, httpAuth: (String, String, ByteArray?) -> HTTPAuthorizationEvent?, context: Context, ): Boolean { val extension = contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: "" - val client = HttpClientManager.getHttpClient(forceProxy(server.apiUrl)) + val client = okHttpClient(server.apiUrl) val requestBuilder = Request.Builder() @@ -303,7 +303,7 @@ class Nip96Uploader { private suspend fun waitProcessing( result: UploadResult, server: ServerInfo, - forceProxy: (String) -> Boolean, + okHttpClient: (String) -> OkHttpClient, onProgress: (percentage: Float) -> Unit, ): MediaUploadResult { var currentResult = result @@ -320,7 +320,7 @@ class Nip96Uploader { .url(procUrl) .build() - val client = HttpClientManager.getHttpClient(forceProxy(procUrl)) + val client = okHttpClient(procUrl) client.newCall(request).execute().use { if (it.isSuccessful) { it.body.use { currentResult = UploadResult.parse(it.string()) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/ServerInfoRetriever.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/ServerInfoRetriever.kt index d95119a31..2906bbe14 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/ServerInfoRetriever.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/ServerInfoRetriever.kt @@ -22,10 +22,10 @@ package com.vitorpamplona.amethyst.service.uploads.nip96 import android.util.Log 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.ServerInfoParser import kotlinx.coroutines.CancellationException +import okhttp3.OkHttpClient import okhttp3.Request class ServerInfoRetriever { @@ -33,7 +33,7 @@ class ServerInfoRetriever { suspend fun loadInfo( baseUrl: String, - forceProxy: Boolean, + okHttpClient: (String) -> OkHttpClient, ): ServerInfo { val request: Request = Request @@ -42,7 +42,10 @@ class ServerInfoRetriever { .url(parser.assembleUrl(baseUrl)) .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() response.use { val body = it.body.string() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt index b2c77c96c..9ea808ca8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt @@ -20,9 +20,8 @@ */ package com.vitorpamplona.amethyst.ui -import android.net.ConnectivityManager -import android.net.Network -import android.net.NetworkCapabilities +import android.app.PendingIntent +import android.content.Intent import android.os.Build import android.os.Bundle import android.util.Log @@ -31,20 +30,18 @@ import androidx.activity.enableEdgeToEdge import androidx.annotation.RequiresApi import androidx.appcompat.app.AppCompatActivity import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.mutableStateOf +import androidx.core.net.toUri import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.Amethyst -import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.debugState import com.vitorpamplona.amethyst.model.LocalCache 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.pip.BackgroundMedia import com.vitorpamplona.amethyst.ui.navigation.Route import com.vitorpamplona.amethyst.ui.screen.AccountScreen import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel +import com.vitorpamplona.amethyst.ui.screen.prepareSharedViewModel import com.vitorpamplona.amethyst.ui.theme.AmethystTheme import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser @@ -65,27 +62,19 @@ import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch -import java.net.URLEncoder -import java.nio.charset.StandardCharsets -import java.util.Timer -import kotlin.concurrent.schedule class MainActivity : AppCompatActivity() { - val isOnMobileDataState = mutableStateOf(false) - private val isOnWifiDataState = mutableStateOf(false) - - private var shouldPauseService = true - @RequiresApi(Build.VERSION_CODES.R) override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() super.onCreate(savedInstanceState) - Log.d("Lifetime Event", "MainActivity.onCreate") + Log.d("ActivityLifecycle", "MainActivity.onCreate $this") setContent { - val sharedPreferencesViewModel = prepareSharedViewModel(act = this) + StringResSetup() + val sharedPreferencesViewModel = prepareSharedViewModel() AmethystTheme(sharedPreferencesViewModel) { val accountStateViewModel: AccountStateViewModel = viewModel() @@ -98,74 +87,26 @@ class MainActivity : AppCompatActivity() { } } - fun prepareToLaunchSigner() { - shouldPauseService = false - } - @OptIn(DelicateCoroutinesApi::class) override fun onResume() { super.onResume() - val locales = this.applicationContext.resources.configuration.locales - if (!locales.isEmpty) { - checkLanguage(locales.get(0).language) - } - - Log.d("Lifetime Event", "MainActivity.onResume") + Log.d("ActivityLifecycle", "MainActivity.onResume $this") // starts muted every time 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() { - Log.d("Lifetime Event", "MainActivity.onPause") + Log.d("ActivityLifecycle", "MainActivity.onPause $this") - GlobalScope.launch(Dispatchers.IO) { - LanguageTranslatorService.clear() - } - Amethyst.instance.serviceManager.cleanObservers() + GlobalScope.launch(Dispatchers.IO) { LanguageTranslatorService.clear() } - // if (BuildConfig.DEBUG) { 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() } - override fun onStart() { - super.onStart() - - Log.d("Lifetime Event", "MainActivity.onStart") - } - override fun onStop() { super.onStop() @@ -174,117 +115,61 @@ class MainActivity : AppCompatActivity() { // serviceManager.trimMemory() // } - Log.d("Lifetime Event", "MainActivity.onStop") + Log.d("ActivityLifecycle", "MainActivity.onStop $this") } override fun onDestroy() { - Log.d("Lifetime Event", "MainActivity.onDestroy") + Log.d("ActivityLifecycle", "MainActivity.onDestroy $this") BackgroundMedia.removeBackgroundControllerAndReleaseIt() super.onDestroy() } - fun updateNetworkCapabilities(networkCapabilities: NetworkCapabilities): Boolean { - val unmetered = networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED) - - val isOnMobileData = !unmetered || networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) - val isOnWifi = unmetered && networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) - - var changedNetwork = false - - 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 + companion object { + fun createIntent(callbackUri: String): PendingIntent = + PendingIntent.getActivity( + Amethyst.instance, + 0, + Intent(Intent.ACTION_VIEW, callbackUri.toUri(), Amethyst.instance, MainActivity::class.java), + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) } - - @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) { - Route.Notification.route.replace("{scrollToTop}", "true") + Route.Notification } else { 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 { val nip19 = Nip19Parser.uriToRoute(uri)?.entity when (nip19) { - is NPub -> "User/${nip19.hex}" - is NProfile -> "User/${nip19.hex}" - is Note -> "Note/${nip19.hex}" + is NPub -> Route.Profile(nip19.hex) + is NProfile -> Route.Profile(nip19.hex) + is Note -> Route.Note(nip19.hex) is NEvent -> { if (nip19.kind == PrivateDmEvent.KIND) { - nip19.author?.let { "RoomByAuthor/$it" } + nip19.author?.let { Route.RoomByAuthor(it) } } else if ( nip19.kind == ChannelMessageEvent.KIND || nip19.kind == ChannelCreateEvent.KIND || nip19.kind == ChannelMetadataEvent.KIND ) { - "Channel/${nip19.hex}" + Route.Channel(nip19.hex) } else { - "Event/${nip19.hex}" + Route.EventRedirect(nip19.hex) } } is NAddress -> { if (nip19.kind == CommunityDefinitionEvent.KIND) { - "Community/${nip19.aTag()}" + Route.Community(nip19.aTag()) } else if (nip19.kind == LiveActivitiesEvent.KIND) { - "Channel/${nip19.aTag()}" + Route.Channel(nip19.aTag()) } else { - "Event/${nip19.aTag()}" + Route.EventRedirect(nip19.aTag()) } } @@ -292,7 +177,7 @@ fun uriToRoute(uri: String?): String? = if (LocalCache.getNoteIfExists(nip19.event.id) == null) { LocalCache.verifyAndConsume(nip19.event, null) } - "Event/${nip19.event.id}" + Route.EventRedirect(nip19.event.id) } else -> null @@ -301,8 +186,7 @@ fun uriToRoute(uri: String?): String? = ?: try { uri?.let { Nip47WalletConnect.parse(it) - val encodedUri = URLEncoder.encode(it, StandardCharsets.UTF_8.toString()) - Route.NIP47Setup.base + "?nip47=" + encodedUri + Route.Nip47NWCSetup(it) } } catch (e: Exception) { if (e is CancellationException) throw e diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/StringResourceCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/StringResourceCache.kt index f4205b7f3..91719a61f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/StringResourceCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/StringResourceCache.kt @@ -25,6 +25,7 @@ import android.util.LruCache import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.res.stringResource +import androidx.lifecycle.compose.LifecycleResumeEffect /** * 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 fun stringRes(id: Int): String = resourceCache.get(id) ?: stringResource(id).also { resourceCache.put(id, it) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/JoinUserOrChannelView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/JoinUserOrChannelView.kt index 5d31fbf92..300ec6164 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/JoinUserOrChannelView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/JoinUserOrChannelView.kt @@ -76,6 +76,7 @@ import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource import com.vitorpamplona.amethyst.service.checkNotInMainThread 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.SearchIcon import com.vitorpamplona.amethyst.ui.note.UsernameDisplay @@ -349,7 +350,7 @@ private fun RenderSearchResults( key = { _, item -> "u" + item.pubkeyHex }, ) { _, item -> UserComposeForChat(item, accountViewModel) { - accountViewModel.createChatRoomFor(item) { nav.nav("Room/$it") } + accountViewModel.createChatRoomFor(item) { nav.nav(Route.Room(it)) } searchBarViewModel.clear() } @@ -368,7 +369,7 @@ private fun RenderSearchResults( loadProfilePicture = accountViewModel.settings.showProfilePictures.value, loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE, ) { - nav.nav("Channel/${item.idHex}") + nav.nav(Route.Channel(item.idHex)) searchBarViewModel.clear() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/MediaSaverToDisk.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/MediaSaverToDisk.kt index d79dd754c..655f03876 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/MediaSaverToDisk.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/MediaSaverToDisk.kt @@ -32,10 +32,10 @@ import androidx.annotation.RequiresApi import androidx.core.net.toFile import androidx.core.net.toUri import com.vitorpamplona.amethyst.BuildConfig -import com.vitorpamplona.amethyst.service.okhttp.HttpClientManager import kotlinx.coroutines.CancellationException import okhttp3.Call import okhttp3.Callback +import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response import okio.BufferedSource @@ -49,7 +49,7 @@ import java.util.UUID object MediaSaverToDisk { fun saveDownloadingIfNeeded( videoUri: String?, - forceProxy: Boolean, + okHttpClient: (String) -> OkHttpClient, mimeType: String?, localContext: Context, onSuccess: () -> Any?, @@ -69,7 +69,7 @@ object MediaSaverToDisk { downloadAndSave( url = videoUri, mimeType = mimeType, - forceProxy = forceProxy, + okHttpClient = okHttpClient, context = localContext, onSuccess = onSuccess, onError = onError, @@ -85,12 +85,12 @@ object MediaSaverToDisk { fun downloadAndSave( url: String, mimeType: String?, - forceProxy: Boolean, + okHttpClient: (String) -> OkHttpClient, context: Context, onSuccess: () -> Any?, onError: (Throwable) -> Any?, ) { - val client = HttpClientManager.getHttpClient(forceProxy) + val client = okHttpClient(url) val request = Request diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt index 6d02af7df..76fd0075a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.actions +import android.R.attr.category import android.content.Context import android.util.Log import androidx.compose.runtime.Stable @@ -239,17 +240,27 @@ open class NewPostViewModel : 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( - accountViewModel: AccountViewModel, replyingTo: Note?, quote: Note?, fork: Note?, version: Note?, draft: Note?, ) { - this.accountViewModel = accountViewModel - this.account = accountViewModel.account - + val accountViewModel = accountViewModel ?: return val noteEvent = draft?.event val noteAuthor = draft?.author @@ -1181,7 +1192,7 @@ open class NewPostViewModel : viewModelScope.launch(Dispatchers.IO) { iMetaAttachments.downloadAndPrepare( item.url.url, - accountViewModel?.account?.shouldUseTorForImageDownload() ?: false, + { Amethyst.instance.okHttpClients.getHttpClient(accountViewModel?.account?.shouldUseTorForImageDownload() ?: false) }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt index fc2cd0a0d..f0e7b2ecf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt @@ -26,6 +26,8 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import coil3.util.CoilUtils.result +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.service.uploads.CompressorQuality @@ -185,7 +187,7 @@ class NewUserMetadataViewModel : ViewModel() { alt = null, sensitiveContent = null, serverBaseUrl = account.settings.defaultFileServer.baseUrl, - forceProxy = account::shouldUseTorForNIP96, + okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(account.shouldUseTorForNIP96(it)) }, onProgress = {}, httpAuth = account::createHTTPAuthorization, context = context, @@ -198,7 +200,7 @@ class NewUserMetadataViewModel : ViewModel() { alt = null, sensitiveContent = null, serverBaseUrl = account.settings.defaultFileServer.baseUrl, - forceProxy = account::shouldUseTorForNIP96, + okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(account.shouldUseTorForNIP96(it)) }, httpAuth = account::createBlossomUploadAuth, context = context, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/CashuRedeem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/CashuRedeem.kt index 19387ac97..a7aa1d7f4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/CashuRedeem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/CashuRedeem.kt @@ -53,18 +53,15 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.content.ContextCompat.startActivity -import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.hashtags.Cashu import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons -import com.vitorpamplona.amethyst.model.ThemeType import com.vitorpamplona.amethyst.service.CachedCashuProcessor import com.vitorpamplona.amethyst.service.CashuToken import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.note.CopyIcon import com.vitorpamplona.amethyst.ui.note.OpenInNewIcon 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.stringRes import com.vitorpamplona.amethyst.ui.theme.CashuCardBorders @@ -124,11 +121,6 @@ fun CashuPreview( @Composable @Preview() fun CashuPreviewPreview() { - val sharedPreferencesViewModel: SharedPreferencesViewModel = viewModel() - - sharedPreferencesViewModel.init() - sharedPreferencesViewModel.updateTheme(ThemeType.DARK) - ThemeComparisonColumn { CashuPreviewNew( token = CashuToken("token", "mint", 32400, listOf()), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableEmail.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableEmail.kt index 2ad3d1f64..857f1b39a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableEmail.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableEmail.kt @@ -23,13 +23,8 @@ package com.vitorpamplona.amethyst.ui.components import android.content.ActivityNotFoundException import android.content.Context 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.remember import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.AnnotatedString import kotlinx.coroutines.CancellationException @Composable @@ -37,10 +32,9 @@ fun ClickableEmail(email: String) { val stripped = email.replaceFirst("mailto:", "") val context = LocalContext.current - ClickableText( - text = remember { AnnotatedString(stripped) }, + ClickableTextPrimary( + text = stripped, onClick = { runCatching { context.sendMail(stripped) } }, - style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickablePhone.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickablePhone.kt index 990ee9137..48f6064b5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickablePhone.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickablePhone.kt @@ -23,22 +23,16 @@ package com.vitorpamplona.amethyst.ui.components import android.content.Context import android.content.Intent 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.remember import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.AnnotatedString @Composable fun ClickablePhone(phone: String) { val context = LocalContext.current - ClickableText( - text = remember { AnnotatedString(phone) }, - onClick = { runCatching { context.dial(phone) } }, - style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary), + ClickableTextPrimary( + text = phone, + onClick = { context.dial(phone) }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt index 7ebb6981d..8214257d4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.components -import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.InlineTextContent @@ -40,18 +39,18 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.takeOrElse -import androidx.compose.ui.input.pointer.pointerInput 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.PlaceholderVerticalAlign 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.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withLink import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.TextUnit 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.User 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.njumpLink import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -200,7 +201,7 @@ private fun DisplayNoteLink( CreateClickableText( clickablePart = noteIdDisplayNote, suffix = addedCharts, - route = remember(noteState) { "Channel/$hex" }, + route = remember(noteState) { Route.Channel(hex) }, nav = nav, ) } else if (note.event is PrivateDmEvent || kind == PrivateDmEvent.KIND) { @@ -208,7 +209,7 @@ private fun DisplayNoteLink( clickablePart = noteIdDisplayNote, suffix = addedCharts, route = - remember(noteState) { (note.author?.pubkeyHex ?: hex).let { "RoomByAuthor/$it" } }, + remember(noteState) { (note.author?.pubkeyHex ?: hex).let { Route.RoomByAuthor(it) } }, nav = nav, ) } else if (channelHex != null) { @@ -222,7 +223,7 @@ private fun DisplayNoteLink( CreateClickableText( clickablePart = channelDisplayName, suffix = addedCharts, - route = remember(noteState) { "Channel/${baseChannel.idHex}" }, + route = remember(noteState) { Route.Channel(baseChannel.idHex) }, nav = nav, ) } @@ -230,7 +231,7 @@ private fun DisplayNoteLink( CreateClickableText( clickablePart = noteIdDisplayNote, suffix = addedCharts, - route = remember(noteState) { "Event/$hex" }, + route = remember(noteState) { Route.EventRedirect(hex) }, nav = nav, ) } @@ -255,7 +256,7 @@ private fun DisplayAddress( noteBase?.let { 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()}" } CreateClickableText( @@ -329,7 +330,7 @@ public fun RenderUserAsClickableText( clickablePart = userState?.bestName() ?: ("@" + baseUser.pubkeyDisplayHex()), suffix = additionalChars?.ifBlank { null }, maxLines = 1, - route = "User/${baseUser.pubkeyHex}", + route = remember(baseUser) { routeFor(baseUser) }, nav = nav, tags = userState?.tags ?: EmptyTagList, ) @@ -343,7 +344,7 @@ fun CreateClickableText( overrideColor: Color? = null, fontWeight: FontWeight? = null, fontSize: TextUnit = TextUnit.Unspecified, - route: String, + route: Route, nav: INav, ) { CreateClickableText( @@ -364,7 +365,7 @@ fun CreateClickableText( overrideColor: Color? = null, fontWeight: FontWeight? = null, fontSize: TextUnit = TextUnit.Unspecified, - onClick: (Int) -> Unit, + onClick: () -> Unit, ) { val primaryColor = MaterialTheme.colorScheme.primary val onBackgroundColor = MaterialTheme.colorScheme.onBackground @@ -378,61 +379,34 @@ fun CreateClickableText( fontWeight = fontWeight, ) - val nonClickablePartStyle = - SpanStyle( - fontSize = fontSize, - color = overrideColor ?: onBackgroundColor, - fontWeight = fontWeight, - ) - buildAnnotatedString { - withStyle(clickablePartStyle) { append(clickablePart) } - if (!suffix.isNullOrBlank()) { - withStyle(nonClickablePartStyle) { append(suffix) } + withLink( + LinkAnnotation.Clickable( + tag = "clickable", + styles = TextLinkStyles(clickablePartStyle), + ) { + onClick() + }, + ) { + append(clickablePart) } - } - } + if (!suffix.isNullOrBlank()) { + val nonClickablePartStyle = + SpanStyle( + fontSize = fontSize, + color = overrideColor ?: onBackgroundColor, + fontWeight = fontWeight, + ) - ClickableText( - 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(null) } - val pressIndicator = - Modifier.pointerInput(onClick) { - detectTapGestures { pos -> - layoutResult.value?.let { layoutResult -> - onClick(layoutResult.getOffsetForPosition(pos)) + withStyle(nonClickablePartStyle) { append(suffix) } } } } Text( text = text, - modifier = modifier.then(pressIndicator), - style = style, - softWrap = softWrap, - overflow = overflow, maxLines = maxLines, - onTextLayout = { - layoutResult.value = it - onTextLayout(it) - }, + overflow = TextOverflow.Ellipsis, ) } @@ -608,21 +582,29 @@ fun CreateClickableTextWithEmoji( maxLines: Int = Int.MAX_VALUE, tags: ImmutableListOfLists?, style: TextStyle, - onClick: (Int) -> Unit, + onClick: () -> Unit, ) { CustomEmojiChecker( text = clickablePart, tags = tags, onRegularText = { - ClickableText( - text = AnnotatedString(clickablePart), + Text( + text = + buildAnnotatedString { + withLink( + LinkAnnotation.Clickable("me") { + onClick() + }, + ) { + append(clickablePart) + } + }, style = style, maxLines = maxLines, - onClick = onClick, ) }, onEmojiText = { - ClickableInLineIconRenderer(it, maxLines, style.toSpanStyle()) { onClick(it) } + ClickableInLineIconRenderer(it, maxLines, style.toSpanStyle(), onClick = onClick) }, ) } @@ -635,7 +617,7 @@ fun CreateClickableTextWithEmoji( overrideColor: Color? = null, fontWeight: FontWeight = FontWeight.Normal, fontSize: TextUnit = TextUnit.Unspecified, - route: String, + route: Route, nav: INav, tags: ImmutableListOfLists?, ) { @@ -680,15 +662,11 @@ fun ClickableInLineIconRenderer( style: SpanStyle, suffix: String? = null, nonClickableStype: SpanStyle? = null, - onClick: (Int) -> Unit, + onClick: () -> Unit, ) { val placeholderSize = remember(style) { - if (style.fontSize == TextUnit.Unspecified) { - 22.sp - } else { - style.fontSize.times(1.1f) - } + if (style.fontSize == TextUnit.Unspecified) 22.sp else style.fontSize.times(1.1f) } val inlineContent = @@ -722,8 +700,10 @@ fun ClickableInLineIconRenderer( val annotatedText = buildAnnotatedString { wordsInOrder.forEachIndexed { idx, value -> - withStyle( - style, + withLink( + LinkAnnotation.Clickable("link", TextLinkStyles(style)) { + onClick() + }, ) { if (value is CustomEmoji.TextType) { append(value.text) @@ -740,20 +720,10 @@ fun ClickableInLineIconRenderer( } } - val layoutResult = remember { mutableStateOf(null) } - val pressIndicator = - Modifier.pointerInput(onClick) { - detectTapGestures { pos -> - layoutResult.value?.let { layoutResult -> onClick(layoutResult.getOffsetForPosition(pos)) } - } - } - Text( text = annotatedText, - modifier = pressIndicator, inlineContent = inlineContent, maxLines = maxLines, - onTextLayout = { layoutResult.value = it }, ) } @@ -768,11 +738,7 @@ fun InLineIconRenderer( ) { val placeholderSize = remember(fontSize) { - if (fontSize == TextUnit.Unspecified) { - 22.sp - } else { - fontSize.times(1.1f) - } + if (fontSize == TextUnit.Unspecified) 22.sp else fontSize.times(1.1f) } val inlineContent = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableTexts.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableTexts.kt new file mode 100644 index 000000000..aabb06fe8 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableTexts.kt @@ -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) + } + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableUrl.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableUrl.kt index a33039cdf..e55a0057e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableUrl.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableUrl.kt @@ -20,13 +20,10 @@ */ package com.vitorpamplona.amethyst.ui.components -import androidx.compose.foundation.text.ClickableText -import androidx.compose.material3.LocalTextStyle -import androidx.compose.material3.MaterialTheme +import android.R.attr.maxLines +import android.R.attr.onClick import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalUriHandler -import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextOverflow @Composable @@ -36,10 +33,8 @@ fun ClickableUrl( ) { val uri = LocalUriHandler.current - val text = remember(urlText) { AnnotatedString(urlText) } - - ClickableText( - text = text, + ClickableTextPrimary( + text = urlText, maxLines = 1, overflow = TextOverflow.Ellipsis, onClick = { @@ -48,6 +43,5 @@ fun ClickableUrl( uri.openUri(doubleCheckedUrl) } }, - style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableWithdrawal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableWithdrawal.kt index 947f1d5ac..faecd0ba7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableWithdrawal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableWithdrawal.kt @@ -20,9 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.components -import androidx.compose.foundation.text.ClickableText import androidx.compose.material3.LocalTextStyle -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -31,7 +29,6 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextDirection import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled @@ -70,8 +67,6 @@ fun MayBeWithdrawal( fun ClickableWithdrawal(withdrawalString: String) { val context = LocalContext.current - val withdraw = remember(withdrawalString) { AnnotatedString("$withdrawalString ") } - var showErrorMessageDialog by remember { mutableStateOf(null) } if (showErrorMessageDialog != null) { @@ -82,9 +77,8 @@ fun ClickableWithdrawal(withdrawalString: String) { ) } - ClickableText( - text = withdraw, + ClickableTextPrimary( + text = "$withdrawalString ", onClick = { payViaIntent(withdrawalString, context, { }) { showErrorMessageDialog = it } }, - style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt index d2e06629f..b87492a5c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt @@ -90,6 +90,8 @@ import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.components.markdown.RenderContentAsMarkdown import com.vitorpamplona.amethyst.ui.navigation.EmptyNav 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.creators.invoice.MayBeInvoicePreview import com.vitorpamplona.amethyst.ui.note.toShortenHex @@ -183,7 +185,7 @@ fun RenderRegularPreview() { word.segmentText.substring(0, 10), "", 1, - route = "", + route = Route.EventRedirect(word.segmentText), nav = nav, ) } @@ -650,7 +652,7 @@ fun HashTag( modifier = remember { Modifier.clickable { - nav.nav("Hashtag/${segment.hashtag}") + nav.nav(Route.Hashtag(segment.hashtag)) } }, inlineContent = @@ -760,7 +762,10 @@ private fun DisplayNoteFromTag( nav = nav, ) } 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) } @@ -779,7 +784,7 @@ private fun DisplayUserFromTag( CreateClickableTextWithEmoji( clickablePart = remember(meta) { it?.bestName() ?: baseUser.pubkeyDisplayHex() }, maxLines = 1, - route = "User/${baseUser.pubkeyHex}", + route = remember(baseUser) { routeFor(baseUser) }, nav = nav, tags = it?.tags, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt index ea1c383f7..717b031dd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt @@ -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 if (content is MediaUrlContent) { - val useTor = - if (isImage) { - accountViewModel.account.shouldUseTorForImageDownload() - } else { - accountViewModel.account.shouldUseTorForVideoDownload() - } - MediaSaverToDisk.downloadAndSave( content.url, mimeType = content.mimeType, - forceProxy = useTor, + okHttpClient = { + if (isImage) { + accountViewModel.okHttpClientForImage(it) + } else { + accountViewModel.okHttpClientForVideo(it) + } + }, localContext, onSuccess = { accountViewModel.toastManager.toast(success, success) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index 356b056fd..34fcb00d2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -79,7 +79,7 @@ import com.vitorpamplona.amethyst.commons.richtext.MediaUrlContent import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo 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.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.actions.InformationDialog @@ -637,7 +637,7 @@ fun DisplayBlurHash( if (blurhash == null) return AsyncImage( - model = Blurhash(blurhash), + model = BlurhashWrapper(blurhash), contentDescription = description, contentScale = contentScale, modifier = modifier, @@ -739,7 +739,7 @@ fun ShareImageAction( private suspend fun verifyHash(content: MediaUrlContent): Boolean? { 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() Log.d("Image Hash Verification", "$hash == ${content.hash}") diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt index 5dcff2e06..187d6a1d4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt @@ -27,7 +27,6 @@ import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.saveable.rememberSaveable -import com.vitorpamplona.amethyst.ui.navigation.Route import kotlin.math.roundToInt private val savedScrollStates = mutableMapOf() @@ -40,17 +39,17 @@ private data class ScrollState( object ScrollStateKeys { const val NOTIFICATION_SCREEN = "NotificationsFeed" const val VIDEO_SCREEN = "VideoFeed" - val HOME_FOLLOWS = Route.Home.base + "FollowsFeed" - val HOME_REPLIES = Route.Home.base + "FollowsRepliesFeed" - val PROFILE_GALLERY = Route.Home.base + "ProfileGalleryFeed" + const val HOME_FOLLOWS = "HomeFollowsFeed" + const val HOME_REPLIES = "HomeFollowsRepliesFeed" + const val PROFILE_GALLERY = "ProfileGalleryFeed" - val DRAFTS = Route.Home.base + "DraftsFeed" + const val DRAFTS = "DraftsFeed" - val DISCOVER_CONTENT = Route.Home.base + "DiscoverContentFeed" - val DISCOVER_MARKETPLACE = Route.Home.base + "MarketplaceFeed" - val DISCOVER_LIVE = Route.Home.base + "LiveFeed" - val DISCOVER_COMMUNITY = Route.Home.base + "CommunitiesFeed" - val DISCOVER_CHATS = Route.Home.base + "ChatsFeed" + const val DISCOVER_CONTENT = "DiscoverDiscoverContentFeed" + const val DISCOVER_MARKETPLACE = "DiscoverMarketplaceFeed" + const val DISCOVER_LIVE = "DiscoverLiveFeed" + const val DISCOVER_COMMUNITY = "DiscoverCommunitiesFeed" + const val DISCOVER_CHATS = "DiscoverChatsFeed" } object PagerStateKeys { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppBottomBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppBottomBar.kt index a0327460c..16533c528 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppBottomBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppBottomBar.kt @@ -30,6 +30,7 @@ import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.material3.BottomAppBarDefaults.windowInsets 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.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.Size0dp 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 val bottomNavigationItems = persistentListOf( - Route.Home, - Route.Message, - Route.Video, - Route.Discover, - Route.Notification, + BottomBarRoute(Route.Home, R.drawable.ic_home, R.string.route_home, Modifier.size(Size25dp), Modifier.size(Size24dp)), + BottomBarRoute(Route.Message, R.drawable.ic_dm, R.string.route_messages), + BottomBarRoute(Route.Video, R.drawable.ic_video, R.string.route_video), + BottomBarRoute(Route.Discover, R.drawable.ic_sensors, R.string.route_discover), + BottomBarRoute(Route.Notification, R.drawable.ic_notifications, R.string.route_notifications), ) enum class Keyboard { @@ -119,7 +123,7 @@ fun IfKeyboardClosed(inner: @Composable () -> Unit) { fun AppBottomBar( selectedRoute: Route?, accountViewModel: AccountViewModel, - nav: (Route, Boolean) -> Unit, + nav: (Route) -> Unit, ) { IfKeyboardClosed { RenderBottomMenu(selectedRoute, accountViewModel, nav) } } @@ -128,7 +132,7 @@ fun AppBottomBar( private fun RenderBottomMenu( selectedRoute: Route?, accountViewModel: AccountViewModel, - nav: (Route, Boolean) -> Unit, + nav: (Route) -> Unit, ) { Column( modifier = @@ -146,7 +150,7 @@ private fun RenderBottomMenu( tonalElevation = Size0dp, ) { bottomNavigationItems.forEach { item -> - HasNewItemsIcon(item == selectedRoute, item, accountViewModel, nav) + HasNewItemsIcon(item.route == selectedRoute, item, accountViewModel, nav) } } } @@ -155,28 +159,28 @@ private fun RenderBottomMenu( @Composable private fun RowScope.HasNewItemsIcon( selected: Boolean, - route: Route, + bottomNav: BottomBarRoute, accountViewModel: AccountViewModel, - nav: (Route, Boolean) -> Unit, + nav: (Route) -> Unit, ) { NavigationBarItem( alwaysShowLabel = false, icon = { NotifiableIcon( selected, - route, + bottomNav, accountViewModel, ) }, selected = selected, - onClick = { nav(route, selected) }, + onClick = { nav(bottomNav.route) }, ) } @Composable private fun NotifiableIcon( selected: Boolean, - route: Route, + route: BottomBarRoute, accountViewModel: AccountViewModel, ) { Box(route.notifSize) { @@ -187,7 +191,7 @@ private fun NotifiableIcon( tint = if (selected) MaterialTheme.colorScheme.primary else Color.Unspecified, ) - AddNotifIconIfNeeded(route, accountViewModel, Modifier.align(Alignment.TopEnd)) + AddNotifIconIfNeeded(route.route, accountViewModel, Modifier.align(Alignment.TopEnd)) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index d4f2fd13c..21aa96e74 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -43,7 +43,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext import androidx.core.net.toUri import androidx.core.util.Consumer -import androidx.navigation.NavBackStackEntry import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import com.vitorpamplona.amethyst.R @@ -87,24 +86,6 @@ import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser import kotlinx.coroutines.delay import kotlinx.coroutines.launch 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 fun AppNavigation( @@ -117,34 +98,15 @@ fun AppNavigation( AccountSwitcherAndLeftDrawerLayout(accountViewModel, accountStateViewModel, nav) { NavHost( navController = nav.controller, - startDestination = Route.Home.route, + startDestination = Route.Home, enterTransition = { fadeIn(animationSpec = tween(200)) }, exitTransition = { fadeOut(animationSpec = tween(200)) }, ) { - composable(Route.Home.route) { HomeScreen(accountViewModel, nav) } - composable(Route.Message.route) { MessagesScreen(accountViewModel, nav) } - composable(Route.Video.route) { VideoScreen(accountViewModel, nav) } - composable(Route.Discover.route) { DiscoverScreen(accountViewModel, nav) } - composable(Route.Notification.route) { 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 { HomeScreen(accountViewModel, nav) } + composable { MessagesScreen(accountViewModel, nav) } + composable { VideoScreen(accountViewModel, nav) } + composable { DiscoverScreen(accountViewModel, nav) } + composable { NotificationScreen(sharedPreferencesViewModel, accountViewModel, nav) } composable( Route.Lists.route, @@ -154,245 +116,42 @@ fun AppNavigation( popExitTransition = { slideOutHorizontallyToEnd }, ) { ListsScreen(accountViewModel, nav) } - composable( - Route.Drafts.route, - enterTransition = { slideInHorizontallyFromEnd }, - exitTransition = { scaleOut }, - popEnterTransition = { scaleIn }, - popExitTransition = { slideOutHorizontallyToEnd }, - ) { DraftListScreen(accountViewModel, nav) } - composable( - Route.ContentDiscovery.route, - Route.ContentDiscovery.arguments, - enterTransition = { slideInHorizontallyFromEnd }, - exitTransition = { scaleOut }, - popEnterTransition = { scaleIn }, - popExitTransition = { slideOutHorizontallyToEnd }, - ) { - DvmContentDiscoveryScreen(it.id(), accountViewModel, nav) - } + composable { NewUserMetadataScreen(nav, accountViewModel) } + composable { SearchScreen(accountViewModel, nav) } - composable( - Route.Profile.route, - Route.Profile.arguments, - enterTransition = { slideInHorizontallyFromEnd }, - exitTransition = { scaleOut }, - popEnterTransition = { scaleIn }, - popExitTransition = { slideOutHorizontallyToEnd }, - ) { - ProfileScreen(it.id(), accountViewModel, nav) - } + composableFromEnd { SecurityFiltersScreen(accountViewModel, nav) } + composableFromEnd { BookmarkListScreen(accountViewModel, nav) } + composableFromEnd { DraftListScreen(accountViewModel, nav) } + composableFromEnd { SettingsScreen(sharedPreferencesViewModel, accountViewModel, nav) } + composableFromBottomArgs { NIP47SetupScreen(accountViewModel, nav, it.nip47) } + composableFromEndArgs { AllRelayListScreen(it.toAdd, accountViewModel, nav) } - composable( - Route.Note.route, - Route.Note.arguments, - enterTransition = { slideInHorizontallyFromEnd }, - exitTransition = { scaleOut }, - popEnterTransition = { scaleIn }, - popExitTransition = { slideOutHorizontallyToEnd }, - ) { - ThreadScreen(it.id(), accountViewModel, nav) - } + composableFromEndArgs { DvmContentDiscoveryScreen(it.id, accountViewModel, nav) } + composableFromEndArgs { ProfileScreen(it.id, accountViewModel, nav) } + composableFromEndArgs { ThreadScreen(it.id, accountViewModel, nav) } + composableFromEndArgs { HashtagScreen(it.id, accountViewModel, nav) } + composableFromEndArgs { GeoHashScreen(it.id, accountViewModel, nav) } + composableFromEndArgs { CommunityScreen(it.id, accountViewModel, nav) } + composableFromEndArgs { ChatroomScreen(it.id.toString(), it.message, it.replyId, it.draftId, accountViewModel, nav) } + composableFromEndArgs { ChatroomByAuthorScreen(it.id, null, accountViewModel, nav) } + composableFromEndArgs { ChannelScreen(it.id, accountViewModel, nav) } - composable( - Route.Hashtag.route, - Route.Hashtag.arguments, - enterTransition = { slideInHorizontallyFromEnd }, - exitTransition = { scaleOut }, - popEnterTransition = { scaleIn }, - popExitTransition = { slideOutHorizontallyToEnd }, - ) { - HashtagScreen(it.id(), accountViewModel, nav) - } + composableFromBottomArgs { ChannelMetadataScreen(it.id, accountViewModel, nav) } + composableFromBottomArgs { NewGroupDMScreen(it.message, it.attachment, accountViewModel, nav) } - composable( - 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 + composableArgs { LoadRedirectScreen(it.id, accountViewModel, nav) } + composableFromBottomArgs { NewPostScreen( - message = draftMessage, - attachment = attachment, - baseReplyTo = baseReplyTo?.let { hex -> accountViewModel.getNoteIfExists(hex) }, - quote = quote?.let { hex -> accountViewModel.getNoteIfExists(hex) }, - fork = fork?.let { hex -> accountViewModel.getNoteIfExists(hex) }, - version = version?.let { hex -> accountViewModel.getNoteIfExists(hex) }, - draft = draft?.let { hex -> accountViewModel.getNoteIfExists(hex) }, - enableGeolocation = enableGeolocation, + message = it.message, + attachment = it.attachment?.ifBlank { null }?.toUri(), + baseReplyTo = it.baseReplyTo?.let { hex -> accountViewModel.getNoteIfExists(hex) }, + quote = it.quote?.let { hex -> accountViewModel.getNoteIfExists(hex) }, + fork = it.fork?.let { hex -> accountViewModel.getNoteIfExists(hex) }, + version = it.version?.let { hex -> accountViewModel.getNoteIfExists(hex) }, + draft = it.draft?.let { hex -> accountViewModel.getNoteIfExists(hex) }, + enableGeolocation = it.enableGeolocation, accountViewModel = accountViewModel, nav = nav, ) @@ -425,7 +184,7 @@ private fun NavigateIfIntentRequested( if (activity.intent.action == Intent.ACTION_SEND) { // avoids restarting the new Post screen when the intent is for the screen. // Microsoft's swift key sends Gifs as new actions - if (isBaseRoute(nav.controller, Route.NewPost.base)) return + if (isBaseRoute(nav.controller)) return // saves the intent to avoid processing again 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 message = null @@ -504,13 +263,13 @@ private fun NavigateIfIntentRequested( if (intent.action == Intent.ACTION_SEND) { // avoids restarting the new Post screen when the intent is for the screen. // Microsoft's swift key sends Gifs as new actions - if (!isBaseRoute(nav.controller, Route.NewPost.base)) { + if (!isBaseRoute(nav.controller)) { intent.getStringExtra(Intent.EXTRA_TEXT)?.let { - nav.newStack(buildNewPostRoute(draftMessage = it)) + nav.newStack(Route.NewPost(message = it)) } (intent.getParcelableExtra(Intent.EXTRA_STREAM) as? Uri)?.let { - nav.newStack(buildNewPostRoute(attachment = it)) + nav.newStack(Route.NewPost(attachment = it.toString())) } } } else { @@ -561,8 +320,8 @@ private fun NavigateIfIntentRequested( } private fun isSameRoute( - currentRoute: String?, - newRoute: String, + currentRoute: Route?, + newRoute: Route, ): Boolean { if (currentRoute == null) return false @@ -570,9 +329,11 @@ private fun isSameRoute( return true } - if (newRoute.startsWith("Event/") && currentRoute.contains("/")) { - if (newRoute.split("/")[1] == currentRoute.split("/")[1]) { - return true + if (newRoute is Route.EventRedirect) { + return when (currentRoute) { + is Route.Note -> newRoute.id == currentRoute.id + is Route.Channel -> newRoute.id == currentRoute.id + else -> false } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt index 6eafc0a02..c8fdbba02 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt @@ -76,7 +76,7 @@ fun GenericMainTopBar( LoggedInUserPictureDrawer(accountViewModel, nav::openDrawer) }, actions = { - IconButton(onClick = { nav.nav(Route.Search.route) }) { + IconButton(onClick = { nav.nav(Route.Search) }) { SearchIcon(modifier = Size22Modifier, MaterialTheme.colorScheme.placeholderText) } }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt index 03769d48a..1cad79dc5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt @@ -72,13 +72,15 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.LinkAnnotation import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLinkStyles import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization 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.sp 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.User 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.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.note.LoadStatuses @@ -232,7 +233,7 @@ fun ProfileContentTemplate( .width(100.dp) .height(100.dp) .clip(shape = CircleShape) - .border(3.dp, MaterialTheme.colorScheme.background, CircleShape) + .border(3.dp, MaterialTheme.colorScheme.onBackground, CircleShape) .clickable(onClick = onClick), loadProfilePicture = accountViewModel.settings.showProfilePictures.value, loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE, @@ -422,7 +423,7 @@ fun WatchFollower( .observeAsState() LaunchedEffect(key1 = accountUserFollowersState) { - onReady(baseAccountUser.followerCount().toString() ?: "--") + onReady(baseAccountUser.followerCount().toString()) } } @@ -433,8 +434,6 @@ fun ListContent( accountViewModel: AccountViewModel, nav: INav, ) { - val route = remember(accountViewModel) { "User/${accountViewModel.userProfile().pubkeyHex}" } - var editMediaServers by remember { mutableStateOf(false) } var backupDialogOpen by remember { mutableStateOf(false) } @@ -445,11 +444,11 @@ fun ListContent( Column(modifier) { NavigationRow( - title = stringRes(R.string.profile), - icon = Route.Profile.icon, + title = R.string.profile, + icon = R.drawable.ic_profile, tint = MaterialTheme.colorScheme.primary, nav = nav, - route = route, + route = remember { Route.Profile(accountViewModel.userProfile().pubkeyHex) }, ) NavigationRow( @@ -461,31 +460,31 @@ fun ListContent( ) NavigationRow( - title = stringRes(R.string.bookmarks), - icon = Route.Bookmarks.icon, + title = R.string.bookmarks, + icon = R.drawable.ic_bookmarks, tint = MaterialTheme.colorScheme.onBackground, nav = nav, - route = Route.Bookmarks.route, + route = Route.Bookmarks, ) NavigationRow( - title = stringRes(R.string.drafts), - icon = Route.Drafts.icon, + title = R.string.drafts, + icon = R.drawable.ic_topics, tint = MaterialTheme.colorScheme.onBackground, nav = nav, - route = Route.Drafts.route, + route = Route.Drafts, ) IconRowRelays( accountViewModel = accountViewModel, onClick = { nav.closeDrawer() - nav.nav(Route.EditRelays.base) + nav.nav(Route.EditRelays()) }, ) IconRow( - title = stringRes(R.string.media_servers), + title = R.string.media_servers, icon = Icons.Outlined.CloudUpload, tint = MaterialTheme.colorScheme.onBackground, onClick = { @@ -495,15 +494,15 @@ fun ListContent( ) NavigationRow( - title = stringRes(R.string.security_filters), - icon = Route.BlockedUsers.icon, + title = R.string.security_filters, + icon = R.drawable.ic_security, tint = MaterialTheme.colorScheme.onBackground, nav = nav, - route = Route.BlockedUsers.route, + route = Route.SecurityFilters, ) IconRow( - title = stringRes(R.string.privacy_options), + title = R.string.privacy_options, icon = R.drawable.ic_tor, tint = MaterialTheme.colorScheme.onBackground, onClick = { @@ -514,7 +513,7 @@ fun ListContent( accountViewModel.account.settings.keyPair.privKey?.let { IconRow( - title = stringRes(R.string.backup_keys), + title = R.string.backup_keys, icon = R.drawable.ic_key, tint = MaterialTheme.colorScheme.onBackground, onClick = { @@ -525,17 +524,17 @@ fun ListContent( } NavigationRow( - title = stringRes(R.string.preferences), - icon = Route.Settings.icon, + title = R.string.preferences, + icon = R.drawable.ic_settings, tint = MaterialTheme.colorScheme.onBackground, nav = nav, - route = Route.Settings.route, + route = Route.Settings, ) Spacer(modifier = Modifier.weight(1f)) IconRow( - title = stringRes(R.string.drawer_accounts), + title = R.string.drawer_accounts, icon = Icons.Outlined.GroupAdd, tint = MaterialTheme.colorScheme.onBackground, onClick = openSheet, @@ -602,11 +601,11 @@ private fun RenderRelayStatus(relayPool: RelayPoolStatus) { @Composable fun NavigationRow( - title: String, + title: Int, icon: Int, tint: Color, nav: INav, - route: String, + route: Route, ) { IconRow( title, @@ -621,7 +620,7 @@ fun NavigationRow( @Composable fun IconRow( - title: String, + title: Int, icon: Int, tint: Color, onClick: () -> Unit, @@ -640,13 +639,13 @@ fun IconRow( ) { Icon( painter = painterResource(icon), - null, + contentDescription = stringRes(title), modifier = Size22Modifier, tint = tint, ) Text( modifier = IconRowTextModifier, - text = title, + text = stringRes(title), fontSize = Font18SP, ) } @@ -655,7 +654,7 @@ fun IconRow( @Composable fun IconRow( - title: String, + title: Int, icon: ImageVector, tint: Color, onClick: () -> Unit, @@ -665,7 +664,7 @@ fun IconRow( Modifier .fillMaxWidth() .clickable( - onClickLabel = title, + onClickLabel = stringRes(title), onClick = onClick, ), ) { @@ -675,13 +674,13 @@ fun IconRow( ) { Icon( imageVector = icon, - null, + contentDescription = stringRes(title), modifier = Size22Modifier, tint = tint, ) Text( modifier = IconRowTextModifier, - text = title, + text = stringRes(title), fontSize = Font18SP, ) } @@ -749,23 +748,33 @@ fun BottomContent( .padding(horizontal = 15.dp), verticalAlignment = Alignment.CenterVertically, ) { - ClickableText( - text = + val string = + remember { buildAnnotatedString { - withStyle( - SpanStyle( - fontSize = 12.sp, - fontWeight = FontWeight.Bold, - ), + withLink( + LinkAnnotation.Clickable( + "clickable", + 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), + overflow = TextOverflow.Ellipsis, + maxLines = 1, ) Box(modifier = Modifier.weight(1F)) IconButton( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/INav.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/INav.kt index a5daf3ab6..26c37269b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/INav.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/INav.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.navigation +import android.annotation.SuppressLint import androidx.compose.material3.DrawerState import androidx.compose.material3.DrawerValue import androidx.compose.runtime.Composable @@ -31,6 +32,7 @@ import androidx.navigation.compose.rememberNavController import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +import kotlin.reflect.KClass @Composable fun rememberNav(): Nav { @@ -52,17 +54,17 @@ fun rememberExtendedNav( interface INav { 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 popUpTo( - route: String, - upTo: String, + fun popUpTo( + route: Route, + klass: KClass, ) fun closeDrawer() @@ -85,7 +87,7 @@ class Nav( scope.launch { drawerState.open() } } - override fun nav(route: String) { + override fun nav(route: Route) { scope.launch { if (getRouteWithArguments(controller) != route) { controller.navigate(route) @@ -93,7 +95,7 @@ class Nav( } } - override fun nav(computeRoute: suspend () -> String) { + override fun nav(computeRoute: suspend () -> Route) { scope.launch { val route = computeRoute() if (getRouteWithArguments(controller) != route) { @@ -102,10 +104,10 @@ class Nav( } } - override fun newStack(route: String) { + override fun newStack(route: Route) { scope.launch { controller.navigate(route) { - popUpTo(Route.Home.route) + popUpTo(Route.Home) launchSingleTop = true } } @@ -117,12 +119,15 @@ class Nav( } } - override fun popUpTo( - route: String, - upTo: String, + @SuppressLint("RestrictedApi") + override fun popUpTo( + route: Route, + upToClass: KClass, ) { scope.launch { - controller.navigate(route) { popUpTo(upTo) { inclusive = true } } + controller.navigate(route) { + popUpTo(upToClass) { inclusive = true } + } } } } @@ -132,34 +137,25 @@ object EmptyNav : INav { override val drawerState = DrawerState(DrawerValue.Closed) override fun closeDrawer() { - runBlocking { - drawerState.close() - } + runBlocking { drawerState.close() } } override fun openDrawer() { - runBlocking { - drawerState.open() - } + runBlocking { 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( - route: String, - upTo: String, - ) { - } + override fun popUpTo( + route: Route, + upToClass: KClass, + ) {} } fun INav.onNavigate(runOnNavigate: () -> Unit): INav = ObservableNavigate(this, runOnNavigate) @@ -170,17 +166,25 @@ class ObservableNavigate( ) : INav { 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() nav.nav(route) } - override fun nav(computeRoute: suspend () -> String) { + override fun nav(computeRoute: suspend () -> Route) { onNavigate() nav.nav(computeRoute) } - override fun newStack(route: String) { + override fun newStack(route: Route) { onNavigate() nav.newStack(route) } @@ -190,19 +194,11 @@ class ObservableNavigate( nav.popBack() } - override fun popUpTo( - route: String, - upTo: String, + override fun popUpTo( + route: Route, + upToClass: KClass, ) { onNavigate() - nav.popUpTo(route, upTo) - } - - override fun closeDrawer() { - nav.closeDrawer() - } - - override fun openDrawer() { - nav.openDrawer() + nav.popUpTo(route, upToClass) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/NavBuilderEffects.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/NavBuilderEffects.kt new file mode 100644 index 000000000..d3a328833 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/NavBuilderEffects.kt @@ -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 NavGraphBuilder.composableFromEnd(noinline content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit) { + composable( + enterTransition = { slideInHorizontallyFromEnd }, + exitTransition = { scaleOut }, + popEnterTransition = { scaleIn }, + popExitTransition = { slideOutHorizontallyToEnd }, + content = content, + ) +} + +inline fun NavGraphBuilder.composableFromEndArgs(noinline content: @Composable AnimatedContentScope.(T) -> Unit) { + composableFromEnd { + content(it.toRoute()) + } +} + +inline fun NavGraphBuilder.composableFromBottom(noinline content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit) { + composable( + enterTransition = { slideInVerticallyFromBottom }, + exitTransition = { scaleOut }, + popEnterTransition = { scaleIn }, + popExitTransition = { slideOutVerticallyToBottom }, + content = content, + ) +} + +inline fun NavGraphBuilder.composableFromBottomArgs(noinline content: @Composable AnimatedContentScope.(T) -> Unit) { + composableFromBottom { + content(it.toRoute()) + } +} + +inline fun NavGraphBuilder.composableArgs(noinline content: @Composable AnimatedContentScope.(T) -> Unit) { + composable { + content(it.toRoute()) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/RouteMaker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/RouteMaker.kt index a30c8a3bc..ba81a7ba3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/RouteMaker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/RouteMaker.kt @@ -38,13 +38,12 @@ import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessa import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent -import java.net.URLEncoder fun routeFor( note: Note, loggedIn: User, -): String? { - val noteEvent = note.event ?: return "Note/${URLEncoder.encode(note.idHex, "utf-8")}" +): Route? { + val noteEvent = note.event ?: return Route.Note(note.idHex) return routeFor(noteEvent, loggedIn) } @@ -52,57 +51,57 @@ fun routeFor( fun routeFor( noteEvent: Event, loggedIn: User, -): String? { +): Route? { if (noteEvent is DraftEvent) { val innerEvent = noteEvent.preCachedDraft(loggedIn.pubkeyHex) if (innerEvent is IsInPublicChatChannel) { innerEvent.channelId()?.let { - return "Channel/$it" + return Route.Channel(it) } } else if (innerEvent is LiveActivitiesEvent) { innerEvent.aTag().toTag().let { - return "Channel/${URLEncoder.encode(it, "utf-8")}" + return Route.Channel(it) } } else if (innerEvent is LiveActivitiesChatMessageEvent) { innerEvent.activity()?.toTag()?.let { - return "Channel/${URLEncoder.encode(it, "utf-8")}" + return Route.Channel(it) } } else if (innerEvent is ChatroomKeyable) { val room = innerEvent.chatroomKey(loggedIn.pubkeyHex) loggedIn.createChatroom(room) - return "Room/${room.hashCode()}" + return Route.Room(room.hashCode()) } else if (innerEvent is AddressableEvent) { - return "Note/${URLEncoder.encode(noteEvent.aTag().toTag(), "utf-8")}" + return Route.Note(noteEvent.aTag().toTag()) } else { - return "Note/${URLEncoder.encode(noteEvent.id, "utf-8")}" + return Route.Note(noteEvent.id) } } else if (noteEvent is AppDefinitionEvent) { - return "ContentDiscovery/${noteEvent.id}" + return Route.ContentDiscovery(noteEvent.id) } else if (noteEvent is IsInPublicChatChannel) { noteEvent.channelId()?.let { - return "Channel/$it" + return Route.Channel(it) } } else if (noteEvent is ChannelCreateEvent) { - return "Channel/${noteEvent.id}" + return Route.Channel(noteEvent.id) } else if (noteEvent is LiveActivitiesEvent) { noteEvent.aTag().toTag().let { - return "Channel/${URLEncoder.encode(it, "utf-8")}" + return Route.Channel(it) } } else if (noteEvent is LiveActivitiesChatMessageEvent) { noteEvent.activity()?.toTag()?.let { - return "Channel/${URLEncoder.encode(it, "utf-8")}" + return Route.Channel(it) } } else if (noteEvent is ChatroomKeyable) { val room = noteEvent.chatroomKey(loggedIn.pubkeyHex) loggedIn.createChatroom(room) - return "Room/${room.hashCode()}" + return Route.Room(room.hashCode()) } 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) { - return "Note/${URLEncoder.encode(noteEvent.aTag().toTag(), "utf-8")}" + return Route.Note(noteEvent.aTag().toTag()) } else { - return "Note/${URLEncoder.encode(noteEvent.id, "utf-8")}" + return Route.Note(noteEvent.id) } return null @@ -112,14 +111,14 @@ fun routeToMessage( user: HexKey, draftMessage: String?, replyId: HexKey? = null, - quoteId: HexKey? = null, + draftId: HexKey? = null, accountViewModel: AccountViewModel, -): String = +): Route = routeToMessage( setOf(user), draftMessage, replyId, - quoteId, + draftId, accountViewModel, ) @@ -127,13 +126,13 @@ fun routeToMessage( users: Set, draftMessage: String?, replyId: HexKey? = null, - quoteId: HexKey? = null, + draftId: HexKey? = null, accountViewModel: AccountViewModel, ) = routeToMessage( ChatroomKey(users), draftMessage, replyId, - quoteId, + draftId, accountViewModel, ) @@ -141,44 +140,24 @@ fun routeToMessage( room: ChatroomKey, draftMessage: String?, replyId: HexKey? = null, - quoteId: HexKey? = null, + draftId: HexKey? = null, accountViewModel: AccountViewModel, -): String { +): Route { accountViewModel.account.userProfile().createChatroom(room) - val params = - 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("&")) - } - } + return Route.Room(room.hashCode(), draftMessage, replyId, draftId) } fun routeToMessage( user: User, draftMessage: String?, replyId: HexKey? = null, - quoteId: HexKey? = null, + draftId: HexKey? = null, 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) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt index 30dc7895f..d38876f4d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt @@ -20,124 +20,46 @@ */ 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.runtime.Immutable -import androidx.compose.runtime.State import androidx.compose.ui.Modifier -import androidx.navigation.NamedNavArgument -import androidx.navigation.NavBackStackEntry -import androidx.navigation.NavDestination +import androidx.navigation.NavDestination.Companion.hasRoute import androidx.navigation.NavHostController -import androidx.navigation.NavType -import androidx.navigation.navArgument +import androidx.navigation.toRoute import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.theme.Size20dp import com.vitorpamplona.amethyst.ui.theme.Size23dp -import com.vitorpamplona.amethyst.ui.theme.Size24dp -import com.vitorpamplona.amethyst.ui.theme.Size25dp -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.persistentListOf -import kotlinx.collections.immutable.toImmutableList -import java.net.URLEncoder +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.serialization.Serializable +import kotlin.String -@Immutable -sealed class Route( - val route: String, - val base: String = route.substringBefore("?"), +class BottomBarRoute( + val route: Route, val icon: Int, + val contentDescriptor: Int = R.string.route, val notifSize: Modifier = Modifier.size(Size23dp), val iconSize: Modifier = Modifier.size(Size20dp), - val contentDescriptor: Int = R.string.route, - val arguments: ImmutableList = 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 : - Route( - route = "Global", - icon = R.drawable.ic_globe, - contentDescriptor = R.string.route_global, - ) +sealed class Route { + @Serializable object Home : Route() - object Search : - Route( - route = "Search", - icon = R.drawable.ic_moments, - contentDescriptor = R.string.route_search, - ) + @Serializable object Message : Route() - object Video : - Route( - route = "Video", - icon = R.drawable.ic_video, - contentDescriptor = R.string.route_video, - ) + @Serializable object Video : Route() - object Discover : - Route( - route = "Discover", - icon = R.drawable.ic_sensors, - contentDescriptor = R.string.route_discover, - ) + @Serializable object Discover : Route() - object Notification : - Route( - route = "Notification", - icon = R.drawable.ic_notifications, - contentDescriptor = R.string.route_notifications, - ) + @Serializable object Notification : Route() - object Message : - Route( - route = "Message", - icon = R.drawable.ic_dm, - contentDescriptor = R.string.route_messages, - ) + @Serializable object Search : Route() - object NewGroupDM : - 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(), - ) + @Serializable object SecurityFilters : Route() - object BlockedUsers : - Route( - route = "BlockedUsers", - icon = R.drawable.ic_security, - contentDescriptor = R.string.route_security_filters, - ) + @Serializable object Bookmarks : Route() - object Bookmarks : - Route( - route = "Bookmarks", - icon = R.drawable.ic_bookmarks, - contentDescriptor = R.string.route_home, - ) + @Serializable object Drafts : Route() + @Serializable object Settings : Route() object Lists : Route( route = "Lists", @@ -145,249 +67,119 @@ sealed class Route( contentDescriptor = R.string.my_lists, ) - object ContentDiscovery : - Route( - icon = R.drawable.ic_bookmarks, - contentDescriptor = R.string.discover_content, - route = "ContentDiscovery/{id}", - arguments = listOf(navArgument("id") { type = NavType.StringType }).toImmutableList(), - ) + @Serializable object EditProfile : Route() - object Drafts : - Route( - route = "Drafts", - icon = R.drawable.ic_topics, - contentDescriptor = R.string.drafts, - ) + @Serializable data class EditRelays( + val toAdd: String? = null, + ) : Route() - object Profile : - Route( - route = "User/{id}", - icon = R.drawable.ic_profile, - arguments = listOf(navArgument("id") { type = NavType.StringType }).toImmutableList(), - ) + @Serializable data class Nip47NWCSetup( + val nip47: String? = null, + ) : Route() - object Note : - Route( - route = "Note/{id}", - icon = R.drawable.ic_moments, - arguments = listOf(navArgument("id") { type = NavType.StringType }).toImmutableList(), - ) + @Serializable data class Profile( + val id: String, + ) : Route() - object Hashtag : - Route( - route = "Hashtag/{id}", - icon = R.drawable.ic_moments, - arguments = listOf(navArgument("id") { type = NavType.StringType }).toImmutableList(), - ) + @Serializable data class ContentDiscovery( + val id: String, + ) : Route() - object Geohash : - Route( - route = "Geohash/{id}", - icon = R.drawable.ic_moments, - arguments = listOf(navArgument("id") { type = NavType.StringType }).toImmutableList(), - ) + @Serializable data class Note( + val id: String, + ) : Route() - object Community : - Route( - route = "Community/{id}", - icon = R.drawable.ic_moments, - arguments = listOf(navArgument("id") { type = NavType.StringType }).toImmutableList(), - ) + @Serializable data class Hashtag( + val id: String, + ) : Route() - object Room : - Route( - route = "Room/{id}?message={message}&replyId={replyId}&draftId={draftId}", - 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(), - ) + @Serializable data class Geohash( + val id: String, + ) : Route() - object RoomByAuthor : - Route( - route = "RoomByAuthor/{id}", - icon = R.drawable.ic_moments, - arguments = listOf(navArgument("id") { type = NavType.StringType }).toImmutableList(), - ) + @Serializable data class Community( + val id: String, + ) : Route() - object Channel : - Route( - route = "Channel/{id}", - icon = R.drawable.ic_moments, - arguments = listOf(navArgument("id") { type = NavType.StringType }).toImmutableList(), - ) + @Serializable data class Channel( + val id: String, + ) : Route() - object ChannelMetadataEdit : - Route( - route = "ChannelMetadataEdit?id={id}", - icon = R.drawable.ic_moments, - arguments = - listOf( - navArgument("id") { - type = NavType.StringType - nullable = true - defaultValue = null - }, - ).toImmutableList(), - ) + @Serializable data class ChannelMetadataEdit( + val id: String? = null, + ) : Route() - object Event : - Route( - route = "Event/{id}", - icon = R.drawable.ic_moments, - arguments = listOf(navArgument("id") { type = NavType.StringType }).toImmutableList(), - ) + @Serializable data class NewGroupDM( + val message: String? = null, + val attachment: String? = null, + ) : Route() - object Settings : - Route( - route = "Settings", - icon = R.drawable.ic_settings, - ) + @Serializable data class Room( + val id: Int, + val message: String? = null, + val replyId: HexKey? = null, + val draftId: HexKey? = null, + ) : Route() - object EditProfile : - Route( - route = "EditProfile", - icon = R.drawable.ic_settings, - ) + @Serializable data class RoomByAuthor( + val id: String, + ) : Route() - object EditRelays : - Route( - route = "EditRelays?toAdd={toAdd}", - icon = R.drawable.ic_globe, - contentDescriptor = R.string.relays, - arguments = - listOf( - navArgument("toAdd") { - type = NavType.StringType - nullable = true - defaultValue = null - }, - ).toImmutableList(), - ) + @Serializable data class EventRedirect( + val id: String, + ) : Route() - object NIP47Setup : - Route( - route = "NIP47Setup?nip47={nip47}", - icon = R.drawable.ic_home, - arguments = - listOf( - navArgument("nip47") { - type = NavType.StringType - nullable = true - defaultValue = null - }, - ).toImmutableList(), - ) - - object NewPost : - Route( - route = "NewPost?message={message}&attachment={attachment}&baseReplyTo={baseReplyTo}"e={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(), - ) + @Serializable + data class NewPost( + val message: String? = null, + val attachment: String? = null, + val baseReplyTo: String? = null, + val quote: String? = null, + val fork: String? = null, + val version: String? = null, + val draft: String? = null, + val enableGeolocation: Boolean = false, + ) : Route() } -fun isBaseRoute( - navController: NavHostController, - startsWith: String, -): Boolean = - navController.currentBackStackEntry - ?.destination - ?.route - ?.startsWith(startsWith) ?: false +inline fun isBaseRoute(navController: NavHostController): Boolean = navController.currentBackStackEntry?.destination?.hasRoute() == true -fun getRouteWithArguments(navController: NavHostController): String? { - val currentEntry = navController.currentBackStackEntry ?: return null - return getRouteWithArguments(currentEntry.destination, currentEntry.arguments) -} +fun getRouteWithArguments(navController: NavHostController): Route? { + val entry = navController.currentBackStackEntry ?: return null + val dest = entry.destination -fun getRouteWithArguments(navState: State): String? = navState.value?.let { getRouteWithArguments(it.destination, it.arguments) } + return when { + dest.hasRoute() -> entry.toRoute() + dest.hasRoute() -> entry.toRoute() + dest.hasRoute() -> entry.toRoute() + dest.hasRoute() -> entry.toRoute() + dest.hasRoute() -> entry.toRoute() -private fun getRouteWithArguments( - destination: NavDestination, - arguments: Bundle?, -): String? { - var route = destination.route ?: return null - arguments?.let { bundle -> - destination.arguments.forEach { - val key = it.key - val value = it.value.type[bundle, key]?.toString() - if (value == null) { - val keyStart = route.indexOf("{$key}") - // if it is a parameter, removes the complete segment `var={key}` and adjust connectors `#`, - // `&` or `&` - if (keyStart > 0 && route[keyStart - 1] == '=') { - val end = keyStart + "{$key}".length - var start = keyStart - for (i in keyStart downTo 0) { - if (route[i] == '#' || route[i] == '?' || route[i] == '&') { - start = i + 1 - break - } - } - if (end < route.length && route[end] == '&') { - route = route.removeRange(start, end + 1) - } else if (end < route.length && route[end] == '#') { - 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) - } + dest.hasRoute() -> entry.toRoute() + dest.hasRoute() -> entry.toRoute() + dest.hasRoute() -> entry.toRoute() + dest.hasRoute() -> entry.toRoute() + dest.hasRoute() -> entry.toRoute() + dest.hasRoute() -> entry.toRoute() + dest.hasRoute() -> entry.toRoute() + + dest.hasRoute() -> entry.toRoute() + dest.hasRoute() -> entry.toRoute() + dest.hasRoute() -> entry.toRoute() + dest.hasRoute() -> entry.toRoute() + dest.hasRoute() -> entry.toRoute() + + dest.hasRoute() -> entry.toRoute() + dest.hasRoute() -> entry.toRoute() + dest.hasRoute() -> entry.toRoute() + dest.hasRoute() -> entry.toRoute() + dest.hasRoute() -> entry.toRoute() + dest.hasRoute() -> entry.toRoute() + dest.hasRoute() -> entry.toRoute() + dest.hasRoute() -> entry.toRoute() + + else -> { + null } } - 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&" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt index 685f72bff..2ed435160 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt @@ -570,7 +570,7 @@ private fun BoxedAuthor( nav: INav, 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 -> WatchUserMetadataAndFollowsAndRenderUserProfilePictureOrDefaultAuthor( author, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt index f0a477015..ed7ac1dc8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt @@ -24,7 +24,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.text.ClickableText import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.OpenInNew import androidx.compose.material3.Icon @@ -390,8 +389,8 @@ fun DisplayNIP05( NIP05VerifiedSymbol(nip05Verified, NIP05IconSize, accountViewModel) - ClickableText( - text = remember(nip05) { AnnotatedString(domain) }, + ClickableTextPrimary( + text = domain, onClick = { runCatching { uri.openUri("https://$domain") } }, style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.nip05, fontSize = Font14SP), @@ -441,7 +440,7 @@ fun DisplayNip05ProfileStatus( if (user != "_") { Text( - text = remember { AnnotatedString(user + "@") }, + text = "$user@", color = MaterialTheme.colorScheme.primary, modifier = Modifier.padding(top = 1.dp, bottom = 1.dp, start = 5.dp), maxLines = 1, @@ -450,10 +449,9 @@ fun DisplayNip05ProfileStatus( domainPadStart = 0.dp } - ClickableText( - text = AnnotatedString(domain), + ClickableTextPrimary( + text = domain, 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), maxLines = 1, overflow = TextOverflow.Ellipsis, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt index 49c617e1c..be90c1bdf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt @@ -88,7 +88,7 @@ import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.components.SelectTextDialog import com.vitorpamplona.amethyst.ui.navigation.EmptyNav 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.ReportNoteDialog import com.vitorpamplona.amethyst.ui.stringRes @@ -173,11 +173,11 @@ fun NoteQuickActionMenu( note = note, onDismiss = onDismiss, onWantsToEditDraft = { - val route = - buildNewPostRoute( + nav.nav( + Route.NewPost( draft = note.idHex, - ) - nav.nav(route) + ), + ) }, accountViewModel = accountViewModel, nav = nav, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index 4f1366bea..047dc83e1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -112,7 +112,7 @@ import com.vitorpamplona.amethyst.ui.components.ClickableBox import com.vitorpamplona.amethyst.ui.components.GenericLoadable import com.vitorpamplona.amethyst.ui.components.InLineIconRenderer 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.navigation.routeToMessage import com.vitorpamplona.amethyst.ui.note.types.EditState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -572,7 +572,7 @@ private fun BoostWithDialog( accountViewModel, onQuotePress = { nav.nav { - buildNewPostRoute( + Route.NewPost( quote = baseNote.idHex, version = (editState.value as? GenericLoadable.Loaded) @@ -594,7 +594,7 @@ private fun BoostWithDialog( null } - buildNewPostRoute( + Route.NewPost( baseReplyTo = replyTo?.idHex, fork = baseNote.idHex, version = @@ -624,7 +624,7 @@ private fun ReplyReactionWithDialog( room = noteEvent.chatroomKey(accountViewModel.userProfile().pubkeyHex), draftMessage = null, replyId = noteEvent.id, - quoteId = null, + draftId = null, accountViewModel = accountViewModel, ) } @@ -634,13 +634,13 @@ private fun ReplyReactionWithDialog( room = noteEvent.chatroomKey(accountViewModel.userProfile().pubkeyHex), draftMessage = null, replyId = noteEvent.id, - quoteId = null, + draftId = null, accountViewModel = accountViewModel, ) } } else { nav.nav { - buildNewPostRoute( + Route.NewPost( baseReplyTo = baseNote.idHex, quote = null, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt index c08187947..bf095f47a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt @@ -39,6 +39,7 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.components.CreateClickableTextWithEmoji import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.routeFor import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer @@ -68,7 +69,7 @@ fun ReplyInformationChannel( ReplyInformationChannel( replyTo, sortedMentions, - onUserTagClick = { nav.nav("User/${it.pubkeyHex}") }, + onUserTagClick = { nav.nav(routeFor(it)) }, ) Spacer(modifier = StdVertSpacer) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt index 6735a220e..a47061d90 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt @@ -60,6 +60,7 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -109,6 +110,7 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +@Stable class UpdateZapAmountViewModel : ViewModel() { var account: Account? = null diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt index fd946c474..0a7016e5c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt @@ -31,6 +31,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.routeFor import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.Size55dp import com.vitorpamplona.amethyst.ui.theme.StdPadding @@ -45,7 +46,7 @@ fun UserCompose( Row( modifier = overallModifier.clickable( - onClick = { nav.nav("User/${baseUser.pubkeyHex}") }, + onClick = { nav.nav(routeFor(baseUser)) }, ), verticalAlignment = Alignment.CenterVertically, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt index 8993b6812..a3972d12d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt @@ -43,6 +43,7 @@ import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImage import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.routeFor import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser import com.vitorpamplona.amethyst.ui.stringRes @@ -57,7 +58,7 @@ fun NoteAuthorPicture( pictureModifier: Modifier = Modifier, ) { NoteAuthorPicture(baseNote, size, accountViewModel, pictureModifier) { - nav.nav("User/${it.pubkeyHex}") + nav.nav(routeFor(it)) } } @@ -137,7 +138,7 @@ fun UserPicture( size = size, accountViewModel = accountViewModel, modifier = pictureModifier, - onClick = { nav.nav("User/${user.pubkeyHex}") }, + onClick = { nav.nav(routeFor(it)) }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapNoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapNoteCompose.kt index 5a04c5e0b..e7ed80066 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapNoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapNoteCompose.kt @@ -45,6 +45,7 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.routeFor import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.FollowButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.UnfollowButton @@ -77,15 +78,13 @@ fun ZapNoteCompose( } } - val route = remember(baseAuthor) { "User/${baseAuthor?.pubkeyHex}" } - if (baseAuthor == null) { BlankNote() } else { Column( modifier = Modifier.clickable( - onClick = { nav.nav(route) }, + onClick = { baseAuthor?.let { nav.nav(routeFor(it)) } }, ), verticalArrangement = Arrangement.Center, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapUserSetCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapUserSetCompose.kt index 75b3d1bb4..6f35ada2f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapUserSetCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapUserSetCompose.kt @@ -35,6 +35,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.routeFor import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.ZapUserSetCard import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer @@ -60,7 +61,7 @@ fun ZapUserSetCompose( Column( modifier = Modifier.background(backgroundColor.value).clickable { - nav.nav("User/${zapSetCard.user.pubkeyHex}") + nav.nav(routeFor(zapSetCard.user)) }, ) { Row( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapsplits/DisplayZapSplits.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapsplits/DisplayZapSplits.kt index 3fe5af56c..5afd85d80 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapsplits/DisplayZapSplits.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapsplits/DisplayZapSplits.kt @@ -24,13 +24,10 @@ import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -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.remember import androidx.compose.ui.Alignment -import androidx.compose.ui.text.AnnotatedString +import com.vitorpamplona.amethyst.ui.components.ClickableTextPrimary import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.note.UserPicture import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -76,11 +73,7 @@ fun DisplayZapSplits( list.forEach { when (it) { is ZapSplitSetupLnAddress -> - ClickableText( - text = AnnotatedString(it.lnAddress), - onClick = {}, - style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary), - ) + ClickableTextPrimary(it.lnAddress) { } is ZapSplitSetup -> UserPicture( userHex = it.pubKeyHex, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayCommunity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayCommunity.kt index f1c4df4ae..a686b891c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayCommunity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayCommunity.kt @@ -20,17 +20,19 @@ */ package com.vitorpamplona.amethyst.ui.note.elements +import android.R.attr.maxLines import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.text.ClickableText 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.Alignment -import androidx.compose.ui.text.AnnotatedString import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.buildLinkString import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag @@ -56,12 +58,13 @@ private fun DisplayCommunity( val communityTag = remember(note) { note.event?.getTagOfAddressableKind(CommunityDefinitionEvent.KIND) } ?: return - val displayTag = remember(note) { AnnotatedString(getCommunityShortName(communityTag)) } - val route = remember(note) { "Community/${communityTag.toTag()}" } + val displayTag = + remember(note) { + buildLinkString(getCommunityShortName(communityTag)) { nav.nav(Route.Community(communityTag.toTag())) } + } - ClickableText( + Text( text = displayTag, - onClick = { nav.nav(route) }, style = LocalTextStyle.current.copy( color = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayHashtags.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayHashtags.kt index efc0aaaf2..3ed6aba7a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayHashtags.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayHashtags.kt @@ -20,12 +20,13 @@ */ package com.vitorpamplona.amethyst.ui.note.elements +import android.R.attr.maxLines import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.text.ClickableText import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -33,10 +34,12 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment -import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.style.TextOverflow import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.buildLinkString import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip01Core.tags.hashtags.firstIsTaggedHashes @@ -69,19 +72,15 @@ private fun DisplayTagList( firstTag: String, nav: INav, ) { - val displayTag = remember(firstTag) { AnnotatedString(" #$firstTag") } - val route = remember(firstTag) { "Hashtag/$firstTag" } - - ClickableText( - text = displayTag, - onClick = { nav.nav(route) }, - style = - LocalTextStyle.current.copy( - color = - MaterialTheme.colorScheme.primary.copy( - alpha = 0.52f, - ), - ), + Text( + text = + remember(firstTag) { + buildLinkString(" #$firstTag") { + nav.nav(Route.Hashtag(firstTag)) + } + }, + style = LocalTextStyle.current.copy(MaterialTheme.colorScheme.primary.copy(alpha = 0.52f)), + overflow = TextOverflow.Ellipsis, maxLines = 1, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayLocation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayLocation.kt index ebae43765..8e23cc395 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayLocation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayLocation.kt @@ -20,13 +20,16 @@ */ package com.vitorpamplona.amethyst.ui.note.elements -import androidx.compose.foundation.text.ClickableText import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withLink import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.Route import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName import com.vitorpamplona.amethyst.ui.theme.Font14SP @@ -36,9 +39,15 @@ fun DisplayLocation( nav: INav, ) { LoadCityName(geohashStr) { cityName -> - ClickableText( - text = AnnotatedString(cityName), - onClick = { nav.nav("Geohash/$geohashStr") }, + Text( + text = + buildAnnotatedString { + withLink( + LinkAnnotation.Clickable("cityname") { nav.nav(Route.Geohash(geohashStr)) }, + ) { + append(cityName) + } + }, style = LocalTextStyle.current.copy( color = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayOts.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayOts.kt index 573fe81ca..6ad96aa16 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayOts.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayOts.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.note.elements -import androidx.compose.foundation.text.ClickableText import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -29,10 +28,10 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.buildLinkString import com.vitorpamplona.amethyst.ui.note.LoadOts import com.vitorpamplona.amethyst.ui.note.timeAgoNoDot import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -61,26 +60,15 @@ fun DisplayOts( ) } - ClickableText( + Text( text = - buildAnnotatedString { - append( - stringRes( - id = R.string.existed_since, - timeStr, - ), + buildLinkString(stringRes(R.string.existed_since, timeStr)) { + accountViewModel.toastManager.toast( + R.string.ots_info_title, + R.string.ots_info_description, + SimpleDateFormat.getDateTimeInstance().format(Date(unixtimestamp * 1000)), ) }, - onClick = { - val fullDateTime = - SimpleDateFormat.getDateTimeInstance().format(Date(unixtimestamp * 1000)) - - accountViewModel.toastManager.toast( - R.string.ots_info_title, - R.string.ots_info_description, - fullDateTime, - ) - }, style = LocalTextStyle.current.copy( color = MaterialTheme.colorScheme.lessImportantLink, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayReward.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayReward.kt index 64765a863..8f44e07eb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayReward.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayReward.kt @@ -30,9 +30,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width -import androidx.compose.foundation.text.ClickableText import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface @@ -48,7 +46,6 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.TextFieldValue @@ -61,7 +58,9 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.ClickableTextColor import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.Route import com.vitorpamplona.amethyst.ui.note.ZapIcon import com.vitorpamplona.amethyst.ui.note.ZappedIcon import com.vitorpamplona.amethyst.ui.note.showAmount @@ -94,17 +93,12 @@ fun DisplayReward( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.clickable { popupExpanded = true }, ) { - ClickableText( - text = AnnotatedString("#bounty"), - onClick = { nav.nav("Hashtag/bounty") }, - style = - LocalTextStyle.current.copy( - color = - MaterialTheme.colorScheme.primary.copy( - alpha = 0.52f, - ), - ), - ) + ClickableTextColor( + "#bounty", + linkColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.52f), + ) { + nav.nav(Route.Hashtag("bounty")) + } RenderPledgeAmount(baseNote, baseReward, accountViewModel) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayUncitedHashtags.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayUncitedHashtags.kt index 7f71aa4f7..2a5943296 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayUncitedHashtags.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayUncitedHashtags.kt @@ -22,17 +22,15 @@ package com.vitorpamplona.amethyst.ui.note.elements import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow -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.getValue import androidx.compose.runtime.produceState -import androidx.compose.runtime.remember -import androidx.compose.ui.text.AnnotatedString import com.vitorpamplona.amethyst.commons.richtext.HashTagSegment import com.vitorpamplona.amethyst.service.CachedRichTextParser +import com.vitorpamplona.amethyst.ui.components.ClickableTextColor import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.Route import com.vitorpamplona.amethyst.ui.theme.HalfTopPadding import com.vitorpamplona.amethyst.ui.theme.lessImportantLink import com.vitorpamplona.quartz.nip01Core.core.Event @@ -94,19 +92,14 @@ fun DisplayUncitedHashtags( } if (unusedHashtags.isNotEmpty()) { - val style = - LocalTextStyle.current.copy( - color = MaterialTheme.colorScheme.lessImportantLink, - ) - FlowRow( modifier = HalfTopPadding, ) { unusedHashtags.forEach { hashtag -> - ClickableText( - text = remember { AnnotatedString("#$hashtag ") }, - onClick = { nav.nav("Hashtag/$hashtag") }, - style = style, + ClickableTextColor( + text = "#$hashtag ", + onClick = { nav.nav(Route.Hashtag(hashtag)) }, + linkColor = MaterialTheme.colorScheme.lessImportantLink, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt index f39be374e..438b9ef45 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt @@ -46,7 +46,7 @@ import com.vitorpamplona.amethyst.ui.actions.EditPostView import com.vitorpamplona.amethyst.ui.components.ClickableBox import com.vitorpamplona.amethyst.ui.components.GenericLoadable 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.note.VerticalDotsIcon import com.vitorpamplona.amethyst.ui.note.externalLinkForNote import com.vitorpamplona.amethyst.ui.note.types.EditState @@ -238,7 +238,7 @@ fun NoteDropDownMenu( text = { Text(stringRes(R.string.edit_draft)) }, onClick = { val route = - buildNewPostRoute( + Route.NewPost( draft = note.idHex, ) nav.nav(route) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ForkInfo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ForkInfo.kt index 5d734079e..63a895ad8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ForkInfo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ForkInfo.kt @@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.ui.note.elements import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.text.ClickableText import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -37,6 +36,7 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.CreateClickableTextWithEmoji import com.vitorpamplona.amethyst.ui.components.LoadNote +import com.vitorpamplona.amethyst.ui.components.appendLink import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.navigation.routeFor import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote @@ -87,13 +87,13 @@ fun ForkInformationRowLightColor( if (route != null) { Row(modifier) { - ClickableText( + Text( text = buildAnnotatedString { - append(stringRes(id = R.string.forked_from)) - append(" ") + appendLink(stringRes(id = R.string.forked_from) + " ") { + nav.nav(route) + } }, - onClick = { nav.nav(route) }, style = LocalTextStyle.current.copy( color = MaterialTheme.colorScheme.nip05, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ZapTheDevsCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ZapTheDevsCard.kt index dd093e50e..958434b97 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ZapTheDevsCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ZapTheDevsCard.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.note.elements +import android.R.attr.onClick import android.content.Context import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.layout.Arrangement @@ -49,11 +50,11 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.LinkAnnotation import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.withStyle +import androidx.compose.ui.text.withLink import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -65,10 +66,11 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.ZapPaymentHandler import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled -import com.vitorpamplona.amethyst.ui.components.ClickableText import com.vitorpamplona.amethyst.ui.components.LoadNote +import com.vitorpamplona.amethyst.ui.components.appendLink import com.vitorpamplona.amethyst.ui.navigation.EmptyNav 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.CloseIcon import com.vitorpamplona.amethyst.ui.note.ObserveZapIcon @@ -221,16 +223,12 @@ fun ZapTheDevsCard( Spacer(modifier = StdVertSpacer) - ClickableText( - text = - buildAnnotatedString { - append(stringRes(id = R.string.zap_the_devs_description, BuildConfig.VERSION_NAME)) - append(" ") - withStyle(SpanStyle(color = MaterialTheme.colorScheme.primary)) { - append("#value4value") - } - }, - onClick = { nav.nav("Hashtag/value4value") }, + Text( + buildAnnotatedString { + append(stringRes(id = R.string.zap_the_devs_description, BuildConfig.VERSION_NAME)) + append(" ") + appendLink("#value4value", MaterialTheme.colorScheme.primary) { nav.nav(Route.Hashtag("value4value")) } + }, ) Spacer(modifier = StdVertSpacer) @@ -243,15 +241,16 @@ fun ZapTheDevsCard( } if (route != null) { - ClickableText( + Text( text = buildAnnotatedString { - withStyle(SpanStyle(color = MaterialTheme.colorScheme.primary)) { + withLink( + LinkAnnotation.Clickable("clickable") { nav.nav(route) }, + ) { append(stringRes(id = R.string.version_name, BuildConfig.VERSION_NAME.substringBefore("-"))) } append(" " + stringRes(id = R.string.brought_to_you_by)) }, - onClick = { nav.nav(route) }, ) } else { Text(stringRes(id = R.string.this_version_brought_to_you_by)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AppDefinition.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AppDefinition.kt index 3ee743da6..10bbeb537 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AppDefinition.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AppDefinition.kt @@ -36,8 +36,6 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape -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.LaunchedEffect @@ -60,6 +58,7 @@ import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.ClickableTextPrimary import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.components.ZoomableImageDialog @@ -210,10 +209,9 @@ fun RenderAppDefinition( Row(verticalAlignment = Alignment.CenterVertically) { LinkIcon(Size16Modifier, MaterialTheme.colorScheme.placeholderText) - ClickableText( - text = AnnotatedString(website.removePrefix("https://")), + ClickableTextPrimary( + text = website.removePrefix("https://"), onClick = { website.let { runCatching { uri.openUri(it) } } }, - style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary), modifier = Modifier.padding(top = 1.dp, bottom = 1.dp, start = 5.dp), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AudioTrack.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AudioTrack.kt index 8f24e4f49..472b4ad95 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AudioTrack.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AudioTrack.kt @@ -47,6 +47,7 @@ import com.vitorpamplona.amethyst.service.playback.composable.LoadThumbAndThenVi import com.vitorpamplona.amethyst.service.playback.composable.VideoView import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.routeFor import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture import com.vitorpamplona.amethyst.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags @@ -118,7 +119,7 @@ fun AudioTrackHeader( Modifier .padding(top = 5.dp, start = 10.dp, end = 10.dp) .clickable { - nav.nav("User/${it.second.pubkeyHex}") + nav.nav(routeFor(it.second)) }, ) { ClickableUserPicture(it.second, 25.dp, accountViewModel) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CommunityHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CommunityHeader.kt index 1da46693b..588badc4a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CommunityHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CommunityHeader.kt @@ -223,7 +223,7 @@ fun LongCommunityHeader( participantUsers.forEach { Row( - lineModifier.clickable { nav.nav("User/${it.second.pubkeyHex}") }, + lineModifier.clickable { nav.nav(routeFor(it.second)) }, verticalAlignment = Alignment.CenterVertically, ) { it.first.role?.let { it1 -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileStorage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileStorage.kt index dca60e57e..02c39c05b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileStorage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileStorage.kt @@ -77,7 +77,7 @@ private fun ObserverAndRenderNIP95( // Creates a new object when the event arrives to force an update of the image. val note = noteState?.note val uri = header.toNostrUri() - val localDir = note?.idHex?.let { File(Amethyst.instance.nip95cache(), it) } + val localDir = note?.idHex?.let { File(Amethyst.instance.nip95cache, it) } val blurHash = eventHeader.blurhash() val dimensions = eventHeader.dimensions() val description = eventHeader.alt() ?: eventHeader.content diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Highlight.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Highlight.kt index 06e0dbcda..d246ea254 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Highlight.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Highlight.kt @@ -26,9 +26,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.text.ClickableText import androidx.compose.material3.LocalTextStyle -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -40,10 +38,10 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.AnnotatedString import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.components.ClickableTextPrimary import com.vitorpamplona.amethyst.ui.components.ClickableUrl import com.vitorpamplona.amethyst.ui.components.CreateClickableTextWithEmoji import com.vitorpamplona.amethyst.ui.components.DisplayEvent @@ -216,16 +214,16 @@ private fun DisplayQuoteAuthor( @Composable fun DisplayEntryForUser( - userBase: User, + baseUser: User, accountViewModel: AccountViewModel, nav: INav, ) { - val userMetadata by userBase.live().userMetadataInfo.observeAsState() + val userMetadata by baseUser.live().userMetadataInfo.observeAsState() CreateClickableTextWithEmoji( - clickablePart = userMetadata?.bestName() ?: userBase.pubkeyDisplayHex(), + clickablePart = userMetadata?.bestName() ?: baseUser.pubkeyDisplayHex(), maxLines = 1, - route = "User/${userBase.pubkeyHex}", + route = remember(baseUser) { routeFor(baseUser) }, nav = nav, tags = userMetadata?.tags, ) @@ -253,10 +251,9 @@ fun DisplayEntryForNote( Text("-", maxLines = 1) if (description != null) { - ClickableText( - text = AnnotatedString(description), + ClickableTextPrimary( + text = description, onClick = { routeFor(note, accountViewModel.userProfile())?.let { nav.nav(it) } }, - style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary), ) } else { DisplayEvent(noteEvent.id, noteEvent.kind, note.toNostrUri(), null, accountViewModel, nav) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivity.kt index 4d91eda84..a3251bba9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivity.kt @@ -54,6 +54,7 @@ import com.vitorpamplona.amethyst.service.playback.composable.VideoView import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.navigation.EmptyNav import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.routeFor import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture import com.vitorpamplona.amethyst.ui.note.DisplayAuthorBanner import com.vitorpamplona.amethyst.ui.note.UsernameDisplay @@ -253,7 +254,7 @@ fun RenderLiveActivityEventInner( modifier = Modifier .padding(vertical = 5.dp) - .clickable { nav.nav("User/${it.second.pubkeyHex}") }, + .clickable { nav.nav(routeFor(it.second)) }, ) { ClickableUserPicture(it.second, 25.dp, accountViewModel) Spacer(StdHorzSpacer) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RelayList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RelayList.kt index df646155b..1e52c2f01 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RelayList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RelayList.kt @@ -62,8 +62,6 @@ import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList -import java.net.URLEncoder -import java.nio.charset.StandardCharsets @Composable fun DisplayRelaySet( @@ -299,11 +297,11 @@ private fun RelayOptionsAction( if (isCurrentlyOnTheUsersList) { AddRelayButton { - nav.nav(Route.EditRelays.base + "?toAdd=" + URLEncoder.encode(relay, StandardCharsets.UTF_8.toString())) + nav.nav(Route.EditRelays(relay)) } } else { RemoveRelayButton { - nav.nav(Route.EditRelays.base + "?toAdd=" + URLEncoder.encode(relay, StandardCharsets.UTF_8.toString())) + nav.nav(Route.EditRelays(relay)) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountScreen.kt index a69208190..0379b09c7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountScreen.kt @@ -20,54 +20,24 @@ */ package com.vitorpamplona.amethyst.ui.screen -import android.app.Activity import android.util.Log -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.Crossfade import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.ViewModelStore -import androidx.lifecycle.ViewModelStoreOwner -import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner -import androidx.lifecycle.viewmodel.compose.viewModel -import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.AccountSettings -import com.vitorpamplona.amethyst.ui.MainActivity -import com.vitorpamplona.amethyst.ui.components.getActivity -import com.vitorpamplona.amethyst.ui.navigation.AppNavigation -import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.LoggedInPage import com.vitorpamplona.amethyst.ui.screen.loggedOff.LoginOrSignupScreen import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.tor.TorManager -import com.vitorpamplona.amethyst.ui.tor.TorType -import com.vitorpamplona.quartz.nip55AndroidSigner.NostrSignerExternal -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch @Composable fun AccountScreen( @@ -76,222 +46,59 @@ fun AccountScreen( ) { val accountState by accountStateViewModel.accountContent.collectAsStateWithLifecycle() + Log.d("ManageRelayServices", "AccountScreen $accountState $accountStateViewModel") + Crossfade( targetState = accountState, animationSpec = tween(durationMillis = 100), - label = "AccountState", ) { state -> when (state) { - is AccountState.Loading -> { - // A surface container using the 'background' color from the theme - Surface( - modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background, - ) { - LoadingAccounts() - } - } - is AccountState.LoggedOff -> { // A surface container using the 'background' color from the theme - Surface( - modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background, - ) { - LoginOrSignupScreen(null, accountStateViewModel, isFirstLogin = true) - } - } - is AccountState.LoggedIn -> { - CompositionLocalProvider( - LocalViewModelStoreOwner provides state.currentViewModelStore, - ) { - LoggedInPage( - state.accountSettings, - state.route, - accountStateViewModel, - sharedPreferencesViewModel, - ) - } - - DisposableEffect(key1 = accountState) { - onDispose { - state.currentViewModelStore.viewModelStore.clear() - } - } - } + is AccountState.Loading -> LoadingSetup() + is AccountState.LoggedOff -> LoggedOffSetup(accountStateViewModel) + is AccountState.LoggedIn -> LoggedInSetup(state, accountStateViewModel, sharedPreferencesViewModel) } } } @Composable -fun LoggedInPage( - accountSettings: AccountSettings, - route: String?, +fun LoadingSetup() { + // A surface container using the 'background' color from the theme + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + Column( + Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text(stringRes(R.string.loading_account)) + } + } +} + +@Composable +fun LoggedOffSetup(accountStateViewModel: AccountStateViewModel) { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + LoginOrSignupScreen(null, accountStateViewModel, isFirstLogin = true) + } +} + +@Composable +fun LoggedInSetup( + state: AccountState.LoggedIn, accountStateViewModel: AccountStateViewModel, sharedPreferencesViewModel: SharedPreferencesViewModel, ) { - val accountViewModel: AccountViewModel = - viewModel( - key = "AccountViewModel", - factory = - AccountViewModel.Factory( - accountSettings, - sharedPreferencesViewModel.sharedPrefs, - ), + SetAccountCentricViewModelStore(state) { + LoggedInPage( + state.accountSettings, + state.route, + accountStateViewModel, + sharedPreferencesViewModel, ) - - accountViewModel.firstRoute = route - - LaunchedEffect(key1 = accountViewModel) { - accountViewModel.restartServices() - } - - ManageTorInstance(accountViewModel) - - ListenToExternalSignerIfNeeded(accountViewModel) - - AppNavigation( - accountViewModel = accountViewModel, - accountStateViewModel = accountStateViewModel, - sharedPreferencesViewModel = sharedPreferencesViewModel, - ) -} - -@Composable -fun ManageTorInstance(accountViewModel: AccountViewModel) { - val torSettings by accountViewModel.account.settings.torSettings.torType - .collectAsStateWithLifecycle() - if (torSettings == TorType.INTERNAL) { - ManageTorInstanceInner(accountViewModel) - } -} - -@Composable -fun ManageTorInstanceInner(accountViewModel: AccountViewModel) { - val context = LocalContext.current.applicationContext - val lifeCycleOwner = LocalLifecycleOwner.current - - val scope = rememberCoroutineScope() - var job = remember { null } - - DisposableEffect(key1 = accountViewModel) { - job?.cancel() - job = null - TorManager.startTorIfNotAlreadyOn(context) - - val observer = - LifecycleEventObserver { _, event -> - when (event) { - Lifecycle.Event.ON_RESUME -> { - job?.cancel() - job = null - TorManager.startTorIfNotAlreadyOn(context) - } - Lifecycle.Event.ON_PAUSE -> { - job = - scope.launch { - delay(5000) // 5 seconds - TorManager.stopTor(context) - } - } - else -> {} - } - } - - lifeCycleOwner.lifecycle.addObserver(observer) - onDispose { - lifeCycleOwner.lifecycle.removeObserver(observer) - TorManager.stopTor(context) - } - } -} - -@Composable -private fun ListenToExternalSignerIfNeeded(accountViewModel: AccountViewModel) { - if (accountViewModel.account.signer is NostrSignerExternal) { - val activity = getActivity() as MainActivity - - val lifeCycleOwner = LocalLifecycleOwner.current - val launcher = - rememberLauncherForActivityResult( - contract = ActivityResultContracts.StartActivityForResult(), - onResult = { result -> - if (result.resultCode != Activity.RESULT_OK) { - accountViewModel.toastManager.toast( - R.string.sign_request_rejected, - R.string.sign_request_rejected_description, - ) - } else { - result.data?.let { - accountViewModel.runOnIO { - accountViewModel.account.signer.launcher - .newResult(it) - } - } - } - }, - ) - - DisposableEffect(accountViewModel, accountViewModel.account, launcher, activity, lifeCycleOwner) { - val observer = - LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - accountViewModel.account.signer.launcher.registerLauncher( - launcher = { - try { - activity.prepareToLaunchSigner() - launcher.launch(it) - } catch (e: Exception) { - if (e is CancellationException) throw e - Log.e("Signer", "Error opening Signer app", e) - accountViewModel.toastManager.toast( - R.string.error_opening_external_signer, - R.string.error_opening_external_signer_description, - ) - } - }, - contentResolver = Amethyst.instance::contentResolverFn, - ) - } - } - - lifeCycleOwner.lifecycle.addObserver(observer) - accountViewModel.account.signer.launcher.registerLauncher( - launcher = { - try { - activity.prepareToLaunchSigner() - launcher.launch(it) - } catch (e: Exception) { - if (e is CancellationException) throw e - Log.e("Signer", "Error opening Signer app", e) - accountViewModel.toastManager.toast( - R.string.error_opening_external_signer, - R.string.error_opening_external_signer_description, - ) - } - }, - contentResolver = Amethyst.instance::contentResolverFn, - ) - onDispose { - accountViewModel.account.signer.launcher - .clearLauncher() - lifeCycleOwner.lifecycle.removeObserver(observer) - } - } - } -} - -class AccountCentricViewModelStore( - val accountSettings: AccountSettings, -) : ViewModelStoreOwner { - override val viewModelStore = ViewModelStore() -} - -@Composable -fun LoadingAccounts() { - Column( - Modifier.fillMaxHeight().fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - Text(stringRes(R.string.loading_account)) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountState.kt index e7d4f4746..1fb045e65 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountState.kt @@ -20,8 +20,15 @@ */ package com.vitorpamplona.amethyst.ui.screen +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.Stable +import androidx.lifecycle.ViewModelStore +import androidx.lifecycle.ViewModelStoreOwner +import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner import com.vitorpamplona.amethyst.model.AccountSettings +import com.vitorpamplona.amethyst.ui.navigation.Route sealed class AccountState { object Loading : AccountState() @@ -31,8 +38,30 @@ sealed class AccountState { @Stable class LoggedIn( val accountSettings: AccountSettings, - var route: String? = null, + var route: Route? = null, ) : AccountState() { - val currentViewModelStore = AccountCentricViewModelStore(accountSettings) + val currentViewModelStore = AccountCentricViewModelStore() } } + +@Composable +fun SetAccountCentricViewModelStore( + state: AccountState.LoggedIn, + content: @Composable () -> Unit, +) { + CompositionLocalProvider( + LocalViewModelStoreOwner provides state.currentViewModelStore, + ) { + content() + } + + DisposableEffect(key1 = state) { + onDispose { + state.currentViewModelStore.viewModelStore.clear() + } + } +} + +class AccountCentricViewModelStore : ViewModelStoreOwner { + override val viewModelStore = ViewModelStore() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt index 803d7f425..4b2f623ff 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt @@ -33,6 +33,7 @@ import com.vitorpamplona.amethyst.model.DefaultDMRelayList import com.vitorpamplona.amethyst.model.DefaultNIP65List import com.vitorpamplona.amethyst.model.DefaultSearchRelayList import com.vitorpamplona.amethyst.service.Nip05NostrAddressVerifier +import com.vitorpamplona.amethyst.ui.navigation.Route import com.vitorpamplona.amethyst.ui.tor.TorSettings import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow import com.vitorpamplona.ammolite.relays.Constants @@ -85,10 +86,14 @@ class AccountStateViewModel : ViewModel() { fun tryLoginExistingAccountAsync() { // pulls account from storage. - viewModelScope.launch { tryLoginExistingAccount() } + if (_accountContent.value !is AccountState.LoggedIn) { + viewModelScope.launch { + tryLoginExistingAccount() + } + } } - private suspend fun tryLoginExistingAccount(route: String? = null) = + private suspend fun tryLoginExistingAccount(route: Route? = null) = withContext(Dispatchers.IO) { LocalPreferences.loadCurrentAccountFromEncryptedStorage() }?.let { startUI(it, route) } ?: run { requestLoginUI() } @@ -96,7 +101,7 @@ class AccountStateViewModel : ViewModel() { private suspend fun requestLoginUI() { _accountContent.update { AccountState.LoggedOff } - viewModelScope.launch(Dispatchers.IO) { Amethyst.instance.serviceManager.pauseForGoodAndClearAccount() } + viewModelScope.launch(Dispatchers.IO) { Amethyst.instance.serviceManager.pauseAndLogOff() } } suspend fun loginAndStartUI( @@ -171,7 +176,7 @@ class AccountStateViewModel : ViewModel() { @OptIn(FlowPreview::class) suspend fun startUI( accountSettings: AccountSettings, - route: String? = null, + route: Route? = null, ) = withContext(Dispatchers.Main) { _accountContent.update { AccountState.LoggedIn(accountSettings, route) } @@ -179,7 +184,9 @@ class AccountStateViewModel : ViewModel() { collectorJob = viewModelScope.launch(Dispatchers.IO) { accountSettings.saveable.debounce(1000).collect { - LocalPreferences.saveToEncryptedStorage(it.accountSettings) + if (it.accountSettings != null) { + LocalPreferences.saveToEncryptedStorage(it.accountSettings) + } } } } @@ -227,7 +234,7 @@ class AccountStateViewModel : ViewModel() { } else if (EMAIL_PATTERN.matcher(key).matches()) { Nip05NostrAddressVerifier().verifyNip05( key, - forceProxy = { false }, + okttpClient = { Amethyst.instance.okHttpClients.getHttpClient(false) }, onSuccess = { publicKey -> loginSync(Hex.decode(publicKey).toNpub(), torSettings, transientAccount, loginWithExternalSigner, packageName, onError) }, @@ -326,7 +333,7 @@ class AccountStateViewModel : ViewModel() { suspend fun switchUserSync( npub: String, - route: String, + route: Route, ): Boolean { if (npub != LocalPreferences.currentAccount()) { val account = LocalPreferences.allSavedAccounts().firstOrNull { it.npub == npub } @@ -340,7 +347,7 @@ class AccountStateViewModel : ViewModel() { suspend fun switchUserSync( accountInfo: AccountInfo, - route: String? = null, + route: Route? = null, ) { prepareLogoutOrSwitch() LocalPreferences.switchToAccount(accountInfo) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FollowListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FollowListState.kt index c98795a6f..a7deab25f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FollowListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FollowListState.kt @@ -33,6 +33,7 @@ import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.checkNotInMainThread +import com.vitorpamplona.amethyst.ui.navigation.Route import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent @@ -168,6 +169,7 @@ class FollowListState( "Community/${communityNote.idHex}", CommunityName(communityNote), CodeNameType.ROUTE, + route = Route.Community(communityNote.idHex), kinds = DEFAULT_COMMUNITY_FEEDS, aTags = listOf(communityNote.idHex), relays = account.activeGlobalRelays().toList(), @@ -181,6 +183,7 @@ class FollowListState( "Hashtag/$it", HashtagName(it), CodeNameType.ROUTE, + route = Route.Hashtag(it), kinds = DEFAULT_FEED_KINDS, tTags = listOf(it), relays = account.activeGlobalRelays().toList(), @@ -193,6 +196,7 @@ class FollowListState( "Geohash/$it", GeoHashName(it), CodeNameType.ROUTE, + route = Route.Geohash(it), kinds = DEFAULT_FEED_KINDS, gTags = listOf(it), relays = account.activeGlobalRelays().toList(), @@ -298,6 +302,7 @@ abstract class FeedDefinition( val code: String, val name: Name, val type: CodeNameType, + val route: Route?, ) @Immutable @@ -307,13 +312,14 @@ class GlobalFeedDefinition( type: CodeNameType, val kinds: List, val relays: List, -) : FeedDefinition(code, name, type) +) : FeedDefinition(code, name, type, null) @Immutable class TagFeedDefinition( code: String, name: Name, type: CodeNameType, + route: Route?, val kinds: List, val relays: List, val pTags: List? = null, @@ -321,7 +327,7 @@ class TagFeedDefinition( val aTags: List? = null, val tTags: List? = null, val gTags: List? = null, -) : FeedDefinition(code, name, type) +) : FeedDefinition(code, name, type, route) @Immutable class AroundMeFeedDefinition( @@ -329,7 +335,7 @@ class AroundMeFeedDefinition( name: Name, type: CodeNameType, val kinds: List, -) : FeedDefinition(code, name, type) +) : FeedDefinition(code, name, type, null) @Immutable class PeopleListOutBoxFeedDefinition( @@ -338,7 +344,7 @@ class PeopleListOutBoxFeedDefinition( type: CodeNameType, val kinds: List, val unpackList: List, -) : FeedDefinition(code, name, type) +) : FeedDefinition(code, name, type, null) val DEFAULT_FEED_KINDS = listOf( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/AppScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/SharedPreferencesComposables.kt similarity index 68% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/AppScreen.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/SharedPreferencesComposables.kt index b231f0bae..769977210 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/AppScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/SharedPreferencesComposables.kt @@ -18,35 +18,55 @@ * 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 +package com.vitorpamplona.amethyst.ui.screen import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.google.accompanist.adaptive.calculateDisplayFeatures -import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.service.connectivity.ConnectivityStatus +import com.vitorpamplona.amethyst.ui.components.getActivity -@OptIn(ExperimentalMaterial3WindowSizeClassApi::class) @Composable -fun prepareSharedViewModel(act: MainActivity): SharedPreferencesViewModel { +fun prepareSharedViewModel(): SharedPreferencesViewModel { val sharedPreferencesViewModel: SharedPreferencesViewModel = viewModel() - val displayFeatures = calculateDisplayFeatures(act) - val windowSizeClass = calculateWindowSizeClass(act) - LaunchedEffect(key1 = sharedPreferencesViewModel) { sharedPreferencesViewModel.init() } - LaunchedEffect(sharedPreferencesViewModel, displayFeatures, windowSizeClass) { - sharedPreferencesViewModel.updateDisplaySettings(windowSizeClass, displayFeatures) - } - - LaunchedEffect(act.isOnMobileDataState) { - sharedPreferencesViewModel.updateConnectivityStatusState(act.isOnMobileDataState) - } + MonitorDisplaySize(sharedPreferencesViewModel) + ManageConnectivity(sharedPreferencesViewModel) return sharedPreferencesViewModel } + +@OptIn(ExperimentalMaterial3WindowSizeClassApi::class) +@Composable +fun MonitorDisplaySize(sharedPreferencesViewModel: SharedPreferencesViewModel) { + val act = LocalContext.current.getActivity() + + val displayFeatures = calculateDisplayFeatures(act) + val windowSizeClass = calculateWindowSizeClass(act) + + LaunchedEffect(sharedPreferencesViewModel, displayFeatures, windowSizeClass) { + sharedPreferencesViewModel.updateDisplaySettings(windowSizeClass, displayFeatures) + } +} + +@Composable +fun ManageConnectivity(sharedPreferencesViewModel: SharedPreferencesViewModel) { + val status = + Amethyst.instance.connManager.status + .collectAsStateWithLifecycle() + + (status.value as? ConnectivityStatus.Active)?.let { + sharedPreferencesViewModel.updateNetworkState(it.networkId) + sharedPreferencesViewModel.updateConnectivityStatusState(it.isMobile) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/SharedPreferencesViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/SharedPreferencesViewModel.kt index 3294969b9..2a9959ca0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/SharedPreferencesViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/SharedPreferencesViewModel.kt @@ -20,15 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen +import android.util.Log import androidx.appcompat.app.AppCompatDelegate import androidx.compose.material3.windowsizeclass.WindowSizeClass import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable -import androidx.compose.runtime.State -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue import androidx.core.os.LocaleListCompat import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -44,77 +40,13 @@ import com.vitorpamplona.amethyst.model.ThemeType import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -@Stable -class SettingsState { - var theme by mutableStateOf(ThemeType.SYSTEM) - var language by mutableStateOf(null) - - var automaticallyShowImages by mutableStateOf(ConnectivityType.ALWAYS) - var automaticallyStartPlayback by mutableStateOf(ConnectivityType.ALWAYS) - var automaticallyShowUrlPreview by mutableStateOf(ConnectivityType.ALWAYS) - var automaticallyHideNavigationBars by mutableStateOf(BooleanType.ALWAYS) - var automaticallyShowProfilePictures by mutableStateOf(ConnectivityType.ALWAYS) - var dontShowPushNotificationSelector by mutableStateOf(false) - var dontAskForNotificationPermissions by mutableStateOf(false) - var featureSet by mutableStateOf(FeatureSetType.SIMPLIFIED) - var gallerySet by mutableStateOf(ProfileGalleryType.CLASSIC) - - var isOnMobileData: State = mutableStateOf(false) - - var windowSizeClass = mutableStateOf(null) - var displayFeatures = mutableStateOf>(emptyList()) - - val showProfilePictures = - derivedStateOf { - when (automaticallyShowProfilePictures) { - ConnectivityType.WIFI_ONLY -> !isOnMobileData.value - ConnectivityType.NEVER -> false - ConnectivityType.ALWAYS -> true - } - } - - val modernGalleryStyle = - derivedStateOf { - when (gallerySet) { - ProfileGalleryType.CLASSIC -> false - ProfileGalleryType.MODERN -> true - } - } - - val showUrlPreview = - derivedStateOf { - when (automaticallyShowUrlPreview) { - ConnectivityType.WIFI_ONLY -> !isOnMobileData.value - ConnectivityType.NEVER -> false - ConnectivityType.ALWAYS -> true - } - } - - val startVideoPlayback = - derivedStateOf { - when (automaticallyStartPlayback) { - ConnectivityType.WIFI_ONLY -> !isOnMobileData.value - ConnectivityType.NEVER -> false - ConnectivityType.ALWAYS -> true - } - } - - val showImages = - derivedStateOf { - when (automaticallyShowImages) { - ConnectivityType.WIFI_ONLY -> !isOnMobileData.value - ConnectivityType.NEVER -> false - ConnectivityType.ALWAYS -> true - } - } -} - @Stable class SharedPreferencesViewModel : ViewModel() { - val sharedPrefs: SettingsState = SettingsState() + val sharedPrefs: SharedSettingsState = SharedSettingsState() fun init() { viewModelScope.launch(Dispatchers.IO) { + Log.d("SharedPreferencesViewModel", "init") val savedSettings = LocalPreferences.loadSharedSettings() ?: Settings() @@ -223,9 +155,17 @@ class SharedPreferencesViewModel : ViewModel() { } } - fun updateConnectivityStatusState(isOnMobileDataState: State) { - if (sharedPrefs.isOnMobileData != isOnMobileDataState) { - sharedPrefs.isOnMobileData = isOnMobileDataState + fun updateConnectivityStatusState(isOnMobileDataState: Boolean) { + if (sharedPrefs.isOnMobileOrMeteredConnection != isOnMobileDataState) { + Log.d("Connectivity", "updateConnectivityStatusState ${sharedPrefs.currentNetworkId}: ${sharedPrefs.isOnMobileOrMeteredConnection} -> $isOnMobileDataState") + sharedPrefs.isOnMobileOrMeteredConnection = isOnMobileDataState + } + } + + fun updateNetworkState(networkId: Long) { + if (sharedPrefs.currentNetworkId != networkId) { + Log.d("Connectivity", "updateNetworkState ${sharedPrefs.currentNetworkId} -> $networkId") + sharedPrefs.currentNetworkId = networkId } } @@ -243,6 +183,7 @@ class SharedPreferencesViewModel : ViewModel() { fun saveSharedSettings() { viewModelScope.launch(Dispatchers.IO) { + Log.d("SharedPreferencesViewModel", "Saving Shared Settings") LocalPreferences.saveSharedSettings( Settings( sharedPrefs.theme, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/SharedSettingsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/SharedSettingsState.kt new file mode 100644 index 000000000..8dc707f78 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/SharedSettingsState.kt @@ -0,0 +1,100 @@ +/** + * 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.screen + +import androidx.compose.material3.windowsizeclass.WindowSizeClass +import androidx.compose.runtime.Stable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.window.layout.DisplayFeature +import com.vitorpamplona.amethyst.model.BooleanType +import com.vitorpamplona.amethyst.model.ConnectivityType +import com.vitorpamplona.amethyst.model.FeatureSetType +import com.vitorpamplona.amethyst.model.ProfileGalleryType +import com.vitorpamplona.amethyst.model.ThemeType + +@Stable +class SharedSettingsState { + var theme by mutableStateOf(ThemeType.SYSTEM) + var language by mutableStateOf(null) + + var automaticallyShowImages by mutableStateOf(ConnectivityType.ALWAYS) + var automaticallyStartPlayback by mutableStateOf(ConnectivityType.ALWAYS) + var automaticallyShowUrlPreview by mutableStateOf(ConnectivityType.ALWAYS) + var automaticallyHideNavigationBars by mutableStateOf(BooleanType.ALWAYS) + var automaticallyShowProfilePictures by mutableStateOf(ConnectivityType.ALWAYS) + var dontShowPushNotificationSelector by mutableStateOf(false) + var dontAskForNotificationPermissions by mutableStateOf(false) + var featureSet by mutableStateOf(FeatureSetType.SIMPLIFIED) + var gallerySet by mutableStateOf(ProfileGalleryType.CLASSIC) + + var isOnMobileOrMeteredConnection by mutableStateOf(false) + var currentNetworkId by mutableStateOf(0L) + + var windowSizeClass = mutableStateOf(null) + var displayFeatures = mutableStateOf>(emptyList()) + + val showProfilePictures = + derivedStateOf { + when (automaticallyShowProfilePictures) { + ConnectivityType.WIFI_ONLY -> !isOnMobileOrMeteredConnection + ConnectivityType.NEVER -> false + ConnectivityType.ALWAYS -> true + } + } + + val modernGalleryStyle = + derivedStateOf { + when (gallerySet) { + ProfileGalleryType.CLASSIC -> false + ProfileGalleryType.MODERN -> true + } + } + + val showUrlPreview = + derivedStateOf { + when (automaticallyShowUrlPreview) { + ConnectivityType.WIFI_ONLY -> !isOnMobileOrMeteredConnection + ConnectivityType.NEVER -> false + ConnectivityType.ALWAYS -> true + } + } + + val startVideoPlayback = + derivedStateOf { + when (automaticallyStartPlayback) { + ConnectivityType.WIFI_ONLY -> !isOnMobileOrMeteredConnection + ConnectivityType.NEVER -> false + ConnectivityType.ALWAYS -> true + } + } + + val showImages = + derivedStateOf { + when (automaticallyShowImages) { + ConnectivityType.WIFI_ONLY -> !isOnMobileOrMeteredConnection + ConnectivityType.NEVER -> false + ConnectivityType.ALWAYS -> true + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedState.kt index ceea2886e..e54ddce58 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedState.kt @@ -20,15 +20,15 @@ */ package com.vitorpamplona.amethyst.ui.screen -import androidx.compose.runtime.MutableState import com.vitorpamplona.amethyst.model.User import kotlinx.collections.immutable.ImmutableList +import kotlinx.coroutines.flow.MutableStateFlow sealed class UserFeedState { object Loading : UserFeedState() class Loaded( - val feed: MutableState>, + val feed: MutableStateFlow>, ) : UserFeedState() object Empty : UserFeedState() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedView.kt index 27f7ceb1f..23240af96 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedView.kt @@ -81,13 +81,14 @@ private fun FeedLoaded( accountViewModel: AccountViewModel, nav: INav, ) { + val items by state.feed.collectAsStateWithLifecycle() val listState = rememberLazyListState() LazyColumn( contentPadding = FeedPadding, state = listState, ) { - itemsIndexed(state.feed.value, key = { _, item -> item.pubkeyHex }) { _, item -> + itemsIndexed(items, key = { _, item -> item.pubkeyHex }) { _, item -> UserCompose(item, accountViewModel = accountViewModel, nav = nav) HorizontalDivider( thickness = DividerThickness, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedViewModel.kt index 3027ee01a..ad50a9b12 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedViewModel.kt @@ -43,7 +43,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch class NostrHiddenAccountsFeedViewModel( @@ -103,16 +102,14 @@ open class UserFeedViewModel( } private fun updateFeed(notes: ImmutableList) { - viewModelScope.launch(Dispatchers.Main) { - val currentState = _feedContent.value - if (notes.isEmpty()) { - _feedContent.update { UserFeedState.Empty } - } else if (currentState is UserFeedState.Loaded) { - // updates the current list - currentState.feed.value = notes - } else { - _feedContent.update { UserFeedState.Loaded(mutableStateOf(notes)) } - } + val currentState = _feedContent.value + if (notes.isEmpty()) { + _feedContent.tryEmit(UserFeedState.Empty) + } else if (currentState is UserFeedState.Loaded) { + // updates the current list + currentState.feed.tryEmit(notes) + } else { + _feedContent.tryEmit(UserFeedState.Loaded(MutableStateFlow(notes))) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 767192e35..53ed061a3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -57,6 +57,7 @@ import com.vitorpamplona.amethyst.service.OnlineChecker import com.vitorpamplona.amethyst.service.ZapPaymentHandler import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver +import com.vitorpamplona.amethyst.service.proxyPort.ProxyPortFlow import com.vitorpamplona.amethyst.ui.actions.Dao import com.vitorpamplona.amethyst.ui.components.UrlPreviewState import com.vitorpamplona.amethyst.ui.components.toasts.ToastManager @@ -67,8 +68,8 @@ import com.vitorpamplona.amethyst.ui.note.ZapAmountCommentNotification import com.vitorpamplona.amethyst.ui.note.ZapraiserStatus import com.vitorpamplona.amethyst.ui.note.showAmount import com.vitorpamplona.amethyst.ui.note.showAmountInteger -import com.vitorpamplona.amethyst.ui.screen.SettingsState import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel +import com.vitorpamplona.amethyst.ui.screen.SharedSettingsState import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.CardFeedState import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.CombinedZap import com.vitorpamplona.amethyst.ui.stringRes @@ -136,16 +137,32 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient @Stable class AccountViewModel( accountSettings: AccountSettings, - val settings: SettingsState, + val settings: SharedSettingsState, ) : ViewModel(), Dao { val account = Account(accountSettings, accountSettings.createSigner(), viewModelScope) - var firstRoute: String? = null + val proxyPortLogic = + ProxyPortFlow( + account.settings.torSettings.torType, + account.settings.torSettings.externalSocksPort, + Amethyst.instance.torManager.status, + ).status.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(30000), + ProxyPortFlow.computePort( + account.settings.torSettings.torType.value, + account.settings.torSettings.externalSocksPort.value, + Amethyst.instance.torManager.status.value, + ), + ) + + var firstRoute: Route? = null // TODO: contact lists are not notes yet // val kind3Relays: StateFlow = observeByAuthor(ContactListEvent.KIND, account.signer.pubKey) @@ -693,7 +710,7 @@ class AccountViewModel( message = message, context = context, showErrorIfNoLnAddress = showErrorIfNoLnAddress, - forceProxy = account::shouldUseTorForMoneyOperations, + okHttpClient = ::okHttpClientForMoney, onError = onError, onProgress = { onProgress(it) @@ -921,7 +938,7 @@ class AccountViewModel( ) { viewModelScope.launch(Dispatchers.IO) { if (account.updateOptOutOptions(warnReports, filterSpam)) { - LocalCache.antiSpam.active = filterSpamFromStrangers() + LocalCache.antiSpam.active = filterSpamFromStrangers().value } } } @@ -980,7 +997,7 @@ class AccountViewModel( onResult: suspend (UrlPreviewState) -> Unit, ) { viewModelScope.launch(Dispatchers.IO) { - UrlCachedPreviewer.previewInfo(url, account.shouldUseTorForPreviewUrl(url), onResult) + UrlCachedPreviewer.previewInfo(url, ::okHttpClientForPreview, onResult) } } @@ -1001,7 +1018,9 @@ class AccountViewModel( Nip05NostrAddressVerifier() .verifyNip05( nip05, - forceProxy = account::shouldUseTorForNIP05, + okttpClient = { + Amethyst.instance.okHttpClients.getHttpClient(account.shouldUseTorForNIP05(it)) + }, onSuccess = { // Marks user as verified if (it == pubkeyHex) { @@ -1038,7 +1057,7 @@ class AccountViewModel( viewModelScope.launch(Dispatchers.IO) { Nip11CachedRetriever.loadRelayInfo( dirtyUrl, - account.shouldUseTorForDirty(dirtyUrl), + okHttpClient = ::okHttpClientForDirty, onInfo, onError, ) @@ -1128,7 +1147,7 @@ class AccountViewModel( ) { onResult( withContext(Dispatchers.Default) { - LocalCache.findEarliestOtsForNote(note) + LocalCache.findEarliestOtsForNote(note, account::otsResolver) }, ) } @@ -1215,7 +1234,9 @@ class AccountViewModel( onDone: (Boolean) -> Unit, ) { viewModelScope.launch(Dispatchers.IO) { - onDone(OnlineChecker.isOnline(videoUrl, account.shouldUseTorForVideoDownload(videoUrl))) + onDone( + OnlineChecker.isOnline(videoUrl, ::okHttpClientForVideo), + ) } } @@ -1248,7 +1269,11 @@ class AccountViewModel( note.event?.createdAt?.let { date -> val route = routeFor(note, accountViewModel.account.userProfile()) route?.let { - account.markAsRead(route, date) + if (route is Route.Room) { + account.markAsRead("Room/${route.id}", date) + } else if (route is Route.Channel) { + account.markAsRead("Channel/${route.id}", date) + } } } } @@ -1268,24 +1293,44 @@ class AccountViewModel( } } - fun setTorSettings(newTorSettings: TorSettings) { + fun setTorSettings(newTorSettings: TorSettings) = viewModelScope.launch(Dispatchers.IO) { // Only restart relay connections if port or type changes if (account.settings.setTorSettings(newTorSettings)) { Amethyst.instance.serviceManager.forceRestart() } } - } - fun restartServices() { + fun forceRestartServices() = viewModelScope.launch(Dispatchers.IO) { - Amethyst.instance.serviceManager.restartIfDifferentAccount(account) + Amethyst.instance.serviceManager.setAccountAndRestart(account) + } + + fun justStart() = + viewModelScope.launch(Dispatchers.IO) { + Amethyst.instance.serviceManager.justStartIfItHasAccount() + } + + fun justPause() = + viewModelScope.launch(Dispatchers.IO) { + Amethyst.instance.serviceManager.cleanObservers() + Amethyst.instance.serviceManager.pauseForGood() + } + + fun pauseAndLogOff() = + viewModelScope.launch(Dispatchers.IO) { + Amethyst.instance.serviceManager.cleanObservers() + Amethyst.instance.serviceManager.pauseAndLogOff() + } + + fun changeProxyPort(port: Int) = + viewModelScope.launch(Dispatchers.IO) { + Amethyst.instance.serviceManager.forceRestart() } - } class Factory( val accountSettings: AccountSettings, - val settings: SettingsState, + val settings: SharedSettingsState, ) : ViewModelProvider.Factory { override fun create(modelClass: Class): AccountViewModel = AccountViewModel(accountSettings, settings) as AccountViewModel } @@ -1399,7 +1444,7 @@ class AccountViewModel( .melt( token, lud16, - forceProxy = account::shouldUseTorForMoneyOperations, + okHttpClient = ::okHttpClientForMoney, onSuccess = { title, message -> onDone(title, message) }, onError = { title, message -> onDone(title, message) }, context, @@ -1491,6 +1536,22 @@ class AccountViewModel( } } + fun proxyPortFor(url: String): Int? = Amethyst.instance.okHttpClients.getCurrentProxyPort(account.shouldUseTorForVideoDownload(url)) + + fun okHttpClientForNip96(url: String): OkHttpClient = Amethyst.instance.okHttpClients.getHttpClient(account.shouldUseTorForNIP96(url)) + + fun okHttpClientForImage(url: String): OkHttpClient = Amethyst.instance.okHttpClients.getHttpClient(account.shouldUseTorForImageDownload()) + + fun okHttpClientForVideo(url: String): OkHttpClient = Amethyst.instance.okHttpClients.getHttpClient(account.shouldUseTorForVideoDownload(url)) + + fun okHttpClientForMoney(url: String): OkHttpClient = Amethyst.instance.okHttpClients.getHttpClient(account.shouldUseTorForMoneyOperations(url)) + + fun okHttpClientForPreview(url: String): OkHttpClient = Amethyst.instance.okHttpClients.getHttpClient(account.shouldUseTorForPreviewUrl(url)) + + fun okHttpClientForDirty(url: String): OkHttpClient = Amethyst.instance.okHttpClients.getHttpClient(account.shouldUseTorForDirty(url)) + + fun okHttpClientForTrustedRelays(url: String): OkHttpClient = Amethyst.instance.okHttpClients.getHttpClient(account.shouldUseTorForTrustedRelays()) + suspend fun deleteDraft(draftTag: String) { account.deleteDraft(draftTag) } @@ -1607,7 +1668,7 @@ class AccountViewModel( milliSats, message, null, - forceProxy = account::shouldUseTorForMoneyOperations, + okHttpClient = ::okHttpClientForMoney, onSuccess = onSuccess, onError = onError, onProgress = onProgress, @@ -1622,7 +1683,7 @@ class AccountViewModel( milliSats, message, zapRequest.toJson(), - forceProxy = account::shouldUseTorForMoneyOperations, + okHttpClient = ::okHttpClientForMoney, onSuccess = onSuccess, onError = onError, onProgress = onProgress, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoadRedirectScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoadRedirectScreen.kt index 796a5aa02..aa0a44e14 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoadRedirectScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoadRedirectScreen.kt @@ -157,15 +157,15 @@ fun redirect( } } else { if (event is ChannelCreateEvent) { - nav.popUpTo("Channel/${event.id}", Route.Event.route) + nav.popUpTo(Route.Channel(event.id), Route.EventRedirect::class) } else if (event is ChatroomKeyable) { val withKey = event.chatroomKey(accountViewModel.userProfile().pubkeyHex) accountViewModel.userProfile().createChatroom(withKey) - nav.popUpTo("Room/${withKey.hashCode()}", Route.Event.route) + nav.popUpTo(Route.Room(withKey.hashCode()), Route.EventRedirect::class) } else if (channelHex != null) { - nav.popUpTo("Channel/$channelHex", Route.Event.route) + nav.popUpTo(Route.Channel(channelHex), Route.EventRedirect::class) } else { - nav.popUpTo("Note/${event.id}", Route.Event.route) + nav.popUpTo(Route.Note(event.id), Route.EventRedirect::class) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt new file mode 100644 index 000000000..35b99b6eb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt @@ -0,0 +1,272 @@ +/** + * 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.screen.loggedIn + +import android.app.Activity +import android.util.Log +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LifecycleResumeEffect +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.LocalPreferences +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.AccountSettings +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.notifications.PushNotificationUtils +import com.vitorpamplona.amethyst.ui.MainActivity +import com.vitorpamplona.amethyst.ui.components.getActivity +import com.vitorpamplona.amethyst.ui.navigation.AppNavigation +import com.vitorpamplona.amethyst.ui.navigation.Route +import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel +import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel +import com.vitorpamplona.amethyst.ui.tor.TorServiceStatus +import com.vitorpamplona.amethyst.ui.tor.TorType +import com.vitorpamplona.quartz.nip55AndroidSigner.NostrSignerExternal +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +@Composable +fun LoggedInPage( + accountSettings: AccountSettings, + route: Route?, + accountStateViewModel: AccountStateViewModel, + sharedPreferencesViewModel: SharedPreferencesViewModel, +) { + val accountViewModel: AccountViewModel = + viewModel( + key = "AccountViewModel", + factory = + AccountViewModel.Factory( + accountSettings, + sharedPreferencesViewModel.sharedPrefs, + ), + ) + + Log.d("ManageRelayServices", "LoggedInPage $accountViewModel") + + accountViewModel.firstRoute = route + + ManageSpamFilters(accountViewModel) + + ManageRelayServices(accountViewModel, sharedPreferencesViewModel) + + ManageTorInstance(accountViewModel) + + ListenToExternalSignerIfNeeded(accountViewModel) + + NotificationRegistration(accountViewModel) + + AppNavigation( + accountViewModel = accountViewModel, + accountStateViewModel = accountStateViewModel, + sharedPreferencesViewModel = sharedPreferencesViewModel, + ) +} + +@Composable +fun ManageSpamFilters(accountViewModel: AccountViewModel) { + val isSpamActive by accountViewModel.account.settings.syncedSettings.security.filterSpamFromStrangers + .collectAsStateWithLifecycle(true) + + LocalCache.antiSpam.active = isSpamActive +} + +@Composable +fun ManageRelayServices( + accountViewModel: AccountViewModel, + sharedPreferencesViewModel: SharedPreferencesViewModel, +) { + LaunchedEffect( + sharedPreferencesViewModel.sharedPrefs.currentNetworkId, + sharedPreferencesViewModel.sharedPrefs.isOnMobileOrMeteredConnection, + ) { + Log.d("ManageRelayServices", "Loading/Change Network Id/State ${sharedPreferencesViewModel.sharedPrefs.currentNetworkId}, forcing start/restart of the relay services") + accountViewModel.forceRestartServices() + } + + val lifeCycleOwner = LocalLifecycleOwner.current + + val scope = rememberCoroutineScope() + var job = remember { null } + + Log.d("ManageRelayServices", "Job $job for $accountViewModel") + + DisposableEffect(key1 = accountViewModel) { + job?.cancel() + val observer = + LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_RESUME -> { + job?.cancel() + Log.d("ManageRelayServices", "Resuming Relay Services $accountViewModel") + job = accountViewModel.justStart() + } + Lifecycle.Event.ON_PAUSE -> { + Log.d("ManageRelayServices", "Prepare to pause Relay Services $accountViewModel") + job?.cancel() + job = + scope.launch { + delay(30000) // 30 seconds + Log.d("ManageRelayServices", "Pausing Relay Services $accountViewModel") + accountViewModel.justPause() + } + } + else -> {} + } + } + + lifeCycleOwner.lifecycle.addObserver(observer) + onDispose { + job?.cancel() + lifeCycleOwner.lifecycle.removeObserver(observer) + Log.d("ManageRelayServices", "Disposing Relay Services $accountViewModel") + // immediately stops upon disposal + accountViewModel.pauseAndLogOff() + } + } +} + +@Composable +fun NotificationRegistration(accountViewModel: AccountViewModel) { + val scope = rememberCoroutineScope() + var job = remember { null } + + LifecycleResumeEffect(key1 = accountViewModel) { + Log.d("RegisterAccounts", "Registering for push notifications") + job?.cancel() + job = + scope.launch { + PushNotificationUtils.checkAndInit(LocalPreferences.allSavedAccounts(), accountViewModel::okHttpClientForTrustedRelays) + } + + onPauseOrDispose { + job.cancel() + } + } +} + +@Composable +fun ManageTorInstance(accountViewModel: AccountViewModel) { + val torSettings by accountViewModel.account.settings.torSettings.torType + .collectAsStateWithLifecycle() + if (torSettings == TorType.INTERNAL) { + WatchTorConnection(accountViewModel) + } +} + +@Composable +fun WatchTorConnection(accountViewModel: AccountViewModel) { + val status by Amethyst.instance.torManager.status + .collectAsStateWithLifecycle() + + if (status is TorServiceStatus.Active) { + LaunchedEffect(key1 = status, key2 = accountViewModel) { + Log.d("TorService", "Tor has just finished connecting, force restart relays $accountViewModel") + accountViewModel.changeProxyPort((status as TorServiceStatus.Active).port) + } + } +} + +@Composable +private fun ListenToExternalSignerIfNeeded(accountViewModel: AccountViewModel) { + if (accountViewModel.account.signer is NostrSignerExternal) { + val activity = getActivity() as MainActivity + + val lifeCycleOwner = LocalLifecycleOwner.current + val launcher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult(), + onResult = { result -> + if (result.resultCode != Activity.RESULT_OK) { + accountViewModel.toastManager.toast( + R.string.sign_request_rejected, + R.string.sign_request_rejected_description, + ) + } else { + result.data?.let { + accountViewModel.runOnIO { + accountViewModel.account.signer.launcher + .newResult(it) + } + } + } + }, + ) + + DisposableEffect(accountViewModel, accountViewModel.account, launcher, activity, lifeCycleOwner) { + val observer = + LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + accountViewModel.account.signer.launcher.registerLauncher( + launcher = { + try { + launcher.launch(it) + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("Signer", "Error opening Signer app", e) + accountViewModel.toastManager.toast( + R.string.error_opening_external_signer, + R.string.error_opening_external_signer_description, + ) + } + }, + contentResolver = Amethyst.instance::contentResolverFn, + ) + } + } + + lifeCycleOwner.lifecycle.addObserver(observer) + accountViewModel.account.signer.launcher.registerLauncher( + launcher = { + try { + launcher.launch(it) + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("Signer", "Error opening Signer app", e) + accountViewModel.toastManager.toast( + R.string.error_opening_external_signer, + R.string.error_opening_external_signer_description, + ) + } + }, + contentResolver = Amethyst.instance::contentResolverFn, + ) + onDispose { + accountViewModel.account.signer.launcher + .clearLauncher() + lifeCycleOwner.lifecycle.removeObserver(observer) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NewPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NewPostScreen.kt index bba53539d..985cf68a3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NewPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NewPostScreen.kt @@ -159,7 +159,7 @@ fun NewPostScreen( nav: Nav, ) { val postViewModel: NewPostViewModel = viewModel() - postViewModel.account = accountViewModel.account + postViewModel.init(accountViewModel) postViewModel.wantsToAddGeoHash = enableGeolocation val context = LocalContext.current @@ -183,7 +183,7 @@ fun NewPostScreen( LaunchedEffect(Unit) { launch(Dispatchers.IO) { - postViewModel.load(accountViewModel, baseReplyTo, quote, fork, version, draft) + postViewModel.load(baseReplyTo, quote, fork, version, draft) message?.ifBlank { null }?.let { postViewModel.updateMessage(TextFieldValue(it)) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt index 005a37325..6ba0a17f8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt @@ -45,6 +45,8 @@ import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.model.FeatureSetType import com.vitorpamplona.amethyst.model.Note 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.DisplayDraftChat import com.vitorpamplona.amethyst.ui.note.LikeReaction import com.vitorpamplona.amethyst.ui.note.NoteQuickActionMenu @@ -168,7 +170,7 @@ fun NormalChatNote( parentBackgroundColor = parentBackgroundColor, onClick = { if (note.event is ChannelCreateEvent) { - nav.nav("Channel/${note.idHex}") + nav.nav(Route.Channel(note.idHex)) true } else { false @@ -176,7 +178,7 @@ fun NormalChatNote( }, onAuthorClick = { note.author?.let { - nav.nav("User/${it.pubkeyHex}") + nav.nav(routeFor(it)) } }, actionMenu = { onDismiss -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderEncryptedFile.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderEncryptedFile.kt index 32fdcb8c2..fc0de62ca 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderEncryptedFile.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderEncryptedFile.kt @@ -28,13 +28,13 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent import com.vitorpamplona.amethyst.commons.richtext.EncryptedMediaUrlImage import com.vitorpamplona.amethyst.commons.richtext.EncryptedMediaUrlVideo import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.service.okhttp.HttpClientManager import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.components.ZoomableContentView import com.vitorpamplona.amethyst.ui.navigation.INav @@ -61,7 +61,7 @@ fun RenderEncryptedFile( val mimeType = noteEvent.mimeType() if (algo == AESGCM.NAME && key != null && nonce != null) { - HttpClientManager.addCipherToCache(noteEvent.content, AESGCM(key, nonce), mimeType) + Amethyst.instance.keyCache.add(noteEvent.content, AESGCM(key, nonce), mimeType) val content by remember(noteEvent) { val isImage = mimeType?.startsWith("image/") == true || RichTextParser.isImageUrl(noteEvent.content) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt index 9c71b4d24..2d0729965 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt @@ -655,7 +655,7 @@ class ChatNewMessageViewModel : viewModelScope.launch(Dispatchers.IO) { iMetaAttachments.downloadAndPrepare( item.url.url, - accountViewModel?.account?.shouldUseTorForImageDownload() ?: false, + { Amethyst.instance.okHttpClients.getHttpClient(accountViewModel?.account?.shouldUseTorForImageDownload() ?: false) }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/IMetaAttachments.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/IMetaAttachments.kt index 441b38460..65a8e2eb1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/IMetaAttachments.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/IMetaAttachments.kt @@ -38,6 +38,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.mimeType import com.vitorpamplona.quartz.nip94FileMetadata.originalHash import com.vitorpamplona.quartz.nip94FileMetadata.sensitiveContent import com.vitorpamplona.quartz.nip94FileMetadata.size +import okhttp3.OkHttpClient import java.util.Locale class IMetaAttachments { @@ -45,13 +46,13 @@ class IMetaAttachments { suspend fun downloadAndPrepare( url: String, - forceProxy: Boolean, + okHttpClient: (String) -> OkHttpClient, ) { val fileExtension: String = MimeTypeMap.getFileExtensionFromUrl(url) val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(fileExtension.lowercase(Locale.getDefault())) val imeta = - FileHeader.prepare(url, mimeType, null, forceProxy).getOrNull()?.let { + FileHeader.prepare(url, mimeType, null, okHttpClient).getOrNull()?.let { IMetaTagBuilder(url) .apply { hash(it.hash) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt index b100d039f..e78f36ff9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt @@ -74,8 +74,10 @@ import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.core.net.toUri import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent import com.vitorpamplona.amethyst.commons.richtext.EncryptedMediaUrlImage @@ -84,7 +86,6 @@ import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource -import com.vitorpamplona.amethyst.service.okhttp.HttpClientManager import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery @@ -137,6 +138,17 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +@OptIn(ExperimentalMaterial3Api::class, FlowPreview::class) +@Composable +fun NewGroupDMScreen( + message: String? = null, + attachment: String? = null, + accountViewModel: AccountViewModel, + nav: Nav, +) { + NewGroupDMScreen(message, attachment?.ifBlank { null }?.toUri(), accountViewModel, nav) +} + @OptIn(ExperimentalMaterial3Api::class, FlowPreview::class) @Composable fun NewGroupDMScreen( @@ -587,7 +599,7 @@ fun ShowImageUploadGallery( val isImage = data.result.mimeTypeBeforeEncryption?.startsWith("image/") == true || RichTextParser.isImageUrl(data.result.url) if (data.cipher != null) { - HttpClientManager.addCipherToCache(data.result.url, data.cipher, data.result.mimeTypeBeforeEncryption) + Amethyst.instance.keyCache.add(data.result.url, data.cipher, data.result.mimeTypeBeforeEncryption) } val content by remember(data) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/EditChatButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/EditChatButton.kt index a1e50c8b2..5f1a1d652 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/EditChatButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/EditChatButton.kt @@ -35,6 +35,7 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.PublicChatChannel import com.vitorpamplona.amethyst.ui.navigation.EmptyNav.nav import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ZeroPadding @@ -50,7 +51,7 @@ fun EditButton( Modifier .padding(horizontal = 3.dp) .width(50.dp), - onClick = { nav.nav("ChannelMetadataEdit?id=${channel.idHex}") }, + onClick = { nav.nav(Route.ChannelMetadataEdit(channel.idHex)) }, contentPadding = ZeroPadding, ) { Icon( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataViewModel.kt index 85993cecd..6868bca4a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataViewModel.kt @@ -28,6 +28,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.text.input.TextFieldValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache @@ -217,7 +218,7 @@ class ChannelMetadataViewModel : ViewModel() { alt = null, sensitiveContent = null, serverBaseUrl = account.settings.defaultFileServer.baseUrl, - forceProxy = account::shouldUseTorForNIP96, + okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(account.shouldUseTorForNIP96(it)) }, onProgress = {}, httpAuth = account::createHTTPAuthorization, context = context, @@ -230,7 +231,7 @@ class ChannelMetadataViewModel : ViewModel() { alt = null, sensitiveContent = null, serverBaseUrl = account.settings.defaultFileServer.baseUrl, - forceProxy = account::shouldUseTorForNIP96, + okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(account.shouldUseTorForNIP96(it)) }, httpAuth = account::createBlossomUploadAuth, context = context, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/LongLiveActivityChannelHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/LongLiveActivityChannelHeader.kt index 43978417a..9b19653f7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/LongLiveActivityChannelHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/LongLiveActivityChannelHeader.kt @@ -46,6 +46,7 @@ import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.components.LoadNote import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.routeFor import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture import com.vitorpamplona.amethyst.ui.note.NoteUsernameDisplay @@ -175,7 +176,7 @@ fun LongLiveActivityChannelHeader( participantUsers.forEach { Row( - lineModifier.clickable { nav.nav("User/${it.second.pubkeyHex}") }, + lineModifier.clickable { nav.nav(routeFor(it.second)) }, verticalAlignment = Alignment.CenterVertically, ) { it.first.role?.let { it1 -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt index 7564ea2b3..01d933ea7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt @@ -162,6 +162,9 @@ open class ChannelNewMessageViewModel : this.userSuggestions?.reset() this.userSuggestions = UserSuggestionState(accountVM) + this.emojiSuggestions?.reset() + this.emojiSuggestions = EmojiSuggestionState(accountVM) + this.uploadState = ChatFileUploadState( account?.settings?.defaultFileServer ?: DEFAULT_MEDIA_SERVERS[0], @@ -566,7 +569,7 @@ open class ChannelNewMessageViewModel : viewModelScope.launch(Dispatchers.IO) { iMetaAttachments.downloadAndPrepare( item.url.url, - accountViewModel?.account?.shouldUseTorForImageDownload() ?: false, + { Amethyst.instance.okHttpClients.getHttpClient(accountViewModel?.account?.shouldUseTorForImageDownload() ?: false) }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChannelFabColumn.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChannelFabColumn.kt index e1fcf37c1..45135dbac 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChannelFabColumn.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChannelFabColumn.kt @@ -50,7 +50,7 @@ import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.navigation.EmptyNav.nav import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.Route.ChannelMetadataEdit +import com.vitorpamplona.amethyst.ui.navigation.Route import com.vitorpamplona.amethyst.ui.navigation.Route.NewGroupDM import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Font12SP @@ -69,7 +69,7 @@ fun ChannelFabColumn(nav: INav) { Column { FloatingActionButton( onClick = { - nav.nav(NewGroupDM.base) + nav.nav(NewGroupDM()) isOpen = false }, modifier = Size55Modifier, @@ -88,7 +88,7 @@ fun ChannelFabColumn(nav: INav) { FloatingActionButton( onClick = { - nav.nav(ChannelMetadataEdit.base) + nav.nav(Route.ChannelMetadataEdit()) isOpen = false }, modifier = Size55Modifier, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt index 1bfaaf44d..79f6e64a2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms +import android.R.attr.description import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.height @@ -58,10 +59,12 @@ import com.vitorpamplona.amethyst.model.Channel import com.vitorpamplona.amethyst.model.FeatureSetType import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.NostrChannelDataSource.channel import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.layouts.ChatHeaderLayout import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.Route import com.vitorpamplona.amethyst.ui.note.BlankNote import com.vitorpamplona.amethyst.ui.note.LoadChannel import com.vitorpamplona.amethyst.ui.note.LoadDecryptedContentOrNull @@ -181,7 +184,7 @@ private fun ChannelRoomCompose( val noteEvent = note.event - val route = "Channel/${channel.idHex}" + val route = Route.Channel(channel.idHex) val description = if (noteEvent is ChannelCreateEvent) { @@ -192,7 +195,7 @@ private fun ChannelRoomCompose( noteEvent?.content?.take(200) } - val lastReadTime by accountViewModel.account.loadLastReadFlow(route).collectAsStateWithLifecycle() + val lastReadTime by accountViewModel.account.loadLastReadFlow("Channel/${channel.idHex}").collectAsStateWithLifecycle() ChannelName( channelIdHex = channel.idHex, @@ -253,8 +256,6 @@ private fun UserRoomCompose( accountViewModel: AccountViewModel, nav: INav, ) { - val route = "Room/${room.hashCode()}" - ChatHeaderLayout( channelPicture = { NonClickableUserPictures( @@ -289,12 +290,12 @@ private fun UserRoomCompose( } } - val lastReadTime by accountViewModel.account.loadLastReadFlow(route).collectAsStateWithLifecycle() + val lastReadTime by accountViewModel.account.loadLastReadFlow("Room/${room.hashCode()}").collectAsStateWithLifecycle() if ((note.createdAt() ?: Long.MIN_VALUE) > lastReadTime) { NewItemsBubble() } }, - onClick = { nav.nav(route) }, + onClick = { nav.nav(Route.Room(room.hashCode())) }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt index 4619e0965..b23345030 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt @@ -40,6 +40,7 @@ import com.vitorpamplona.amethyst.ui.feeds.FeedError import com.vitorpamplona.amethyst.ui.feeds.FeedState import com.vitorpamplona.amethyst.ui.feeds.LoadingFeed import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox +import com.vitorpamplona.amethyst.ui.feeds.WatchScrollToTop import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.ChatroomHeaderCompose @@ -80,7 +81,7 @@ private fun CrossFadeState( FeedError(state.errorMessage) { feedContentState.invalidateData() } } is FeedState.Loaded -> { - FeedLoaded(state, accountViewModel, nav, markAsRead) + FeedLoaded(state, feedContentState, accountViewModel, nav, markAsRead) } FeedState.Loading -> { LoadingFeed() @@ -92,6 +93,7 @@ private fun CrossFadeState( @Composable private fun FeedLoaded( loaded: FeedState.Loaded, + feedContentState: FeedContentState, accountViewModel: AccountViewModel, nav: INav, markAsRead: MutableState, @@ -106,6 +108,8 @@ private fun FeedLoaded( } } + WatchScrollToTop(feedContentState, listState) + LazyColumn( contentPadding = FeedPadding, state = listState, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/singlepane/MessagesSinglePane.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/singlepane/MessagesSinglePane.kt index 3d64f1e61..4384893bd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/singlepane/MessagesSinglePane.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/singlepane/MessagesSinglePane.kt @@ -81,11 +81,11 @@ fun MessagesSinglePane( } }, bottomBar = { - AppBottomBar(Route.Message, accountViewModel) { route, _ -> + AppBottomBar(Route.Message, accountViewModel) { route -> if (route == Route.Message) { tabs[pagerState.currentPage].feedContentState.sendToTop() } else { - nav.newStack(route.base) + nav.newStack(route) } } }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/MessagesTwoPane.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/MessagesTwoPane.kt index 5902df4c2..7c126bd16 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/MessagesTwoPane.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/MessagesTwoPane.kt @@ -46,6 +46,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.Chatroom import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ChannelView import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.ChannelFabColumn import com.vitorpamplona.amethyst.ui.theme.Size20dp +import kotlinx.coroutines.channels.Channel @Composable fun MessagesTwoPane( @@ -80,8 +81,13 @@ fun MessagesTwoPane( } }, bottomBar = { - AppBottomBar(Route.Message, accountViewModel) { route, _ -> - nav.newStack(route.base) + AppBottomBar(Route.Message, accountViewModel) { route -> + if (route == Route.Message) { + knownFeedContentState.sendToTop() + newFeedContentState.sendToTop() + } else { + nav.newStack(route) + } } }, accountViewModel = accountViewModel, @@ -104,15 +110,15 @@ fun MessagesTwoPane( second = { Box(Modifier.fillMaxSize().systemBarsPadding()) { twoPaneNav.innerNav.value?.let { - if (it.route == "Room") { + if (it is Route.Room) { Chatroom( - roomId = it.id, + roomId = it.id.toString(), accountViewModel = accountViewModel, nav = nav, ) } - if (it.route == "Channel") { + if (it is Route.Channel) { ChannelView( channelId = it.id, accountViewModel = accountViewModel, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/TwoPaneNav.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/TwoPaneNav.kt index 1d6b703e6..8b9ae66fe 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/TwoPaneNav.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/TwoPaneNav.kt @@ -22,10 +22,13 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.twopane import androidx.compose.material3.DrawerState import androidx.compose.runtime.mutableStateOf +import com.vitorpamplona.amethyst.ui.navigation.EmptyNav.nav import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.Route import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlin.reflect.KClass class TwoPaneNav( val nav: INav, @@ -33,28 +36,28 @@ class TwoPaneNav( ) : INav { override val drawerState: DrawerState = nav.drawerState - val innerNav = mutableStateOf(null) + val innerNav = mutableStateOf(null) - override fun nav(route: String) { - if (route.startsWith("Room/") || route.startsWith("Channel/")) { - innerNav.value = RouteId(route.substringBefore("/"), route.substringAfter("/")) + override fun nav(route: Route) { + if (route is Route.Room || route is Route.Channel) { + innerNav.value = route } else { nav.nav(route) } } - override fun nav(routeMaker: suspend () -> String) { + override fun nav(routeMaker: suspend () -> Route) { scope.launch(Dispatchers.Default) { val route = routeMaker() - if (route.startsWith("Room/") || route.startsWith("Channel/")) { - innerNav.value = RouteId(route.substringBefore("/"), route.substringAfter("/")) + if (route is Route.Room || route is Route.Channel) { + innerNav.value = route } else { nav.nav(route) } } } - override fun newStack(route: String) { + override fun newStack(route: Route) { nav.newStack(route) } @@ -62,11 +65,11 @@ class TwoPaneNav( nav.popBack() } - override fun popUpTo( - route: String, - upTo: String, + override fun popUpTo( + route: Route, + upToClass: KClass, ) { - nav.popUpTo(route, upTo) + nav.popUpTo(route, upToClass) } override fun closeDrawer() { @@ -76,9 +79,4 @@ class TwoPaneNav( override fun openDrawer() { nav.openDrawer() } - - data class RouteId( - val route: String, - val id: String, - ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/NewCommunityNoteButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/NewCommunityNoteButton.kt index 8546e965b..10d9dece8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/NewCommunityNoteButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/NewCommunityNoteButton.kt @@ -34,7 +34,7 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.LoadNote 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.stringRes import com.vitorpamplona.amethyst.ui.theme.Size55Modifier @@ -59,7 +59,7 @@ fun NewCommunityNoteButton( FloatingActionButton( onClick = { val route = - buildNewPostRoute( + Route.NewPost( baseReplyTo = note.idHex, ) nav.nav(route) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoverScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoverScreen.kt index f21abbea7..1d876a1b4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoverScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoverScreen.kt @@ -129,28 +129,28 @@ fun DiscoverScreen( TabItem( R.string.discover_content, discoveryContentNIP89FeedContentState, - Route.Discover.base + "DiscoverContent", + "DiscoverDiscoverContent", ScrollStateKeys.DISCOVER_CONTENT, AppDefinitionEvent.KIND, ), TabItem( R.string.discover_live, discoveryLiveFeedContentState, - Route.Discover.base + "Live", + "DiscoverLive", ScrollStateKeys.DISCOVER_LIVE, LiveActivitiesEvent.KIND, ), TabItem( R.string.discover_community, discoveryCommunityFeedContentState, - Route.Discover.base + "Community", + "DiscoverCommunity", ScrollStateKeys.DISCOVER_COMMUNITY, CommunityDefinitionEvent.KIND, ), TabItem( R.string.discover_marketplace, discoveryMarketplaceFeedContentState, - Route.Discover.base + "Marketplace", + "DiscoverMarketplace", ScrollStateKeys.DISCOVER_MARKETPLACE, ClassifiedsEvent.KIND, useGridLayout = true, @@ -158,7 +158,7 @@ fun DiscoverScreen( TabItem( R.string.discover_chat, discoveryChatFeedContentState, - Route.Discover.base + "Chats", + "DiscoverChats", ScrollStateKeys.DISCOVER_CHATS, ChannelCreateEvent.KIND, ), @@ -229,11 +229,11 @@ private fun DiscoverPages( } }, bottomBar = { - AppBottomBar(Route.Discover, accountViewModel) { route, _ -> + AppBottomBar(Route.Discover, accountViewModel) { route -> if (route == Route.Discover) { tabs[pagerState.currentPage].feedState.sendToTop() } else { - nav.newStack(route.base) + nav.newStack(route) } } }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt index dc1c2ef18..65f2b4446 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt @@ -120,13 +120,13 @@ private fun AssembleHomeTabs( TabItem( resource = R.string.new_threads, feedState = newThreadsFeedState, - routeForLastRead = Route.Home.base + "Follows", + routeForLastRead = "HomeFollows", scrollStateKey = ScrollStateKeys.HOME_FOLLOWS, ), TabItem( resource = R.string.conversations, feedState = repliesFeedState, - routeForLastRead = Route.Home.base + "FollowsReplies", + routeForLastRead = "HomeFollowsReplies", scrollStateKey = ScrollStateKeys.HOME_REPLIES, ), ).toImmutableList(), @@ -183,11 +183,11 @@ private fun HomePages( } }, bottomBar = { - AppBottomBar(Route.Home, accountViewModel) { route, _ -> + AppBottomBar(Route.Home, accountViewModel) { route -> if (route == Route.Home) { tabs[pagerState.currentPage].feedState.sendToTop() } else { - nav.newStack(route.base) + nav.newStack(route) } } }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeTopBar.kt index 42befc826..f1b07a9d8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeTopBar.kt @@ -26,7 +26,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.ui.navigation.FollowListWithRoutes import com.vitorpamplona.amethyst.ui.navigation.GenericMainTopBar import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.screen.CodeNameType import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @Composable @@ -42,8 +41,8 @@ fun HomeTopBar( followListsModel = accountViewModel.feedStates.feedListOptions, listName = list, ) { listName -> - if (listName.type == CodeNameType.ROUTE) { - nav.nav(listName.code) + if (listName.route != null) { + nav.nav(listName.route) } else { accountViewModel.account.settings.changeDefaultHomeFollowList(listName.code) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/NewNoteButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/NewNoteButton.kt index 570e3dbfc..35bc30eea 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/NewNoteButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/NewNoteButton.kt @@ -32,7 +32,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R 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.stringRes import com.vitorpamplona.amethyst.ui.theme.Size55Modifier @@ -43,8 +43,7 @@ fun NewNoteButton( ) { FloatingActionButton( onClick = { - val route = buildNewPostRoute(enableGeolocation = enableGeolocation) - nav.nav(route) + nav.nav(Route.NewPost(enableGeolocation = enableGeolocation)) }, modifier = Size55Modifier, shape = CircleShape, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationScreen.kt index eb6a73387..a27d83588 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationScreen.kt @@ -100,11 +100,11 @@ fun NotificationScreen( } }, bottomBar = { - AppBottomBar(Route.Notification, accountViewModel) { route, _ -> + AppBottomBar(Route.Notification, accountViewModel) { route -> if (route == Route.Notification) { notifFeedContentState.invalidateDataAndSendToTop(true) } else { - nav.newStack(route.base) + nav.newStack(route) } } }, @@ -117,7 +117,7 @@ fun NotificationScreen( feedContent = notifFeedContentState, accountViewModel = accountViewModel, nav = nav, - routeForLastRead = Route.Notification.base, + routeForLastRead = "Notification", scrollStateKey = ScrollStateKeys.NOTIFICATION_SCREEN, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryThumb.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryThumb.kt index 76737f6c0..efbc5f58f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryThumb.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryThumb.kt @@ -51,7 +51,6 @@ import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo import com.vitorpamplona.amethyst.commons.richtext.RichTextParser.Companion.isVideoUrl import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.service.okhttp.HttpClientManager import com.vitorpamplona.amethyst.service.playback.composable.GetVideoController import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.GetMediaItem import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled @@ -292,7 +291,7 @@ fun UrlVideoView( callbackUri = content.uri, mimeType = content.mimeType, aspectRatio = ratio, - proxyPort = HttpClientManager.getCurrentProxyPort(accountViewModel.account.shouldUseTorForVideoDownload(content.url)), + proxyPort = accountViewModel.proxyPortFor(content.url), ) { mediaItem -> GetVideoController( mediaItem = mediaItem, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/ProfileGalleryFeed.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/ProfileGalleryFeed.kt index 4ab78a01a..a7d6c7c72 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/ProfileGalleryFeed.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/ProfileGalleryFeed.kt @@ -41,7 +41,6 @@ import com.vitorpamplona.amethyst.ui.feeds.FeedState import com.vitorpamplona.amethyst.ui.feeds.LoadingFeed import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.screen.FeedViewModel -import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.FeedPadding @@ -89,14 +88,13 @@ private fun GalleryFeedLoaded( nav: INav, ) { val items by loaded.feed.collectAsStateWithLifecycle() - val sharedPreferencesViewModel: SharedPreferencesViewModel = viewModel() - sharedPreferencesViewModel.init() - - var ratio = 1.0f - if (sharedPreferencesViewModel.sharedPrefs.modernGalleryStyle.value) { - ratio = 0.8f - } + val ratio = + if (accountViewModel.settings.modernGalleryStyle.value) { + 0.8f + } else { + 1.0f + } LazyVerticalGrid( columns = GridCells.Fixed(3), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/hashtags/TabFollowedTags.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/hashtags/TabFollowedTags.kt index b2d8720ba..0489c8563 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/hashtags/TabFollowedTags.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/hashtags/TabFollowedTags.kt @@ -32,6 +32,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.HashtagHeader import com.vitorpamplona.amethyst.ui.theme.DividerThickness @@ -58,7 +59,7 @@ fun TabFollowedTags( HashtagHeader( tag = hashtag, account = account, - onClick = { nav.nav("Hashtag/$hashtag") }, + onClick = { nav.nav(Route.Hashtag(hashtag)) }, ) HorizontalDivider( thickness = DividerThickness, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DisplayLNAddress.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DisplayLNAddress.kt index 550034c09..75699dea8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DisplayLNAddress.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DisplayLNAddress.kt @@ -22,8 +22,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding -import androidx.compose.material3.LocalTextStyle -import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -33,11 +31,10 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.actions.InformationDialog -import com.vitorpamplona.amethyst.ui.components.ClickableText +import com.vitorpamplona.amethyst.ui.components.ClickableTextPrimary import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.navigation.routeToMessage import com.vitorpamplona.amethyst.ui.note.ErrorMessageDialog @@ -91,10 +88,9 @@ fun DisplayLNAddress( Row(verticalAlignment = Alignment.CenterVertically) { LightningAddressIcon(modifier = Size16Modifier, tint = BitcoinOrange) - ClickableText( - text = AnnotatedString(lud16), + ClickableTextPrimary( + text = lud16, onClick = { zapExpanded = !zapExpanded }, - style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary), modifier = Modifier .padding(top = 1.dp, bottom = 1.dp, start = 5.dp) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt index 6454778b5..3ae5407be 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt @@ -29,7 +29,6 @@ import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.Link import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -50,7 +49,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.ui.components.ClickableText +import com.vitorpamplona.amethyst.ui.components.ClickableTextPrimary import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji import com.vitorpamplona.amethyst.ui.components.DisplayNip05ProfileStatus import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer @@ -171,8 +170,8 @@ fun DrawAdditionalInfo( modifier = Modifier.size(16.dp), ) - ClickableText( - text = AnnotatedString(website.removePrefix("https://")), + ClickableTextPrimary( + text = website.removePrefix("https://"), onClick = { website.let { runCatching { @@ -184,7 +183,6 @@ fun DrawAdditionalInfo( } } }, - style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary), modifier = Modifier.padding(top = 1.dp, bottom = 1.dp, start = 5.dp), ) } @@ -205,10 +203,9 @@ fun DrawAdditionalInfo( modifier = Modifier.size(16.dp), ) - ClickableText( - text = AnnotatedString(identity.identity), + ClickableTextPrimary( + text = identity.identity, onClick = { runCatching { uri.openUri(identity.toProofUrl()) } }, - style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary), modifier = Modifier .padding(top = 1.dp, bottom = 1.dp, start = 5.dp) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/EditButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/EditButton.kt index c14d0d78d..a1b754695 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/EditButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/EditButton.kt @@ -38,7 +38,7 @@ import com.vitorpamplona.amethyst.ui.theme.ZeroPadding @Composable fun EditButton(nav: INav) { - InnerEditButton { nav.nav(Route.EditProfile.route) } + InnerEditButton { nav.nav(Route.EditProfile) } } @Preview diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/MessageButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/MessageButton.kt index 36a32d006..3e09abc6f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/MessageButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/MessageButton.kt @@ -32,6 +32,7 @@ import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size20Modifier @@ -53,7 +54,7 @@ fun MessageButton( .padding(horizontal = 3.dp) .width(50.dp), onClick = { - scope.launch(Dispatchers.IO) { accountViewModel.createChatRoomFor(user) { nav.nav("Room/$it") } } + scope.launch(Dispatchers.IO) { accountViewModel.createChatRoomFor(user) { nav.nav(Route.Room(it)) } } }, contentPadding = ZeroPadding, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/apps/WatchApp.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/apps/WatchApp.kt index 3d935555e..77d45ac85 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/apps/WatchApp.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/apps/WatchApp.kt @@ -36,6 +36,7 @@ import androidx.compose.ui.draw.clip import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.Route import com.vitorpamplona.amethyst.ui.theme.Size35dp import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import kotlinx.coroutines.Dispatchers @@ -69,7 +70,7 @@ fun WatchApp( remember { Modifier .size(Size35dp) - .clickable { nav.nav("Note/${baseApp.idHex}") } + .clickable { nav.nav(Route.Note(baseApp.idHex)) } }, ) { AsyncImage( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/badges/DisplayBadges.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/badges/DisplayBadges.kt index 1c116cf6e..4b61a4793 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/badges/DisplayBadges.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/badges/DisplayBadges.kt @@ -52,6 +52,7 @@ import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImage import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.Route import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes @@ -162,7 +163,7 @@ fun BadgeThumb( size: Dp, pictureModifier: Modifier = Modifier, ) { - BadgeThumb(note, loadProfilePicture, loadRobohash, size, pictureModifier) { nav.nav("Note/${note.idHex}") } + BadgeThumb(note, loadProfilePicture, loadRobohash, size, pictureModifier) { nav.nav(Route.Note(note.idHex)) } } @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelayFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelayFeedView.kt index d9eee03e1..6e97d9252 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelayFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelayFeedView.kt @@ -34,8 +34,6 @@ import com.vitorpamplona.amethyst.ui.note.RelayCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.FeedPadding -import java.net.URLEncoder -import java.nio.charset.StandardCharsets @Composable fun RelayFeedView( @@ -58,10 +56,10 @@ fun RelayFeedView( item, accountViewModel = accountViewModel, onAddRelay = { - nav.nav(Route.EditRelays.base + "?toAdd=" + URLEncoder.encode(item.url, StandardCharsets.UTF_8.toString())) + nav.nav(Route.EditRelays(item.url)) }, onRemoveRelay = { - nav.nav(Route.EditRelays.base + "?toAdd=" + URLEncoder.encode(item.url, StandardCharsets.UTF_8.toString())) + nav.nav(Route.EditRelays(item.url)) }, ) HorizontalDivider( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/LnZapFeedState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/LnZapFeedState.kt index b2a3f2cf3..421b2369f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/LnZapFeedState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/LnZapFeedState.kt @@ -21,10 +21,10 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps import androidx.compose.runtime.Immutable -import androidx.compose.runtime.MutableState import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.model.Note import kotlinx.collections.immutable.ImmutableList +import kotlinx.coroutines.flow.MutableStateFlow @Immutable data class ZapReqResponse( val zapRequest: Note, @@ -36,7 +36,7 @@ sealed class LnZapFeedState { object Loading : LnZapFeedState() class Loaded( - val feed: MutableState>, + val feed: MutableStateFlow>, ) : LnZapFeedState() object Empty : LnZapFeedState() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/LnZapFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/LnZapFeedView.kt index 440e42123..3c237c5ff 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/LnZapFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/LnZapFeedView.kt @@ -73,13 +73,14 @@ private fun LnZapFeedLoaded( accountViewModel: AccountViewModel, nav: INav, ) { + val items by state.feed.collectAsStateWithLifecycle() val listState = rememberLazyListState() LazyColumn( contentPadding = FeedPadding, state = listState, ) { - itemsIndexed(state.feed.value, key = { _, item -> item.zapEvent.idHex }) { _, item -> + itemsIndexed(items, key = { _, item -> item.zapEvent.idHex }) { _, item -> ZapNoteCompose(item, accountViewModel = accountViewModel, nav = nav) HorizontalDivider( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/LnZapFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/LnZapFeedViewModel.kt index 802e44863..7fffd030a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/LnZapFeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/LnZapFeedViewModel.kt @@ -22,10 +22,10 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps import android.util.Log import androidx.compose.runtime.Stable -import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.LocalCache.notes import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.ui.dal.FeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists @@ -36,7 +36,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @Stable @@ -66,16 +65,14 @@ open class LnZapFeedViewModel( } private fun updateFeed(notes: ImmutableList) { - viewModelScope.launch(Dispatchers.Main) { - val currentState = _feedContent.value - if (notes.isEmpty()) { - _feedContent.update { LnZapFeedState.Empty } - } else if (currentState is LnZapFeedState.Loaded) { - // updates the current list - currentState.feed.value = notes - } else { - _feedContent.update { LnZapFeedState.Loaded(mutableStateOf(notes)) } - } + val currentState = _feedContent.value + if (notes.isEmpty()) { + _feedContent.tryEmit(LnZapFeedState.Empty) + } else if (currentState is LnZapFeedState.Loaded) { + // updates the current list + currentState.feed.tryEmit(notes) + } else { + _feedContent.tryEmit(LnZapFeedState.Loaded(MutableStateFlow(notes))) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/QrCodeScanner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/QrCodeScanner.kt index 747be3551..82dbf6a32 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/QrCodeScanner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/QrCodeScanner.kt @@ -28,12 +28,13 @@ import com.google.zxing.client.android.Intents import com.journeyapps.barcodescanner.ScanContract import com.journeyapps.barcodescanner.ScanOptions import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.navigation.Route import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.uriToRoute import kotlinx.coroutines.CancellationException @Composable -fun NIP19QrCodeScanner(onScan: (String?) -> Unit) { +fun NIP19QrCodeScanner(onScan: (Route?) -> Unit) { SimpleQrCodeScanner { try { onScan(uriToRoute(it)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/ShowQRDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/ShowQRDialog.kt index de685a6e8..833b489da 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/ShowQRDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/ShowQRDialog.kt @@ -62,6 +62,7 @@ import com.vitorpamplona.amethyst.ui.components.DisplayNIP05 import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.components.SetDialogToEdgeToEdge import com.vitorpamplona.amethyst.ui.components.nip05VerificationAsAState +import com.vitorpamplona.amethyst.ui.navigation.Route import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel @@ -109,7 +110,7 @@ fun BackButton(onPress: () -> Unit) { fun ShowQRDialog( user: User, accountViewModel: AccountViewModel, - onScan: (String) -> Unit, + onScan: (Route) -> Unit, onClose: () -> Unit, ) { var presenting by remember { mutableStateOf(true) } @@ -220,7 +221,7 @@ fun ShowQRDialog( } } else { NIP19QrCodeScanner { - if (it.isNullOrEmpty()) { + if (it == null) { presenting = true } else { onScan(it) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoModel.kt index 7764fda06..eb8e2f998 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoModel.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.service.Nip11CachedRetriever import com.vitorpamplona.amethyst.service.replace @@ -63,7 +64,7 @@ abstract class BasicRelaySetupInfoModel : ViewModel() { _relays.value.forEach { item -> Nip11CachedRetriever.loadRelayInfo( dirtyUrl = item.url, - forceProxy = account.shouldUseTorForDirty(item.url), + okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(account.shouldUseTorForDirty(item.url)) }, onInfo = { togglePaidRelay(item, it.limitation?.payment_required ?: false) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/kind3/Kind3RelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/kind3/Kind3RelayListViewModel.kt index 348d5d87b..6b561f488 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/kind3/Kind3RelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/kind3/Kind3RelayListViewModel.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.kind3 import androidx.compose.runtime.Stable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.service.Nip11CachedRetriever import com.vitorpamplona.amethyst.service.replace @@ -84,7 +85,7 @@ class Kind3RelayListViewModel : ViewModel() { _relays.value.forEach { item -> Nip11CachedRetriever.loadRelayInfo( dirtyUrl = item.url, - forceProxy = account.shouldUseTorForDirty(item.url), + okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(account.shouldUseTorForDirty(item.url)) }, onInfo = { togglePaidRelay(item, it.limitation?.payment_required ?: false) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListViewModel.kt index 114da4bcd..628b9a73a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListViewModel.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.nip65 import androidx.compose.runtime.Stable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.service.Nip11CachedRetriever import com.vitorpamplona.amethyst.service.replace @@ -84,7 +85,7 @@ class Nip65RelayListViewModel : ViewModel() { _homeRelays.value.forEach { item -> Nip11CachedRetriever.loadRelayInfo( dirtyUrl = item.url, - forceProxy = account.shouldUseTorForDirty(item.url), + okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(account.shouldUseTorForDirty(item.url)) }, onInfo = { toggleHomePaidRelay(item, it.limitation?.payment_required ?: false) }, @@ -95,7 +96,7 @@ class Nip65RelayListViewModel : ViewModel() { _notificationRelays.value.forEach { item -> Nip11CachedRetriever.loadRelayInfo( dirtyUrl = item.url, - forceProxy = account.shouldUseTorForDirty(item.url), + okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(account.shouldUseTorForDirty(item.url)) }, onInfo = { toggleNotifPaidRelay(item, it.limitation?.payment_required ?: false) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt index 5f44f0f28..55a13d3dc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt @@ -150,8 +150,8 @@ fun SearchScreen( SearchBar(searchBarViewModel, listState, nav) }, bottomBar = { - AppBottomBar(Route.Search, accountViewModel) { route, _ -> - nav.newStack(route.base) + AppBottomBar(Route.Search, accountViewModel) { route -> + nav.newStack(route) } }, accountViewModel = accountViewModel, @@ -305,7 +305,7 @@ private fun DisplaySearchResults( hashTags, key = { _, item -> "#$item" }, ) { _, item -> - HashtagLine(item) { nav.nav("Hashtag/$item") } + HashtagLine(item) { nav.nav(Route.Hashtag(item)) } HorizontalDivider( modifier = Modifier.padding(top = 10.dp), @@ -342,7 +342,7 @@ private fun DisplaySearchResults( hasNewMessages = false, loadProfilePicture = accountViewModel.settings.showProfilePictures.value, loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE, - onClick = { nav.nav("Channel/${item.idHex}") }, + onClick = { nav.nav(Route.Channel(item.idHex)) }, ) HorizontalDivider( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt index 1d1d17749..83877c3d4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt @@ -158,7 +158,7 @@ fun SecurityFiltersScreen( val pagerState = rememberPagerState { 3 } val coroutineScope = rememberCoroutineScope() var warnAboutReports by remember { mutableStateOf(accountViewModel.account.settings.syncedSettings.security.warnAboutPostsWithReports) } - var filterSpam by remember { mutableStateOf(accountViewModel.account.settings.syncedSettings.security.filterSpamFromStrangers) } + var filterSpam by remember { mutableStateOf(accountViewModel.account.settings.syncedSettings.security.filterSpamFromStrangers.value) } Row(verticalAlignment = Alignment.CenterVertically) { Checkbox( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/StringFeedState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/StringFeedState.kt index 03c287a6a..93eca9411 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/StringFeedState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/StringFeedState.kt @@ -20,14 +20,14 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings -import androidx.compose.runtime.MutableState import kotlinx.collections.immutable.ImmutableList +import kotlinx.coroutines.flow.MutableStateFlow sealed class StringFeedState { object Loading : StringFeedState() class Loaded( - val feed: MutableState>, + val feed: MutableStateFlow>, ) : StringFeedState() object Empty : StringFeedState() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/StringFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/StringFeedView.kt index a2026df8a..69704776a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/StringFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/StringFeedView.kt @@ -101,6 +101,7 @@ private fun FeedLoaded( post: (@Composable () -> Unit)? = null, inner: @Composable (String) -> Unit, ) { + val items by state.feed.collectAsStateWithLifecycle() val listState = rememberLazyListState() LazyColumn( @@ -109,7 +110,7 @@ private fun FeedLoaded( ) { item { pre?.let { it() } } - itemsIndexed(state.feed.value, key = { _, item -> item }) { _, item -> + itemsIndexed(items, key = { _, item -> item }) { _, item -> inner(item) HorizontalDivider( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/StringFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/StringFeedViewModel.kt index 54746bf31..57885dca6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/StringFeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/StringFeedViewModel.kt @@ -41,7 +41,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch class NostrHiddenWordsFeedViewModel( @@ -93,16 +92,14 @@ open class StringFeedViewModel( } private fun updateFeed(notes: ImmutableList) { - viewModelScope.launch(Dispatchers.Main) { - val currentState = _feedContent.value - if (notes.isEmpty()) { - _feedContent.update { StringFeedState.Empty } - } else if (currentState is StringFeedState.Loaded) { - // updates the current list - currentState.feed.value = notes - } else { - _feedContent.update { StringFeedState.Loaded(mutableStateOf(notes)) } - } + val currentState = _feedContent.value + if (notes.isEmpty()) { + _feedContent.tryEmit(StringFeedState.Empty) + } else if (currentState is StringFeedState.Loaded) { + // updates the current list + currentState.feed.tryEmit(notes) + } else { + _feedContent.tryEmit(StringFeedState.Loaded(MutableStateFlow(notes))) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt index 81a7bfc0e..bffcd62bc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt @@ -882,7 +882,7 @@ private fun RenderClassifiedsReaderForThread( nav.nav { routeToMessage( it, - note.toNostrUri() + "\n\n" + msg, + draftMessage = note.toNostrUri() + "\n\n" + msg, accountViewModel = accountViewModel, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/VideoScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/VideoScreen.kt index e0414782a..934fb3f7d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/VideoScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/VideoScreen.kt @@ -74,7 +74,6 @@ import com.vitorpamplona.amethyst.ui.feeds.rememberForeverPagerState import com.vitorpamplona.amethyst.ui.navigation.AppBottomBar import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.navigation.Route -import com.vitorpamplona.amethyst.ui.navigation.buildNewPostRoute import com.vitorpamplona.amethyst.ui.navigation.routeFor import com.vitorpamplona.amethyst.ui.note.BoostReaction import com.vitorpamplona.amethyst.ui.note.CheckHiddenFeedWatchBlockAndReport @@ -149,11 +148,11 @@ fun VideoScreen( StoriesTopBar(accountViewModel, nav) }, bottomBar = { - AppBottomBar(Route.Video, accountViewModel) { route, _ -> + AppBottomBar(Route.Video, accountViewModel) { route -> if (route == Route.Video) { videoFeedContentState.sendToTop() } else { - nav.newStack(route.base) + nav.newStack(route) } } }, @@ -448,12 +447,11 @@ fun ReactionsColumn( iconSizeModifier = Size40Modifier, iconSize = Size40dp, onQuotePress = { -// wantsToQuote = baseNote - val route = - buildNewPostRoute( + nav.nav( + Route.NewPost( quote = baseNote.idHex, - ) - nav.nav(route) + ), + ) }, onForkPress = { }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/AcceptTerms.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/AcceptTerms.kt index 6e67a0672..2ac1f4997 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/AcceptTerms.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/AcceptTerms.kt @@ -23,14 +23,13 @@ package com.vitorpamplona.amethyst.ui.screen.loggedOff import androidx.compose.foundation.layout.Row import androidx.compose.material3.Checkbox import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.platform.LocalUriHandler -import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.withStyle import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.components.ClickableText +import com.vitorpamplona.amethyst.ui.components.appendLink import com.vitorpamplona.amethyst.ui.stringRes @Composable @@ -44,32 +43,18 @@ fun AcceptTerms( onCheckedChange = onCheckedChange, ) - val regularText = SpanStyle(color = MaterialTheme.colorScheme.onBackground) - val clickableTextStyle = SpanStyle(color = MaterialTheme.colorScheme.primary) - - val annotatedTermsString = - buildAnnotatedString { - withStyle(regularText) { append(stringRes(R.string.i_accept_the)) } - - withStyle(clickableTextStyle) { - pushStringAnnotation("openTerms", "") - append(stringRes(R.string.terms_of_use)) - pop() - } - } - val uri = LocalUriHandler.current + val primary = MaterialTheme.colorScheme.primary - ClickableText( - annotatedTermsString, - ) { spanOffset -> - annotatedTermsString.getStringAnnotations(spanOffset, spanOffset).firstOrNull()?.also { span -> - if (span.tag == "openTerms") { + Text( + buildAnnotatedString { + append(stringRes(R.string.i_accept_the)) + appendLink(stringRes(R.string.terms_of_use), primary) { runCatching { uri.openUri("https://github.com/vitorpamplona/amethyst/blob/main/PRIVACY.md") } } - } - } + }, + ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginScreen.kt index 1399a91e2..eb7514614 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginScreen.kt @@ -644,7 +644,6 @@ private fun PrepareExternalSignerReceiver(onLogin: (pubkey: String, packageName: externalSignerLauncher.registerLauncher( launcher = { try { - activity.prepareToLaunchSigner() launcher.launch(it) } catch (e: Exception) { if (e is CancellationException) throw e diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/TorSettingsSetup.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/TorSettingsSetup.kt index 295a8d45a..1757e2daa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/TorSettingsSetup.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/TorSettingsSetup.kt @@ -22,18 +22,17 @@ package com.vitorpamplona.amethyst.ui.screen.loggedOff import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.components.ClickableText +import com.vitorpamplona.amethyst.ui.components.appendLink import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.tor.ConnectTorDialog import com.vitorpamplona.amethyst.ui.tor.TorSettings @@ -48,20 +47,16 @@ fun TorSettingsSetup( var connectOrbotDialogOpen by remember { mutableStateOf(false) } var activeTor by remember { mutableStateOf(false) } - val text = - buildAnnotatedString { - append(stringRes(R.string.connect_via_tor1) + " ") - withStyle(SpanStyle(color = MaterialTheme.colorScheme.primary)) { - append(stringRes(R.string.connect_via_tor2)) - } - } + val primary = MaterialTheme.colorScheme.primary - ClickableText( - text = text, + Text( + text = + buildAnnotatedString { + append(stringRes(R.string.connect_via_tor1) + " ") + appendLink(stringRes(R.string.connect_via_tor2), primary) { connectOrbotDialogOpen = true } + }, modifier = Modifier.padding(vertical = 10.dp), - ) { - connectOrbotDialogOpen = true - } + ) if (connectOrbotDialogOpen) { ConnectTorDialog( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt index a4f60bb7b..70edb0362 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt @@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.ui.theme import android.app.Activity import android.app.UiModeManager import android.content.Context -import android.graphics.Color.red import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.isSystemInDarkTheme @@ -278,14 +277,14 @@ val darkLargeProfilePictureModifier = .width(120.dp) .height(120.dp) .clip(shape = CircleShape) - .border(3.dp, DarkColorPalette.background, CircleShape) + .border(3.dp, DarkColorPalette.onBackground, CircleShape) val lightLargeProfilePictureModifier = Modifier .width(120.dp) .height(120.dp) .clip(shape = CircleShape) - .border(3.dp, LightColorPalette.background, CircleShape) + .border(3.dp, LightColorPalette.onBackground, CircleShape) val RichTextDefaults = RichTextStyle().resolveDefaults() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorManager.kt index a25fc1340..31773c6cb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorManager.kt @@ -20,87 +20,41 @@ */ package com.vitorpamplona.amethyst.ui.tor -import android.content.ComponentName -import android.content.Context -import android.content.Intent -import android.content.ServiceConnection -import android.os.IBinder -import android.util.Log -import androidx.appcompat.app.AppCompatActivity.BIND_AUTO_CREATE -import com.vitorpamplona.amethyst.service.okhttp.HttpClientManager +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 import org.torproject.jni.TorService -import org.torproject.jni.TorService.LocalBinder -import java.util.concurrent.atomic.AtomicBoolean -object TorManager { - var runningIntent: Intent? = null - var torService: TorService? = null - - // To make sure we don't start two services. - var isConnectingMutex = AtomicBoolean(false) - - fun startTorIfNotAlreadyOn(ctx: Context) { - if (runningIntent == null && isConnectingMutex.compareAndSet(false, true)) { - try { - startTor(ctx) - } finally { - isConnectingMutex.set(false) - } - } - } - - fun stopTor(ctx: Context) { - Log.d("TorManager", "Stopping Tor Service") - runningIntent?.let { - ctx.stopService(runningIntent) - } - runningIntent = null - torService = null - } - - private fun startTor(ctx: Context) { - Log.d("TorManager", "Binding Tor Service") - val currentIntent = Intent(ctx, TorService::class.java) - runningIntent = currentIntent - ctx.bindService( - currentIntent, - object : ServiceConnection { - override fun onServiceConnected( - name: ComponentName, - service: IBinder, - ) { - // moved torService to a local variable, since we only need it once - torService = (service as LocalBinder).service - - while (!isSocksReady()) { - try { - Thread.sleep(100) - } catch (e: InterruptedException) { - e.printStackTrace() - } - } - - HttpClientManager.setDefaultProxyOnPort(socksPort()) - - Log.d("TorManager", "Tor Service Connected ${socksPort()}") - } - - override fun onServiceDisconnected(name: ComponentName) { - runningIntent = null - torService = null - Log.d("TorManager", "Tor Service Disconected") - } - }, - BIND_AUTO_CREATE, +/** + * There should be only one instance of the Tor binding per app. + * + * Tor will connect as soon as status is listened to. + */ +class TorManager( + app: Application, + scope: CoroutineScope, +) { + val status: StateFlow = + TorService(app).status.stateIn( + scope, + SharingStarted.WhileSubscribed(30000), + TorServiceStatus.Off, ) - } - fun isSocksReady() = - torService?.let { - it.socksPort > 0 - } ?: false + val activePortOrNull: StateFlow = + status + .map { + (status.value as? TorServiceStatus.Active)?.port + }.stateIn( + scope, + SharingStarted.WhileSubscribed(2000), + (status.value as? TorServiceStatus.Active)?.port, + ) - fun socksPort(): Int = torService?.socksPort ?: 9050 + fun isSocksReady() = status.value is TorServiceStatus.Active - fun httpPort(): Int = torService?.httpTunnelPort ?: 9050 + fun socksPort(): Int = (status.value as? TorServiceStatus.Active)?.port ?: 9050 } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorService.kt new file mode 100644 index 000000000..024b1b085 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorService.kt @@ -0,0 +1,88 @@ +/** + * 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.tor + +import android.content.ComponentName +import android.content.Context +import android.content.Context.BIND_AUTO_CREATE +import android.content.Intent +import android.content.ServiceConnection +import android.os.IBinder +import android.util.Log +import com.vitorpamplona.amethyst.service.checkNotInMainThread +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.launch +import org.torproject.jni.TorService +import org.torproject.jni.TorService.LocalBinder + +class TorService( + val context: Context, +) { + val status = + callbackFlow { + Log.d("TorService", "Binding Tor Service") + trySend(TorServiceStatus.Connecting) + + val currentIntent = Intent(context, TorService::class.java) + + context.bindService( + currentIntent, + object : ServiceConnection { + override fun onServiceConnected( + name: ComponentName, + service: IBinder, + ) { + launch { + // moved torService to a local variable, since we only need it once + val torService = (service as LocalBinder).service + + while (torService.socksPort < 0) { + delay(100) + } + + trySend(TorServiceStatus.Active(torService.socksPort)) + Log.d("TorService", "Tor Service Connected ${torService.socksPort}") + } + } + + override fun onServiceDisconnected(name: ComponentName) { + Log.d("TorService", "Tor Service Disconnected") + trySend(TorServiceStatus.Off) + } + }, + BIND_AUTO_CREATE, + ) + + awaitClose { + checkNotInMainThread() + Log.d("TorService", "Stopping Tor Service") + launch { + context.stopService(currentIntent) + } + trySend(TorServiceStatus.Off) + } + }.distinctUntilChanged().flowOn(Dispatchers.IO) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorServiceStatus.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorServiceStatus.kt new file mode 100644 index 000000000..cf3912319 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorServiceStatus.kt @@ -0,0 +1,31 @@ +/** + * 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.tor + +sealed class TorServiceStatus { + data class Active( + val port: Int, + ) : TorServiceStatus() + + object Off : TorServiceStatus() + + object Connecting : TorServiceStatus() +} diff --git a/amethyst/src/main/res/values-fr-rFR/strings.xml b/amethyst/src/main/res/values-fr-rFR/strings.xml index 404809453..a54e04ff7 100644 --- a/amethyst/src/main/res/values-fr-rFR/strings.xml +++ b/amethyst/src/main/res/values-fr-rFR/strings.xml @@ -98,6 +98,7 @@ Description "à propos" Quoi de neuf ? + Rédiger un message... Envoyer Sauvegarder Créer @@ -205,6 +206,7 @@ avec le descriptif de et photo changé le nom du chat en + Nouveau profil de tchat : descriptif à et image à Partir @@ -212,6 +214,13 @@ Canal créé "Les informations du canal ont été remplacées par" Chat public + Métadonnées de tchat public + Les conversations publiques sont visibles pour tout le monde sur Nostr et n\'importe qui + peut y participer. Elles sont idéales pour les communautés ouvertes autour de sujets spécifiques. + La modération peut être contrôlée en supprimant les messages sur ses relais + Relais + Insérez entre 1 et 3 relais qui hébergent ce groupe. + Les clients Nostr utilisent ce paramètre pour savoir où télécharger les messages et où les envoyer. messages reçus Retirer Auto @@ -519,6 +528,7 @@ Envoyer vers le portefeuille Zap Ouvrir dans le portefeuille Cashu Copier le Jeton + Ouvrir dans une autre application Adresse Lightning non définie Jeton copié dans le presse-papiers EN DIRECT @@ -598,6 +608,8 @@ Sujet Sujet de la conversation "\@Utilisateur1, @Utilisateur2, @Utilisateur3" + Impossible d\'uploader + Insérer d\'abord la destination du message Membres de ce groupe Explication aux membres Changement de nom pour les nouveaux objectifs. @@ -865,6 +877,7 @@ Récapitulatif des modifications Corrections rapides ... Accepter la Suggestion + Lancer la vidéo dans une popup Télécharger Paroles activées Paroles désactivées @@ -930,4 +943,5 @@ Aucune application torrent installée pour ouvrir et télécharger le fichier. Sélectionnez une liste pour filtrer le fil Se déconnecter au verrouillage de l\'appareil + Message privé diff --git a/amethyst/src/main/res/values-hi-rIN/strings.xml b/amethyst/src/main/res/values-hi-rIN/strings.xml index 94e5e2a55..e49c5d174 100644 --- a/amethyst/src/main/res/values-hi-rIN/strings.xml +++ b/amethyst/src/main/res/values-hi-rIN/strings.xml @@ -98,6 +98,7 @@ विवरण "हमारा परिचय.. " आपके मन में क्या है? + सन्देश लिखें ... पत्र प्रकाशन अभिलेखन करें बनाएँ @@ -205,6 +206,7 @@ इसके विवरण सहित तथा चित्र चर्चा नाम यह बना दिया गया + नया चर्चा परिचय : इस विवरण तक तथा इस चित्र तक निर्गमन @@ -212,6 +214,13 @@ प्रणाली बनायी गयी "प्रणाली जानकारी परिवर्तित की गयी" सार्वजनिक चर्चा + सार्वजनिक चर्चा उपतथ्य + सार्वजनिक चर्चाएँ सबके लिए दृश्यमान हैं नोस्टर पर तथा सभी + इनमें भाग ले सकते हैं। ये उत्कृष्ट हैं खुले समुदायों के लिए विशिष्ट विषयों के प्रसंग में। + नियमन का नियन्त्रण किया जा सकता है पत्रों को मिटाने से पुनःप्रसारकों में + पुनःप्रसारक + इस समुदाय के निवास के लिए १ - ३ पुनःप्रसारकों को जोडें। + नोस्टर ग्राहक इस स्थापना विकल्प का प्रयोग करेंगे यह जानने के लिए कि कहाँ से सन्देशों का अवरोहण करना है तथा कहाँ आपके सन्देश भेजने हैं। पत्र प्राप्त हटाएँ स्वचालित @@ -519,6 +528,7 @@ ज्साप धनकोष को भेजें काशयु धनकोष में खोलें राशिखण्ड की अनुकृति करें + अन्य क्रमक में खोलें कोई लैटनिंग पता स्थापित नहीं राशिखण्ड की अनुकृति की गयी टाँकाफलक में तत्क्षणप्रसार @@ -598,6 +608,8 @@ विषय संवाद का विषय "\@उपयोगकर्ता१, @उपयोगकर्ता२, @उपयोगकर्ता३" + आरोहण असफल + पहले सन्देश का गन्तव्य प्रविष्ट करें इस झुण्ड के सदस्य सदस्यों के लिए व्याख्यान नाम का परिवर्तन नये लक्ष्य के लिए। @@ -865,6 +877,7 @@ परिवर्तनों का साराम्श शीघ्र सुधार… सुझाव को स्वीकारें + चलचित्र आरम्भ करें उद्भूत गवाक्ष में अवरोहण संगीत पद्य दिखाएँ संगीत पद्य ना दिखाएँ @@ -930,4 +943,5 @@ कोई उग्रप्रवाह क्रमक स्थापित नहीं अभिलेख खोलने तथा अवरोहण करने के लिए। सूचनावली छानने के लिए सूची चुनें यन्त्र ताला लगने पर निर्गमनांकन करें + निजी सन्देश diff --git a/amethyst/src/main/res/values-hu-rHU/strings.xml b/amethyst/src/main/res/values-hu-rHU/strings.xml index 35fe3141a..7ff89b879 100644 --- a/amethyst/src/main/res/values-hu-rHU/strings.xml +++ b/amethyst/src/main/res/values-hu-rHU/strings.xml @@ -98,6 +98,7 @@ Leírás "Névjegy… " Mi jár a fejében? + Üzenet írása… Közzététel Mentés Létrehozás @@ -205,6 +206,7 @@ a következő leírásával: és kép megváltoztatta a csevegés nevét erre: + Új csevegési profil: leírását erre: és a képet erre: Elhagyás @@ -526,6 +528,7 @@ Küldés ide: Zap Wallet Megnyitás a Cashu pénztárcában Token másolása + Megnyitás másik alkalmazásban Nincs beállítva Lightning-cím Token a vágólapra másolva ÉLŐ @@ -605,6 +608,8 @@ Tárgy A beszélgetés tárgya "\@Felhasználó1, @Felhasználó2, @Felhasználó3" + Nem sikerült feltölteni + Először adja meg az üzenet címzettjét A csoport tagjai Kifejtés a csoport tagjai számára Név megváltoztatása az új célok érdekében. @@ -938,4 +943,5 @@ A fájl megnyitásához és letöltéséhez nincsenek torrent-alkalmazások telepítve. Lista kiválasztása a hírfolyam szűréséhez Kijelentkeztetés az eszköz zárolása esetén + Privát üzenet diff --git a/amethyst/src/main/res/values-sl-rSI/strings.xml b/amethyst/src/main/res/values-sl-rSI/strings.xml index fbf7caf88..4fa7802ba 100644 --- a/amethyst/src/main/res/values-sl-rSI/strings.xml +++ b/amethyst/src/main/res/values-sl-rSI/strings.xml @@ -20,6 +20,7 @@ Oponašanje Nedovoljeno obnašanje Drugo + Nadlegovanje Neznano Ikona releja Avtor neznan @@ -89,6 +90,13 @@ Prijavi se s privatnim ključem, za prikaz skritih besed in stavkov Najlepša hvala! Vsota v Sat Pošlji Sat + Orodje za Emoji-je s skrivnostmi + Izberi Emoji za pošiljanje s skritim sporočilom + Sprejem skrivnosti + Moja skrita sporočila + Vidni prefiksi + 😎 + Dodaj v zapisek "Napaka pri razčlembi predogleda za %1$s : %2$s" "Predogled kartne slike za %1$s" Nov kanal @@ -98,6 +106,7 @@ Prijavi se s privatnim ključem, za prikaz skritih besed in stavkov Opis "O nas.. " Kaj imaš v mislih? + Napiši sporočilo... Pošlji Shrani Ustvari @@ -114,6 +123,7 @@ Prijavi se s privatnim ključem, za prikaz skritih besed in stavkov Globalni vir Išči vir Dodaj rele + Ime Prikazano ime Moje prikazno ime Janez Slovenc @@ -149,6 +159,7 @@ Prijavi se s privatnim ključem, za prikaz skritih besed in stavkov Pogovori Zapiski Pogovori + Tvoje Galerija "Sledi" "Reportaže" @@ -203,6 +214,7 @@ Prijavi se s privatnim ključem, za prikaz skritih besed in stavkov z opisom in sliko ime kanala spremenjeno v + Nov profil klepeta: opis za in fotografijo Zapusti diff --git a/amethyst/src/play/AndroidManifest.xml b/amethyst/src/play/AndroidManifest.xml index 8783872cb..5426033b5 100644 --- a/amethyst/src/play/AndroidManifest.xml +++ b/amethyst/src/play/AndroidManifest.xml @@ -1,5 +1,6 @@ - + @@ -13,6 +14,11 @@ + + + + diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationReceiverService.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationReceiverService.kt index 0335f8130..05da54353 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationReceiverService.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationReceiverService.kt @@ -26,6 +26,7 @@ import android.util.LruCache import androidx.core.content.ContextCompat import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.RemoteMessage +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.getOrCreateDMChannel import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.getOrCreateZapChannel @@ -36,7 +37,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.launch -import kotlin.time.measureTimedValue class PushNotificationReceiverService : FirebaseMessagingService() { private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) @@ -44,11 +44,9 @@ class PushNotificationReceiverService : FirebaseMessagingService() { // this is called when a message is received override fun onMessageReceived(remoteMessage: RemoteMessage) { - Log.d("Time", "Notification received $remoteMessage") + Log.d("PushNotificationService", "Notification received $remoteMessage") scope.launch(Dispatchers.IO) { - val (value, elapsed) = - measureTimedValue { parseMessage(remoteMessage.data)?.let { receiveIfNew(it) } } - Log.d("Time", "Notification processed in $elapsed") + parseMessage(remoteMessage.data)?.let { receiveIfNew(it) } } } @@ -70,11 +68,11 @@ class PushNotificationReceiverService : FirebaseMessagingService() { override fun onCreate() { super.onCreate() - Log.d("Lifetime Event", "PushNotificationReceiverService.onCreate") + Log.d("PushNotificationService", "PushNotificationReceiverService.onCreate") } override fun onDestroy() { - Log.d("Lifetime Event", "PushNotificationReceiverService.onDestroy") + Log.d("PushNotificationService", "PushNotificationReceiverService.onDestroy") scope.cancel() super.onDestroy() @@ -82,7 +80,11 @@ class PushNotificationReceiverService : FirebaseMessagingService() { override fun onNewToken(token: String) { scope.launch(Dispatchers.IO) { - RegisterAccounts(LocalPreferences.allSavedAccounts()).go(token) + Log.d("PushNotificationService", "PushNotificationReceiverService.onNewToken") + // if the app is running, try to get tor. if not, goes open web. + PushNotificationUtils.checkAndInit(token, LocalPreferences.allSavedAccounts()) { + Amethyst.instance.okHttpClients.getHttpClient(Amethyst.instance.torManager.isSocksReady()) + } notificationManager().getOrCreateZapChannel(applicationContext) notificationManager().getOrCreateDMChannel(applicationContext) } diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationUtils.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationUtils.kt index 0c2726702..56ea9ce76 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationUtils.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationUtils.kt @@ -20,29 +20,48 @@ */ package com.vitorpamplona.amethyst.service.notifications -import android.util.Log import com.google.firebase.messaging.FirebaseMessaging import com.vitorpamplona.amethyst.AccountInfo -import kotlinx.coroutines.CancellationException +import com.vitorpamplona.amethyst.service.retryIfException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.tasks.await +import okhttp3.OkHttpClient object PushNotificationUtils { + var lastToken: String? = null var hasInit: List? = null - suspend fun init(accounts: List) = - with(Dispatchers.IO) { - if (hasInit?.equals(accounts) == true) { - return@with - } - // get user notification token provided by firebase - try { - RegisterAccounts(accounts).go(FirebaseMessaging.getInstance().token.await()) - - hasInit = accounts.toList() - } catch (e: Exception) { - if (e is CancellationException) throw e - Log.e("Firebase token", "failed to get firebase token", e) - } + suspend fun checkAndInit( + accounts: List, + okHttpClient: (String) -> OkHttpClient, + ) = with(Dispatchers.IO) { + val token = FirebaseMessaging.getInstance().token.await() + if (hasInit?.equals(accounts) == true && lastToken == token) { + return@with } + + registerToken(token, accounts, okHttpClient) + } + + suspend fun checkAndInit( + token: String, + accounts: List, + 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, + okHttpClient: (String) -> OkHttpClient, + ) = retryIfException("RegisterAccounts") { + RegisterAccounts(accounts, okHttpClient).go(token) + lastToken = token + hasInit = accounts.toList() + } } diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt index 1c2e86911..4dc9b42f6 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt @@ -27,7 +27,6 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.text.ClickableText import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check import androidx.compose.material3.DropdownMenu @@ -49,10 +48,8 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.core.os.ConfigurationCompat import com.vitorpamplona.amethyst.R @@ -178,54 +175,25 @@ private fun TranslationMessage( Row( modifier = Modifier.fillMaxWidth().padding(top = 5.dp), ) { - val clickableTextStyle = SpanStyle(color = MaterialTheme.colorScheme.lessImportantLink) + val textColor = MaterialTheme.colorScheme.lessImportantLink - val annotatedTranslationString = - buildAnnotatedString { - withStyle(clickableTextStyle) { - pushStringAnnotation("langSettings", true.toString()) - append(stringRes(R.string.translations_auto)) - pop() - } - - append("-${stringRes(R.string.translations_translated_from)} ") - - withStyle(clickableTextStyle) { - pushStringAnnotation("showOriginal", true.toString()) - append(Locale(source).displayName) - pop() - } - - append(" ${stringRes(R.string.translations_to)} ") - - withStyle(clickableTextStyle) { - pushStringAnnotation("showOriginal", false.toString()) - append(Locale(target).displayName) - pop() - } - } - - ClickableText( - text = annotatedTranslationString, + Text( + text = + buildAnnotatedString { + appendLink(stringRes(R.string.translations_auto), textColor) { langSettingsPopupExpanded = !langSettingsPopupExpanded } + append("-${stringRes(R.string.translations_translated_from)} ") + appendLink(Locale(source).displayName, textColor) { onChangeWhatToShow(true) } + append(" ${stringRes(R.string.translations_to)} ") + appendLink(Locale(target).displayName, textColor) { onChangeWhatToShow(false) } + }, style = LocalTextStyle.current.copy( - color = - MaterialTheme.colorScheme.onSurface.copy( - alpha = 0.32f, - ), + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.32f), fontSize = Font14SP, ), overflow = TextOverflow.Visible, maxLines = 3, - ) { spanOffset -> - annotatedTranslationString.getStringAnnotations(spanOffset, spanOffset).firstOrNull()?.also { span -> - if (span.tag == "showOriginal") { - onChangeWhatToShow(span.item.toBoolean()) - } else { - langSettingsPopupExpanded = !langSettingsPopupExpanded - } - } - } + ) DropdownMenu( expanded = langSettingsPopupExpanded, diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModelTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModelTest.kt index 65f4018ee..76053df10 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModelTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModelTest.kt @@ -36,6 +36,7 @@ import io.mockk.mockkObject import io.mockk.unmockkAll import io.mockk.verify import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Before @@ -75,9 +76,12 @@ class NewPostViewModelTest { every { replyingTo.event } returns textNoteEvent every { accountViewModel.userProfile() } returns mockk(relaxed = true) + every { accountViewModel.account.userProfile() } returns mockk(relaxed = true) + every { accountViewModel.account.myEmojis } returns mockk>>(relaxed = true) // Act: Call load with mentions - newPostViewModelUnderTest.load(accountViewModel, replyingTo, quote = null, fork = null, version = null, draft = null) + newPostViewModelUnderTest.init(accountViewModel) + newPostViewModelUnderTest.load(replyingTo, quote = null, fork = null, version = null, draft = null) // Assert // Two mentions should call LocalCache.getOrCreateUser twice @@ -87,9 +91,9 @@ class NewPostViewModelTest { @Test fun `test load with zero mentions`() = runTest { - // Arrange: Setup Note with zero mentions every { accountViewModel.account } returns mockk() + // Arrange: Setup Note with zero mentions val textNoteEvent = mockk(relaxed = true) every { textNoteEvent.mentions() } returns emptyList() every { replyingTo.event } returns textNoteEvent @@ -97,7 +101,7 @@ class NewPostViewModelTest { every { accountViewModel.userProfile() } returns mockk(relaxed = true) // Act: Call load with empty mentions - newPostViewModelUnderTest.load(accountViewModel, replyingTo, quote = null, fork = null, version = null, draft = null) + newPostViewModelUnderTest.load(replyingTo, quote = null, fork = null, version = null, draft = null) // Assert // With no mentions LocalCache.getOrCreateUser should not be called @@ -107,9 +111,9 @@ class NewPostViewModelTest { @Test fun `test load with empty mentions`() = runTest { - // Arrange: Setup empty mentions every { accountViewModel.account } returns mockk() + // Arrange: Setup empty mentions val textNoteEvent = mockk(relaxed = true) every { textNoteEvent.mentions() } returns emptyList() every { replyingTo.event } returns textNoteEvent @@ -117,7 +121,7 @@ class NewPostViewModelTest { every { accountViewModel.userProfile() } returns mockk(relaxed = true) // Act: Call load with empty mentions - newPostViewModelUnderTest.load(accountViewModel, replyingTo, quote = null, fork = null, version = null, draft = null) + newPostViewModelUnderTest.load(replyingTo, quote = null, fork = null, version = null, draft = null) // Assert // Verify LocalCache.getOrCreateUser(it) is not called with empty hex, it will crash the app diff --git a/ammolite/consumer-rules.pro b/ammolite/consumer-rules.pro index a98136082..fd7f2b7db 100644 --- a/ammolite/consumer-rules.pro +++ b/ammolite/consumer-rules.pro @@ -41,6 +41,4 @@ # Keep All enums -keep enum ** { *; } --keep class com.vitorpamplona.ammolite.service.** { *; } --keep class com.vitorpamplona.ammolite.relays.** { *; } --keep class com.vitorpamplona.ammolite.sockets.** { *; } \ No newline at end of file +-keep class com.vitorpamplona.ammolite.** { *; } \ No newline at end of file diff --git a/ammolite/proguard-rules.pro b/ammolite/proguard-rules.pro index a98136082..fd7f2b7db 100644 --- a/ammolite/proguard-rules.pro +++ b/ammolite/proguard-rules.pro @@ -41,6 +41,4 @@ # Keep All enums -keep enum ** { *; } --keep class com.vitorpamplona.ammolite.service.** { *; } --keep class com.vitorpamplona.ammolite.relays.** { *; } --keep class com.vitorpamplona.ammolite.sockets.** { *; } \ No newline at end of file +-keep class com.vitorpamplona.ammolite.** { *; } \ No newline at end of file diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/NostrClient.kt b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/NostrClient.kt index 70b61d5cc..2631384b5 100644 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/NostrClient.kt +++ b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/NostrClient.kt @@ -20,7 +20,9 @@ */ package com.vitorpamplona.ammolite.relays +import android.system.Os.close import android.util.Log +import androidx.core.app.PendingIntentCompat.send import com.vitorpamplona.ammolite.service.checkNotInMainThread import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.RelayState @@ -51,6 +53,11 @@ class NostrClient( fun getRelay(url: String): Relay? = relayPool.getRelay(url) + fun reconnect() { + // Reconnects all relays that may have disconnected + relayPool.requestAndWatch() + } + @Synchronized fun reconnect( relays: Array?, @@ -74,6 +81,9 @@ class NostrClient( relayPool.requestAndWatch() this.relays = newRelays.toTypedArray() } + } else { + // Reconnects all relays that may have disconnected + relayPool.requestAndWatch() } } else { if (this.relays.isNotEmpty()) { diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/Relay.kt b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/Relay.kt index 2a6b27ad2..09ce54737 100644 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/Relay.kt +++ b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/Relay.kt @@ -101,7 +101,7 @@ class Relay( val relaySubFilter = RelaySubFilter(url, activeTypes, subs) val inner = - SimpleClientRelay(url, socketBuilderFactory.build(forceProxy), relaySubFilter, this@Relay, RelayStats.get(url)) + SimpleClientRelay(url, socketBuilderFactory.build(url, forceProxy), relaySubFilter, this@Relay, RelayStats.get(url)) val brief = RelayBriefInfoCache.get(url) diff --git a/build.gradle b/build.gradle index 6727778a6..3646b9e71 100644 --- a/build.gradle +++ b/build.gradle @@ -49,6 +49,6 @@ tasks.register('installGitHook', Copy) { from new File(rootProject.rootDir, 'git-hooks/pre-commit') from new File(rootProject.rootDir, 'git-hooks/pre-push') into { new File(rootProject.rootDir, '.git/hooks') } - fileMode 0777 + filePermissions { unix(0777) } } tasks.getByPath(':amethyst:preBuild').dependsOn installGitHook \ No newline at end of file diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/base64Image/Base64Image.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/base64Image/Base64Image.kt new file mode 100644 index 000000000..3317cdd91 --- /dev/null +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/base64Image/Base64Image.kt @@ -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.commons.base64Image + +import android.R.attr.data +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import com.vitorpamplona.amethyst.commons.richtext.RichTextParser +import java.util.Base64 +import java.util.regex.Pattern + +class Base64Image { + companion object { + val pattern = Pattern.compile("data:image/(${RichTextParser.Companion.imageExtensions.joinToString(separator = "|") { it } });base64,([a-zA-Z0-9+/]+={0,2})") + + fun isBase64(content: String): Boolean { + val matcher = pattern.matcher(content) + return matcher.find() + } + + fun toBitmap(content: String): Bitmap { + val matcher = pattern.matcher(data.toString()) + + if (matcher.find()) { + val base64String = matcher.group(2) + + val byteArray = Base64.getDecoder().decode(base64String) + return BitmapFactory.decodeByteArray(byteArray, 0, byteArray.size) + } + + throw Exception("Unable to convert base64 to image $content") + } + } +} diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/CustomHashTagIcons.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/CustomHashTagIcons.kt index 022f4914c..15917ff45 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/CustomHashTagIcons.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/CustomHashTagIcons.kt @@ -43,6 +43,7 @@ val CustomHashTagIcons.AllIcons: ____KtList Cashu, Grownostr, Footstr, + Flowerstr, Btc, Zap, Tunestr, diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Flowerstr.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Flowerstr.kt new file mode 100644 index 000000000..2d7947d4d --- /dev/null +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Flowerstr.kt @@ -0,0 +1,180 @@ +/** + * 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.commons.hashtags + +import androidx.compose.foundation.Image +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType.Companion.NonZero +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap.Companion.Butt +import androidx.compose.ui.graphics.StrokeJoin.Companion.Miter +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.ImageVector.Builder +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +@Preview +@Composable +fun CustomHashTagIconsFlowerstrPreview() { + Image( + painter = + rememberVectorPainter( + CustomHashTagIcons.Flowerstr, + ), + contentDescription = "", + ) +} + +public val CustomHashTagIcons.Flowerstr: ImageVector + get() { + if (customHashTagIconsFlowerstr != null) { + return customHashTagIconsFlowerstr!! + } + customHashTagIconsFlowerstr = + Builder( + name = "Flowerstr", + defaultWidth = 1145.dp, + defaultHeight = 1780.dp, + viewportWidth = 1145f, + viewportHeight = 1780f, + ).apply { + path( + fill = SolidColor(Color(0xFF42963c)), + stroke = SolidColor(Color(0xFF306d2c)), + strokeLineWidth = 0.689972f, + strokeLineCap = Butt, + strokeLineJoin = Miter, + strokeLineMiter = 4.0f, + pathFillType = NonZero, + ) { + moveToRelative(602.8f, 1767.7f) + curveToRelative(-58.2f, 4.7f, -59.2f, -13.3f, -47.2f, -37.5f) + curveToRelative(23.1f, -147.5f, 12.0f, -295.5f, -6.0f, -443.0f) + curveToRelative(-11.0f, -111.5f, -14.1f, -222.9f, -11.0f, -334.9f) + curveToRelative(42.2f, -2.8f, 67.2f, 1.9f, 52.2f, 25.1f) + curveToRelative(-5.0f, 158.4f, 12.0f, 316.8f, 28.1f, 474.3f) + curveToRelative(6.0f, 104.8f, 1.0f, 210.1f, -16.1f, 314.9f) + close() + } + path( + fill = SolidColor(Color(0xFF42963c)), + stroke = SolidColor(Color(0xFF306d2c)), + strokeLineWidth = 0.789335f, + strokeLineCap = Butt, + strokeLineJoin = Miter, + strokeLineMiter = 4.0f, + pathFillType = NonZero, + ) { + moveToRelative(592.0f, 1497.3f) + curveToRelative(17.0f, 82.0f, 31.7f, 166.2f, -1.1f, 247.7f) + curveToRelative(-12.5f, 55.6f, -117.7f, 21.5f, -140.4f, -12.1f) + arcTo(3489.7f, 1697.0f, 0.0f, false, true, 168.6f, 1370.2f) + curveTo(81.5f, 1212.7f, 15.8f, 1050.9f, 2.2f, 887.4f) + curveTo(-5.7f, 861.6f, 12.4f, 787.3f, 78.1f, 836.8f) + curveTo(181.1f, 922.7f, 247.8f, 1017.3f, 328.2f, 1108.7f) + curveToRelative(99.6f, 122.7f, 217.3f, 244.4f, 260.3f, 375.9f) + lineToRelative(2.3f, 6.6f) + close() + } + path( + fill = SolidColor(Color(0xFF42963c)), + stroke = SolidColor(Color(0xFF306d2c)), + strokeLineWidth = 0.766261f, + strokeLineCap = Butt, + strokeLineJoin = Miter, + strokeLineMiter = 4.0f, + pathFillType = NonZero, + ) { + moveToRelative(587.0f, 1501.8f) + curveToRelative(-16.0f, 82.2f, -29.8f, 166.7f, 1.1f, 248.4f) + curveToRelative(11.7f, 55.7f, 110.6f, 21.5f, 131.9f, -12.1f) + curveToRelative(122.3f, -113.7f, 195.7f, -239.0f, 264.9f, -363.7f) + curveToRelative(81.9f, -157.9f, 143.6f, -320.1f, 156.4f, -484.1f) + curveToRelative(7.4f, -25.9f, -9.6f, -100.5f, -71.3f, -50.8f) + curveToRelative(-96.8f, 86.1f, -159.6f, 181.0f, -235.1f, 272.7f) + curveToRelative(-93.6f, 123.1f, -204.2f, 245.1f, -244.7f, 377.0f) + lineToRelative(-2.1f, 6.6f) + close() + } + path( + fill = SolidColor(Color(0xFF9545db)), + stroke = null, + strokeLineWidth = 0.0f, + strokeLineCap = Butt, + strokeLineJoin = Miter, + strokeLineMiter = 4.0f, + pathFillType = NonZero, + ) { + moveToRelative(270.0f, 923.0f) + curveToRelative(95.0f, 74.0f, 223.0f, 73.0f, 337.0f, 67.0f) + curveToRelative(102.0f, -3.0f, 222.0f, -12.0f, 290.0f, -98.0f) + curveToRelative(90.0f, -111.0f, 98.0f, -265.0f, 76.0f, -401.0f) + arcTo(612.0f, 612.0f, 0.0f, false, false, 636.0f, 32.0f) + curveTo(590.0f, 3.0f, 539.0f, -8.0f, 494.0f, 28.0f) + arcTo(622.0f, 622.0f, 0.0f, false, false, 171.0f, 438.0f) + curveToRelative(-37.0f, 141.0f, -36.0f, 303.0f, 46.0f, 429.0f) + curveToRelative(15.0f, 22.0f, 32.0f, 41.0f, 52.0f, 57.0f) + close() + } + path( + fill = SolidColor(Color(0xFF965bd6)), + stroke = SolidColor(Color(0xFF9545db)), + strokeLineWidth = 1.0f, + strokeLineCap = Butt, + strokeLineJoin = Miter, + strokeLineMiter = 4.0f, + pathFillType = NonZero, + ) { + moveTo(551.0f, 976.0f) + curveTo(401.0f, 894.0f, 594.0f, 503.0f, 858.0f, 241.0f) + curveTo(975.0f, 125.0f, 1113.0f, 28.0f, 1140.0f, 40.0f) + curveToRelative(27.0f, 13.0f, -87.0f, 122.0f, -132.0f, 308.0f) + curveToRelative(-27.0f, 112.0f, 2.0f, 121.0f, -26.0f, 287.0f) + curveToRelative(-25.0f, 152.0f, -39.0f, 229.0f, -116.0f, 284.0f) + curveToRelative(-84.0f, 60.0f, -240.0f, 98.0f, -315.0f, 58.0f) + close() + } + path( + fill = SolidColor(Color(0xFF965bd6)), + stroke = SolidColor(Color(0xFF9545db)), + strokeLineWidth = 1.0f, + strokeLineCap = Butt, + strokeLineJoin = Miter, + strokeLineMiter = 4.0f, + pathFillType = NonZero, + ) { + moveTo(571.0f, 976.0f) + curveTo(716.0f, 897.0f, 530.0f, 519.0f, 276.0f, 266.0f) + curveTo(163.0f, 154.0f, 31.0f, 60.0f, 5.0f, 72.0f) + curveToRelative(-26.0f, 12.0f, 83.0f, 118.0f, 127.0f, 297.0f) + curveToRelative(26.0f, 108.0f, -2.0f, 117.0f, 25.0f, 277.0f) + curveToRelative(24.0f, 147.0f, 37.0f, 221.0f, 112.0f, 274.0f) + curveToRelative(80.0f, 58.0f, 231.0f, 95.0f, 303.0f, 55.0f) + close() + } + }.build() + return customHashTagIconsFlowerstr!! + } + +private var customHashTagIconsFlowerstr: ImageVector? = null diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt index 398e3eb5b..65cbdab2a 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt @@ -24,6 +24,7 @@ import android.util.Log import android.util.Patterns import com.linkedin.urls.detection.UrlDetector import com.linkedin.urls.detection.UrlDetectorOptions +import com.vitorpamplona.amethyst.commons.base64Image.Base64Image import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder import com.vitorpamplona.quartz.experimental.inlineMetadata.Nip54InlineMetadata import com.vitorpamplona.quartz.nip02FollowList.ImmutableListOfLists @@ -103,11 +104,6 @@ class RichTextParser { } } - private fun checkBase64(content: String): Boolean { - val matcher = base64contentPattern.matcher(content) - return matcher.find() - } - fun parseValidUrls(content: String): LinkedHashSet { val urls = UrlDetector(content, UrlDetectorOptions.Default).detect() @@ -239,9 +235,7 @@ class RichTextParser { ): Segment { if (word.isEmpty()) return RegularTextSegment(word) - if (word.startsWith("data:image/")) { - if (checkBase64(word)) return Base64Segment(word) - } + if (word.startsWith("data:image/") && Base64Image.isBase64(word)) return Base64Segment(word) if (images.contains(word)) return ImageSegment(word) @@ -361,8 +355,6 @@ class RichTextParser { val imageExtensions = imageExt + imageExt.map { it.uppercase() } val videoExtensions = videoExt + videoExt.map { it.uppercase() } - val base64contentPattern = Pattern.compile("data:image/(${imageExtensions.joinToString(separator = "|") { it } });base64,([a-zA-Z0-9+/]+={0,2})") - val tagIndex = Pattern.compile("\\#\\[([0-9]+)\\](.*)") val hashTagsPattern: Pattern = Pattern.compile("#([^\\s!@#\$%^&*()=+./,\\[{\\]};:'\"?><]+)(.*)", Pattern.CASE_INSENSITIVE) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b49eb58ab..c1fdf46e5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,10 +13,10 @@ benchmark = "1.3.4" benchmarkJunit4 = "1.3.4" biometricKtx = "1.2.0-alpha05" coil = "3.1.0" -composeBom = "2025.03.01" -coreKtx = "1.15.0" +composeBom = "2025.04.00" +coreKtx = "1.16.0" espressoCore = "3.6.1" -firebaseBom = "33.11.0" +firebaseBom = "33.12.0" fragmentKtx = "1.8.6" gms = "4.4.2" jacksonModuleKotlin = "2.18.3" @@ -25,7 +25,7 @@ jtorctl = "0.4.5.7" junit = "4.13.2" kotlin = "2.1.0" kotlinxCollectionsImmutable = "0.3.8" -kotlinxSerialization = "1.8.0" +kotlinxSerialization = "1.8.1" kotlinxSerializationPlugin = "2.0.0" languageId = "17.0.6" lazysodiumAndroid = "5.1.0" @@ -33,22 +33,22 @@ lifecycleRuntimeKtx = "2.8.7" lightcompressor = "1.3.2" markdown = "077a2cde64" media3 = "1.6.0" -mockk = "1.13.17" -kotlinx-coroutines-test = "1.10.1" +mockk = "1.14.0" +kotlinx-coroutines-test = "1.10.2" navigationCompose = "2.8.9" okhttp = "5.0.0-alpha.14" runner = "1.6.2" rfc3986 = "0.1.2" secp256k1KmpJniAndroid = "0.17.1" -securityCryptoKtx = "1.1.0-alpha06" +securityCryptoKtx = "1.1.0-alpha07" spotless = "6.25.0" torAndroid = "0.4.8.12" translate = "17.0.3" unifiedpush = "2.3.1" urlDetector = "0.1.23" -vico-charts = "2.1.1" +vico-charts = "2.1.2" zelory = "3.0.1" -zoomable = "2.4.0" +zoomable = "2.5.0" zxing = "3.5.3" zxingAndroidEmbedded = "4.3.0" windowCoreAndroid = "1.3.0" diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip03Timestamp/ots/OtsTest.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip03Timestamp/ots/OtsTest.kt index 7aa3095f8..e33bf3d6b 100644 --- a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip03Timestamp/ots/OtsTest.kt +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip03Timestamp/ots/OtsTest.kt @@ -34,6 +34,8 @@ import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit class OtsTest { + val resolver = OtsResolver() + val otsEvent = "{\"content\":\"AE9wZW5UaW1lc3RhbXBzAABQcm9vZgC/ieLohOiSlAEIqCiiW0FlsU9lqK5f1A+cL6CGJ1Ah4V/A1yNJY/stUE3wECJz6ng/QxU5Z6xwaMx97qkI//AQqJv8bEMrGTplGWRv5qm4DgjxIHkcQqzpL0Fjr9VBAAijDe0IsQYpOhw1SIjZIgQa6i16CPEEZck7CvAIxR0AloJzCZoAg9/jDS75DI4uLWh0dHBzOi8vYWxpY2UuYnRjLmNhbGVuZGFyLm9wZW50aW1lc3RhbXBzLm9yZ//wEOwPtjIkKI1hmtv9t1kuxZcI8QRlyTsK8Ahl0wrCSggZzgCD3+MNLvkMjiwraHR0cHM6Ly9ib2IuYnRjLmNhbGVuZGFyLm9wZW50aW1lc3RhbXBzLm9yZ//wEE1dVGa8JCuf2ek0c5ybDKII8SCBoVz8Sal45Kd1O8STWIGJTcl5JPtAZBZitqk3BE9MqAjxBGXJOwrwCHoGVgAZi9q9AIPf4w0u+QyOKShodHRwczovL2Zpbm5leS5jYWxlbmRhci5ldGVybml0eXdhbGwuY29t8BAFZFXFYg7DJJ0OzjmJ0FKWCPEEZck7CvAI0M49IcBR5bf/AIPf4w0u+QyOIyJodHRwczovL2J0Yy5jYWxlbmRhci5jYXRhbGxheHkuY29tCPEgoE3IfYTmxxo4W/x/QYp/NGX6Wu93gSQkwbpjpOhZORcI8SDWLLurVQaXHdUuwivCfTfuxYCaq+AzypSGqLDAVocrEgjwIBfgjta16y13Gp4etQOCa9YiKEcM+/9AieG/vZolr3IDCPAgMR2zFCb384CEi8tVuI2fHgLT3I9zpe7oqJTzCcEqxWEI8SAJSdgeeosr7IxdOt8r7f0ipWc8FI6GAhgep8zSRgWikAjxIGxYmtCsC79Tx4z4YsT1WuMo+ycMkwhGQsQltF597cchCPAgCLrBf9vR1Aex6yY+vSkXAvLjMKdMqM/a1g8zNPwLeJcI8SBDCbTk4CczTuiIyZeUyYRVh31BZdjaSd2nU/pBQxu+6QjwIOwqE9/WqGC6CHH0i+tr7edvYX5PstSDf08KmnMqsqCoCPEgQIdEBg358vfek3Qjfyrgl51iCU6WUWmThsGLPDTcB0QI8SAX4dp64iI8pBx+zBqAQwUN6XgZ1cEfT8+2vha/9I1vzwjxWQEAAAAB8YJxvxJJth8OnxIV6UOXveIZAcJPTHcAkWgnucpuYqYAAAAAAP3///8CRhoMAAAAAAAWABTUUZK82uAvbU3vVyaaPIOddZBicwAAAAAAAAAAImog8ARCqgwACAjwIB9TcMDLhzgeS1Uw647lNvCfWECkkUvfrrOe6nay0sGdCAjwICYfs90sbPggoMICyOHGYbmOzop2L9mlnh4xqiBLY7yPCAjxINDiVWOBHnRmGJleQdB9myvJAJbNJ9kciZlTOkgJy89mCAjwIHfxqDLdwycj1Vtyth2CaSDdLQwiey9oV6Xov4stLpWNCAjxIGurJYpKJnKp9+y7MAdC+gXgHOiAu5P3RRUFW9l5hGaCCAjxICXqs9hdY0QMP/MNeqlt6s7xaIYtEXZ1CLvou5gaZNEICAjxIHdYxVeI76NXbT2zHcv6lw+v819Ooib7KWxc1GAsiX2fCAjwICPWdi3uBXOlIdmYi+V9C7wAqLyGE4DMoHD+GtvLizqiCAjwIOF2vENtWN5okEMMS+JSf1SGTY9yYP9j0JjXLbC1s+N1CAjxINWsgCtsPxhRNbe372k8/20WDbiL9e8934hGF256DvRECAjwII3mi+Li06j10ORxg0dYMkcsyGb115Jiqq1YEV3K/u+aCAgABYiWDXPXGQEDw9Qy\",\"created_at\":1707690688,\"id\":\"759f9da5846e936fab06766a524b36ba71c03bbc69ad0944fb8ee4bb1f3dd705\",\"kind\":1040,\"pubkey\":\"82fbb08c8ef45c4d71c88368d0ae805bc62fb92f166ab04a0b7a0c83d8cbc29a\",\"sig\":\"07c7896c8cbb97b5d7483097590c9d31b73f35c1ad9e752002bb5c1776cbd852e1d32704333d6930c9bc3e40f8b899a1f2e9f91cc3bf797d86acdecba7792576\",\"tags\":[[\"e\",\"a828a25b4165b14f65a8ae5fd40f9c2fa086275021e15fc0d7234963fb2d504d\"],[\"p\",\"595ca8eaace5899cb6ab7e2542bfc972136376f2eabc09287f1857eb8f167e53\"],[\"alt\",\"NIP-03 time stamp\"]]}" val otsEvent2 = "{\"content\":\"AE9wZW5UaW1lc3RhbXBzAABQcm9vZgC/ieLohOiSlAEIqGNPU2jhd4no+zg2ytDkuf5PIoivr8KHI8BL68aKGNbwENyCNtiEN98IzIZgEu3cl6YI//AQ6TkSRd3BTGhDHCK1KkJc+AjxIAHaizG++NNL3Vm13BJrIhT7Br6tEYpb0TVRGaadgiUMCPAgOSDREH9v1Y50UHu79LfC4Lcd9WklQJzRQpw+Unb/pyII8QRltDD58AgqrxfAVrLw7QCD3+MNLvkMji4taHR0cHM6Ly9hbGljZS5idGMuY2FsZW5kYXIub3BlbnRpbWVzdGFtcHMub3Jn//AQQMq/CLpGwY60nmddPS7OVgjxIDKxqd9nl+Mej41vP52Wd7gv7004r3n1rFGDObS8icRvCPAgH9TB/kwvXJEEw+h9Ce6fLaI3MORjtTEge0GbAefT6W4I8QRltDD58AhRcoU3gAo/swCD3+MNLvkMjiwraHR0cHM6Ly9ib2IuYnRjLmNhbGVuZGFyLm9wZW50aW1lc3RhbXBzLm9yZ//wECWtWsKo0uvSr8BYonjs3DEI8CBlsh2ng1Spl0K4oStYElGuMJsjd2uo5nXB+apo5A7ipwjxIM8oxynBwNA+QS/X7Ebtl1kyhFgfoOQioASNfCBzZ4gaCPEEZbQw+fAId6Yd5cw5gioAg9/jDS75DI4pKGh0dHBzOi8vZmlubmV5LmNhbGVuZGFyLmV0ZXJuaXR5d2FsbC5jb23wEJmPzXQbxv0AFTIyjTWjMskI8CAurbkrfrBtlinZXSDxj+m/oIkze57hGjTSxu1Xs87XYQjwIPk/LMD0zIgKoEE2dfeoYrrdHuO6dwmghTwUFajH2QzkCPEEZbQw+fAIE+Pq1/Wmdpj/AIPf4w0u+QyOIyJodHRwczovL2J0Yy5jYWxlbmRhci5jYXRhbGxheHkuY29tCPAgB2CbqkV7VpjRKIl3Ea6cBmB/EHcSN/YCgcc1E+mc07QI8CAfpkZ2Hh4Rukz3x4il3tZqQtlDlbna+I6so2t2YSEmMQjwIJOv32jbsMa2HJwpleRCKLEhgYOoHCSfpv1ZO0YNNNFsCPAgLMM7eFfCjokQfU4gdU5WpG/wBLkO9lDRF0GktL6ujt8I8SCRxJ0bC1PQ8qFmI/1jh8AS5d1/6VRJNMt1Hz41QmNr3QjwIBmgrKBF+OZ3y+XOMv2E7IZ4WwLr2u2H+ehsBfy7cPlICPEgm4ZMCSXzZVWu40d+zk2edaur6KOauo8X7V2KaFBR1VoI8SBKVVOiyq6IFqGn/15kLwk7L8upMAIZ0znjhYxYqSTQCQjwIMBD1twPZ33GxbwTiuOCeJPkoP++6R2wYpCii8UBTdgwCPAg84VkgMXwrt2xxRoeC1/6CtsFctki+w3m8Rs5/6g/IhEI8CCnzZQDhJyicX7bS7U8PMUObuC9Y4TXe+4THoXBMMXkxwjxIMZ0oAvshpcwowR3qPEDbwKZ6B4NPSU4Hz/+4PnD74gnCPAgIfLZEKqAvkMNXfakXoNq1UVqGSzL4Z86z5GzUfbvw8UI8SC8KoIeLvjd4vJ/xhNVphakPRd80YKeNkeYEuVH8k2EtAjwIPRtinLLxzt8iuw0XZtpTDzEstZOTNYVm+Bi3fEzdeIuCPFZAQAAAAE8vsasINN5DKon0KakX2HNdCB126ZLKXrw4PfvyEfqbwAAAAAA/f///wLPGw8AAAAAABYAFCvgxlh6msa4ZOtgvlc5KiZCx7IvAAAAAAAAAAAiaiDwBKygDAAICPEgB/2DJ3s6gMky/PceGZocTFRXjZUiCCAhHGYwQk/8yrUICPEgDuSd6+PJHUMuEHyLcKFxw7xfvRHRfInjkV3/Zy3BxqAICPEgZDgQ+4VXzlOIkGoO8EVxDgs2cWaeh4EEiaqa/y50gKAICPAgFI+zIuYcMF69GmPQVsXa9oy8eng7MeRZdIxArQyeX3oICPEgX5HYIuImpiSTEapgEssEW4l+W+4aRfNCG3pZf7z0hCoICPEgPkAbOSjFdtS4NT7MXgMYVQoQhI1JZtdFxUu4J3NTt7IICPEguW3qyuyGjctu5d9rM9P9ZCs/ZK4vAc+z21b9ygklgWAICPEgu4e2645xtvGhI1Zzuiv23vRhwE8uC9vj1TAgNg/C8UcICPAgwoQX8X0nY4HoQLRsJ0z8JCWQDzRh2iL2QXEb8z3gbjIICPAgzTwlPRtStsLJWhz3Q/0l8tMnrPSHVuh+zCiGk95dW2MICPAgGcBEuYZyzFNapHOfnJ9Q515QzO2VbIRhlVI0vIhd4jwICPAgidRMoM2pA+KmVJenVrLcbollsbUg9lL9bmv1C1dSxswICPAgZinGakwhbHdanTaRJeBkEUlbhfNokvj8b5KneyG+wzIICAAFiJYNc9cZAQOtwTI=\",\"created_at\":1706324334,\"id\":\"2ad074ddb7724eb13b4244b49cf2321b1057f37fdf8ce102e6329b839cf763a9\",\"kind\":1040,\"pubkey\":\"82fbb08c8ef45c4d71c88368d0ae805bc62fb92f166ab04a0b7a0c83d8cbc29a\",\"sig\":\"ad7274bb32ba9e9cfdbd52f4887e8a2fda1047c75a7185b2ab7ff254ebac14ed48a2b60737494d655e24c9400eeeec7e29293a77bfcaafaecd94b350c9a2c22b\",\"tags\":[[\"e\",\"a8634f5368e17789e8fb3836cad0e4b9fe4f2288afafc28723c04bebc68a18d6\"],[\"p\",\"c31e22c3715c1bde5608b7e0d04904f22f5fc453ba1806d21c9f2382e1e58c6c\"],[\"alt\",\"NIP-03 time stamp\"]]}" 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\"}" @@ -43,22 +45,22 @@ class OtsTest { @Test fun verifyNostrEvent() { val ots = Event.fromJson(otsEvent) as OtsEvent - println(OtsResolver.info(ots.otsByteArray())) - assertEquals(1707688818L, ots.verify()) + println(resolver.info(ots.otsByteArray())) + assertEquals(1707688818L, ots.verify(resolver)) } @Test fun verifyNostrEvent2() { val ots = Event.Companion.fromJson(otsEvent2) as OtsEvent - println(OtsResolver.info(ots.otsByteArray())) - assertEquals(1706322179L, ots.verify()) + println(resolver.info(ots.otsByteArray())) + assertEquals(1706322179L, ots.verify(resolver)) } @Test fun verifyNostrPendingEvent() { val ots = Event.Companion.fromJson(otsPendingEvent) as OtsEvent - println(OtsResolver.info(ots.otsByteArray())) - assertEquals(null, ots.verify()) + println(resolver.info(ots.otsByteArray())) + assertEquals(null, ots.verify(resolver)) val eventId = ots.digestEventId() ?: run { @@ -66,7 +68,7 @@ class OtsTest { return } - val upgraded = OtsEvent.upgrade(ots.otsByteArray(), eventId) + val upgraded = OtsEvent.upgrade(ots.otsByteArray(), eventId, resolver) assertNotNull(upgraded) @@ -83,9 +85,9 @@ class OtsTest { Assert.assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) println(newOts!!.toJson()) - println(OtsResolver.info(newOts!!.otsByteArray())) + println(resolver.info(newOts.otsByteArray())) - assertEquals(1708879025L, newOts!!.verify()) + assertEquals(1708879025L, newOts.verify(resolver)) } @Test @@ -95,7 +97,7 @@ class OtsTest { val countDownLatch = CountDownLatch(1) - signer.sign(OtsEvent.build(otsEvent2Digest, OtsEvent.stamp(otsEvent2Digest))) { + signer.sign(OtsEvent.build(otsEvent2Digest, OtsEvent.stamp(otsEvent2Digest, resolver))) { ots = it countDownLatch.countDown() } @@ -103,8 +105,8 @@ class OtsTest { Assert.assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)) println(ots!!.toJson()) - println(OtsResolver.info(ots!!.otsByteArray())) + println(resolver.info(ots.otsByteArray())) - assertEquals(null, ots!!.verify()) + assertEquals(null, ots.verify(resolver)) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/SimpleClientRelay.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/SimpleClientRelay.kt index 9a373862a..02978d479 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/SimpleClientRelay.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/SimpleClientRelay.kt @@ -35,6 +35,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.AuthCmd import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CloseCmd import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocket import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocketListener @@ -312,10 +313,7 @@ class SimpleClientRelay( if (isConnectionStarted()) { if (isReady) { if (filters.isNotEmpty()) { - writeToSocket( - com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd - .toJson(requestId, filters), - ) + writeToSocket(ReqCmd.toJson(requestId, filters)) afterEOSEPerSubscription[requestId] = false } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/sockets/WebsocketBuilderFactory.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/sockets/WebsocketBuilderFactory.kt index 1cbcdddd9..5be40adee 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/sockets/WebsocketBuilderFactory.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/sockets/WebsocketBuilderFactory.kt @@ -21,5 +21,8 @@ package com.vitorpamplona.quartz.nip01Core.relay.sockets interface WebsocketBuilderFactory { - fun build(forceProxy: Boolean): WebsocketBuilder + fun build( + url: String, + forceProxy: Boolean, + ): WebsocketBuilder } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/OtsEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/OtsEvent.kt index bcd82b3fb..d6f236275 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/OtsEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/OtsEvent.kt @@ -51,27 +51,30 @@ class OtsEvent( fun otsByteArray(): ByteArray = decodeOtsState(content) - fun cacheVerify(): VerificationState = VerificationStateCache.cacheVerify(this) + fun verifyState(resolver: OtsResolver): VerificationState = digestEventId()?.let { verify(otsByteArray(), it, resolver) } ?: VerificationState.Error("Digest Not found") - fun verifyState(): VerificationState = digestEventId()?.let { verify(otsByteArray(), it) } ?: VerificationState.Error("Digest Not found") - - fun verify(): Long? = (verifyState() as? VerificationState.Verified)?.verifiedTime + fun verify(resolver: OtsResolver): Long? = (verifyState(resolver) as? VerificationState.Verified)?.verifiedTime companion object { const val KIND = 1040 const val ALT = "Opentimestamps Attestation" - fun stamp(eventId: HexKey) = OtsResolver.stamp(eventId.hexToByteArray()) + fun stamp( + eventId: HexKey, + resolver: OtsResolver, + ) = resolver.stamp(eventId.hexToByteArray()) fun upgrade( otsState: ByteArray, eventId: HexKey, - ) = OtsResolver.upgrade(otsState, eventId.hexToByteArray()) + resolver: OtsResolver, + ) = resolver.upgrade(otsState, eventId.hexToByteArray()) fun verify( otsState: ByteArray, eventId: HexKey, - ) = OtsResolver.verify(otsState, eventId.hexToByteArray()) + resolver: OtsResolver, + ) = resolver.verify(otsState, eventId.hexToByteArray()) fun encodeOtsState(otsState: ByteArray) = Base64.getEncoder().encodeToString(otsState) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/OtsResolver.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/OtsResolver.kt index 384c46e59..e7bff90eb 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/OtsResolver.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/OtsResolver.kt @@ -21,7 +21,9 @@ package com.vitorpamplona.quartz.nip03Timestamp import android.util.Log +import com.vitorpamplona.quartz.nip03Timestamp.ots.BitcoinExplorer import com.vitorpamplona.quartz.nip03Timestamp.ots.BlockstreamExplorer +import com.vitorpamplona.quartz.nip03Timestamp.ots.CalendarBuilder import com.vitorpamplona.quartz.nip03Timestamp.ots.CalendarPureJavaBuilder import com.vitorpamplona.quartz.nip03Timestamp.ots.DetachedTimestampFile import com.vitorpamplona.quartz.nip03Timestamp.ots.Hash @@ -31,13 +33,11 @@ import com.vitorpamplona.quartz.nip03Timestamp.ots.exceptions.UrlException import com.vitorpamplona.quartz.nip03Timestamp.ots.op.OpSHA256 import kotlinx.coroutines.CancellationException -object OtsResolver { - // default config - var ots = - OpenTimestamps( - BlockstreamExplorer(), - CalendarPureJavaBuilder(), - ) +class OtsResolver( + explorer: BitcoinExplorer = BlockstreamExplorer(), + calendarBuilder: CalendarBuilder = CalendarPureJavaBuilder(), +) { + val ots: OpenTimestamps = OpenTimestamps(explorer, calendarBuilder) fun info(otsState: ByteArray): String = ots.info(DetachedTimestampFile.deserialize(otsState)) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/VerificationStateCache.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/VerificationStateCache.kt index 98d7ce76a..9c31d011f 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/VerificationStateCache.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip03Timestamp/VerificationStateCache.kt @@ -24,21 +24,24 @@ import android.util.LruCache import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.utils.TimeUtils -object VerificationStateCache { +class VerificationStateCache { private val cache = LruCache(200) - fun cacheVerify(event: OtsEvent): VerificationState = + fun cacheVerify( + event: OtsEvent, + resolverBuilder: () -> OtsResolver, + ): VerificationState = when (val verif = cache[event.id]) { is VerificationState.Verified -> verif is VerificationState.NetworkError -> { // try again in 5 mins if (verif.time < TimeUtils.fiveMinutesAgo()) { - event.verifyState().also { cache.put(event.id, it) } + event.verifyState(resolverBuilder()).also { cache.put(event.id, it) } } else { verif } } is VerificationState.Error -> verif - else -> event.verifyState().also { cache.put(event.id, it) } + else -> event.verifyState(resolverBuilder()).also { cache.put(event.id, it) } } }