From 6ff9460243aee013d6f946fb154e91b7b65c81c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 15:28:20 +0000 Subject: [PATCH 01/50] refactor: move the DataStore preference layer into commons (KMP) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 1 of the SharedPreferences -> DataStore migration. androidx.datastore:datastore-preferences 1.2.1 publishes android, jvm, ios, linux and macos variants plus common metadata, so the preference layer does not need a port interface to serve the JVM app — the implementation itself is portable. Moves the dependency from commons' androidMain to commonMain and the storage infrastructure with it, using the okio-based `createWithPath` factory because the `java.io.File` overloads are absent on Apple targets. Placement follows what each piece actually needs: - commonMain: DataStoreExt, AccountPreferenceStores (per-account, non-secret). No platform API — the root directory is injected, so Android passes filesDir, desktop its data dir, tests a temp folder. - jvmAndroid: SecretEncryption (expect), EncryptedDataStore, AccountSecretsEncryptedStores. Android and desktop are the two targets that store secrets today; an Apple actual belongs with the first iOS build that needs one rather than stubbed here where nothing exercises it. - androidMain: the existing AndroidKeyStore implementation, moved verbatim from amethyst so the Marmot stores' data stays readable. - jvmMain: a new desktop actual — same AES-256-GCM, key in a 0600 file this OS user owns. Weaker custody than a TEE, documented as such. Fixes a real defect in EncryptedDataStore while moving it. `decrypt` read `encryption.decrypt(...).contentToString()`, which renders a ByteArray as "[104, 101, 108]" rather than decoding it, so every value round-tripped to the debug rendering of its own bytes. Nothing caught it because the class had no callers: AccountPreferenceStores and AccountSecretsEncryptedStores were dead code, one reference each (their own declaration). Verified by reintroducing the bug against the new tests — saveAndGetRoundTrips reads "second" back as "[115, 101, 99, 111, 110, 100]". Private keys deliberately do NOT move into AccountSecretsEncryptedStores. commons already has SecureKeyStorage, backed by the OS credential manager and already used by desktopApp; a second, weaker identity-key store would win by being convenient. That store keeps only non-identity secrets. 13 new tests cover the encrypt/decrypt round trip, that values are not stored in plaintext, that GCM never reuses an IV, that the key persists across instances, that a different key cannot decrypt, 0600 permissions, and that a malformed key file is refused rather than silently replaced. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AXvKXakvup4inNFfAhhr4L --- .../marmot/AndroidKeyPackageBundleStore.kt | 6 +- .../model/marmot/AndroidMarmotMessageStore.kt | 6 +- .../model/marmot/AndroidMlsGroupStateStore.kt | 6 +- .../marmot/AndroidPublishObligationStore.kt | 6 +- .../preferences/AccountPreferenceStores.kt | 83 ---------- commons/build.gradle.kts | 9 +- .../preferences/SecretEncryption.android.kt | 10 +- .../preferences/AccountPreferenceStores.kt | 68 ++++++++ .../model/preferences/DataStoreExt.kt | 41 +++-- .../AccountSecretsEncryptedStores.kt | 56 ++++--- .../model/preferences/EncryptedDataStore.kt | 52 +++---- .../model/preferences/SecretEncryption.kt | 41 +++++ .../model/preferences/SecretEncryption.jvm.kt | 146 ++++++++++++++++++ .../preferences/EncryptedDataStoreTest.kt | 134 ++++++++++++++++ .../model/preferences/SecretEncryptionTest.kt | 118 ++++++++++++++ 15 files changed, 599 insertions(+), 183 deletions(-) delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/AccountPreferenceStores.kt rename amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/KeyStoreEncryption.kt => commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/SecretEncryption.android.kt (96%) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountPreferenceStores.kt rename {amethyst/src/main/java/com/vitorpamplona/amethyst => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons}/model/preferences/DataStoreExt.kt (68%) rename {amethyst/src/main/java/com/vitorpamplona/amethyst => commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons}/model/preferences/AccountSecretsEncryptedStores.kt (55%) rename {amethyst/src/main/java/com/vitorpamplona/amethyst => commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons}/model/preferences/EncryptedDataStore.kt (64%) create mode 100644 commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/SecretEncryption.kt create mode 100644 commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/SecretEncryption.jvm.kt create mode 100644 commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/EncryptedDataStoreTest.kt create mode 100644 commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/SecretEncryptionTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidKeyPackageBundleStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidKeyPackageBundleStore.kt index 7a99f80347..04c0db4493 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidKeyPackageBundleStore.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidKeyPackageBundleStore.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.model.marmot -import com.vitorpamplona.amethyst.model.preferences.KeyStoreEncryption +import com.vitorpamplona.amethyst.commons.model.preferences.SecretEncryption import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.Dispatchers @@ -40,12 +40,12 @@ import java.io.File * The blob contains private key material — init keys, encryption keys, * signature keys — that the MLS engine needs to process Welcome events * received days or weeks after the corresponding KeyPackage was published. - * It is encrypted at rest with [KeyStoreEncryption] (AES/GCM via Android + * It is encrypted at rest with [SecretEncryption] (AES/GCM via Android * KeyStore), the same primitive used by [AndroidMlsGroupStateStore]. */ class AndroidKeyPackageBundleStore( private val rootDir: File, - private val encryption: KeyStoreEncryption = KeyStoreEncryption(), + private val encryption: SecretEncryption = SecretEncryption(), ) : KeyPackageBundleStore { private val mutex = Mutex() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidMarmotMessageStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidMarmotMessageStore.kt index e7641cb3bf..e6775aa56a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidMarmotMessageStore.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidMarmotMessageStore.kt @@ -21,7 +21,7 @@ package com.vitorpamplona.amethyst.model.marmot import com.vitorpamplona.amethyst.commons.marmot.EncryptedAppendLog -import com.vitorpamplona.amethyst.model.preferences.KeyStoreEncryption +import com.vitorpamplona.amethyst.commons.model.preferences.SecretEncryption import com.vitorpamplona.quartz.marmot.mls.group.MarmotMessageStore import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.utils.Log @@ -46,7 +46,7 @@ import java.io.File */ class AndroidMarmotMessageStore( private val rootDir: File, - private val encryption: KeyStoreEncryption = KeyStoreEncryption(), + private val encryption: SecretEncryption = SecretEncryption(), ) : MarmotMessageStore { private val logMutex = Mutex() @@ -324,7 +324,7 @@ class AndroidMarmotMessageStore( EncryptedAppendLog( encrypt = { encryption.encrypt(it) }, // EncryptedAppendLog requires null, not a throw, for a segment it - // cannot open — KeyStoreEncryption.decrypt rethrows. Without this + // cannot open — SecretEncryption.decrypt rethrows. Without this // one bad segment would abort the whole read, and a caller that // then sees an empty log can overwrite a history that was merely // unreadable. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidMlsGroupStateStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidMlsGroupStateStore.kt index 504037d630..8d509ed7fc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidMlsGroupStateStore.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidMlsGroupStateStore.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.model.marmot -import com.vitorpamplona.amethyst.model.preferences.KeyStoreEncryption +import com.vitorpamplona.amethyst.commons.model.preferences.SecretEncryption import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.Dispatchers @@ -32,7 +32,7 @@ import java.io.FileOutputStream * Android implementation of [MlsGroupStateStore] using file-based encrypted storage. * * All MLS group state (containing private keys and epoch secrets) is encrypted - * at rest using [KeyStoreEncryption] (AES/GCM backed by Android KeyStore). + * at rest using [SecretEncryption] (AES/GCM backed by Android KeyStore). * * Storage layout: * ``` @@ -43,7 +43,7 @@ import java.io.FileOutputStream */ class AndroidMlsGroupStateStore( private val rootDir: File, - private val encryption: KeyStoreEncryption = KeyStoreEncryption(), + private val encryption: SecretEncryption = SecretEncryption(), ) : MlsGroupStateStore { init { Log.d(TAG) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidPublishObligationStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidPublishObligationStore.kt index 445e91ee91..64b561935e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidPublishObligationStore.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidPublishObligationStore.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.model.marmot -import com.vitorpamplona.amethyst.model.preferences.KeyStoreEncryption +import com.vitorpamplona.amethyst.commons.model.preferences.SecretEncryption import com.vitorpamplona.quartz.marmot.protocolCore.MarmotPublishObligationStore import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.utils.Log @@ -32,7 +32,7 @@ import java.io.File /** * Android implementation of [MarmotPublishObligationStore], encrypted at rest - * with [KeyStoreEncryption] like the group-state and KeyPackage stores. + * with [SecretEncryption] like the group-state and KeyPackage stores. * * ``` * /marmot_obligations/.obligation @@ -51,7 +51,7 @@ import java.io.File */ class AndroidPublishObligationStore( private val rootDir: File, - private val encryption: KeyStoreEncryption = KeyStoreEncryption(), + private val encryption: SecretEncryption = SecretEncryption(), ) : MarmotPublishObligationStore { private val mutex = Mutex() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/AccountPreferenceStores.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/AccountPreferenceStores.kt deleted file mode 100644 index c7fdad1616..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/AccountPreferenceStores.kt +++ /dev/null @@ -1,83 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.model.preferences - -import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.PreferenceDataStoreFactory -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.core.stringPreferencesKey -import com.vitorpamplona.quartz.utils.cache.LargeCache -import java.io.File - -class AccountPreferenceStores( - val rootFilesDir: () -> File, -) { - companion object { - val defaultHomeFollowList = stringPreferencesKey("defaultHomeFollowList") - val defaultStoriesFollowList = stringPreferencesKey("defaultStoriesFollowList") - val defaultNotificationFollowList = stringPreferencesKey("defaultNotificationFollowList") - val defaultDiscoveryFollowList = stringPreferencesKey("defaultDiscoveryFollowList") - - val localRelayServers = stringPreferencesKey("localRelayServers") - val defaultFileServer = stringPreferencesKey("defaultFileServer") - - val latestUserMetadata = stringPreferencesKey("latestUserMetadata") - val latestContactList = stringPreferencesKey("latestContactList") - val latestDMRelayList = stringPreferencesKey("latestDMRelayList") - val latestNIP65RelayList = stringPreferencesKey("latestNIP65RelayList") - val latestSearchRelayList = stringPreferencesKey("latestSearchRelayList") - val latestBlockedRelayList = stringPreferencesKey("latestBlockedRelayList") - val latestTrustedRelayList = stringPreferencesKey("latestTrustedRelayList") - val latestMuteList = stringPreferencesKey("latestMuteList") - val latestPrivateHomeRelayList = stringPreferencesKey("latestPrivateHomeRelayList") - val latestAppSpecificData = stringPreferencesKey("latestAppSpecificData") - val latestChannelList = stringPreferencesKey("latestChannelList") - val latestCommunityList = stringPreferencesKey("latestCommunityList") - val latestHashtagList = stringPreferencesKey("latestHashtagList") - val latestGeohashList = stringPreferencesKey("latestGeohashList") - val latestEphemeralChatList = stringPreferencesKey("latestEphemeralChatList") - - val hideDeleteRequestDialog = stringPreferencesKey("hideDeleteRequestDialog") - val hideBlockAlertDialog = stringPreferencesKey("hideBlockAlertDialog") - val hideNip17WarningDialog = stringPreferencesKey("hideNip17WarningDialog") - - val torSettings = stringPreferencesKey("tor_settings") - - val hasDonatedInVersion = stringPreferencesKey("hasDonatedInVersion") - } - - private val storeCache = LargeCache>() - - fun file(npub: String) = File(rootFilesDir(), "datastore/$npub.preferences") - - private fun getDataStore(npub: String): DataStore = - storeCache.getOrCreate(npub) { - PreferenceDataStoreFactory.create( - produceFile = { file(npub) }, - ) - } - - fun removeAccount(npub: String): Boolean { - val deleted = file(npub).delete() - storeCache.remove(npub) - return deleted - } -} diff --git a/commons/build.gradle.kts b/commons/build.gradle.kts index b952fe67af..4d6a78e368 100644 --- a/commons/build.gradle.kts +++ b/commons/build.gradle.kts @@ -90,6 +90,14 @@ kotlin { // OkHttp), so declare the dependency the file actually has. implementation(libs.okio) + // DataStore (KMP, Apache-2.0) — the preference storage layer. + // Publishes android/jvm/ios/linux/macos variants plus common + // metadata, so the stores under model/preferences/ are shared + // rather than duplicated per front end. Uses the okio-based + // `createWithPath` factory in common; the `java.io.File` + // overloads are jvmAndroid-only. + implementation(libs.androidx.datastore.preferences) + // Immutable collections api(libs.kotlinx.collections.immutable) @@ -161,7 +169,6 @@ kotlin { // Secure key storage via Android Keystore implementation(libs.androidx.security.crypto.ktx) - implementation(libs.androidx.datastore.preferences) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/KeyStoreEncryption.kt b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/SecretEncryption.android.kt similarity index 96% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/KeyStoreEncryption.kt rename to commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/SecretEncryption.android.kt index 68c4efb75e..6e48a17de5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/KeyStoreEncryption.kt +++ b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/SecretEncryption.android.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.model.preferences +package com.vitorpamplona.amethyst.commons.model.preferences import android.os.Build import android.security.keystore.KeyGenParameterSpec @@ -33,9 +33,9 @@ import javax.crypto.SecretKey import javax.crypto.SecretKeyFactory import javax.crypto.spec.GCMParameterSpec -class KeyStoreEncryption { +actual class SecretEncryption { companion object { - private const val TAG = "KeyStoreEncryption" + private const val TAG = "SecretEncryption" private const val ANDROID_KEY_STORE = "AndroidKeyStore" private const val ALGORITHM = KeyProperties.KEY_ALGORITHM_AES private const val BLOCK_MODE = KeyProperties.BLOCK_MODE_GCM @@ -147,7 +147,7 @@ class KeyStoreEncryption { return createKeyStrongBoxIfAvailable() ?: createKeyRegular() } - fun encrypt(bytes: ByteArray): ByteArray { + actual fun encrypt(bytes: ByteArray): ByteArray { try { // Initializes the cipher in encrypt mode and encrypts data val cipher = ciphers.get() @@ -164,7 +164,7 @@ class KeyStoreEncryption { } } - fun decrypt(bytes: ByteArray): ByteArray? { + actual fun decrypt(bytes: ByteArray): ByteArray? { try { // Extract the 12-byte GCM IV prefix and decrypt the remainder. The // AndroidKeyStore cipher only accepts GCMParameterSpec (not a plain diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountPreferenceStores.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountPreferenceStores.kt new file mode 100644 index 0000000000..ed544ac080 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountPreferenceStores.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.preferences + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import com.vitorpamplona.amethyst.commons.util.platformFileSystem +import com.vitorpamplona.quartz.utils.cache.LargeCache +import okio.Path + +/** + * The per-account, non-secret preference store: one DataStore file per npub, + * at `/datastore/.preferences_pb`. + * + * The root directory is injected rather than discovered so every front end can + * say where its data lives — `filesDir` on Android, the app data directory on + * desktop, a temp folder in tests — and so this class needs no platform API of + * its own. + * + * Built on [PreferenceDataStoreFactory.createWithPath], the okio-based factory, + * because the `java.io.File` overloads are absent on Apple targets. + */ +class AccountPreferenceStores( + val rootFilesDir: () -> Path, +) { + private val storeCache = LargeCache>() + + fun file(npub: String): Path = rootFilesDir() / "datastore" / "$npub.preferences_pb" + + fun getDataStore(npub: String): DataStore = + storeCache.getOrCreate(npub) { + PreferenceDataStoreFactory.createWithPath(produceFile = { file(npub) }) + } + + /** + * Drops the account's stored preferences. + * + * The cached handle goes first: deleting the file under a live DataStore + * would leave that instance writing the account's settings back out on the + * next edit, re-creating what this call is meant to erase. + */ + fun removeAccount(npub: String): Boolean { + storeCache.remove(npub) + val path = file(npub) + if (!platformFileSystem.exists(path)) return false + platformFileSystem.delete(path) + return true + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/DataStoreExt.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/DataStoreExt.kt similarity index 68% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/DataStoreExt.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/DataStoreExt.kt index 453dd70212..0358899d51 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/DataStoreExt.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/DataStoreExt.kt @@ -18,18 +18,28 @@ * 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.model.preferences +package com.vitorpamplona.amethyst.commons.model.preferences import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.emptyPreferences -import com.vitorpamplona.amethyst.commons.model.preferences.UpdatablePropertyFlow import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.map -import java.io.IOException +import okio.IOException +/** + * Exposes one DataStore key as an [UpdatablePropertyFlow]. + * + * A missing key, a blank serialization and an explicit null all mean the same + * thing here — the property is absent — so each of them removes the key rather + * than storing an empty string that would later parse into a bogus value. + * + * Uses okio's [IOException] rather than `java.io.IOException`: on JVM targets + * okio aliases it to exactly that type, so the read-error branch keeps catching + * what DataStore throws while the file stays compilable for Apple targets. + */ fun DataStore.getProperty( key: Preferences.Key, parser: (String) -> T, @@ -42,29 +52,14 @@ fun DataStore.getProperty( .catch { e -> if (e is IOException) emit(emptyPreferences()) else throw e }.map { prefs -> - val value = prefs[key] - if (value != null) { - parser(value) - } else { - null - } + prefs[key]?.let(parser) }, update = { newValue -> - if (newValue != null) { - val serialized = serializer(newValue) - if (serialized.isNotBlank()) { - edit { prefs -> - prefs[key] = serialized - } - } else { - edit { prefs -> - prefs.remove(key) - } - } + val serialized = newValue?.let(serializer) + if (serialized != null && serialized.isNotBlank()) { + edit { prefs -> prefs[key] = serialized } } else { - edit { prefs -> - prefs.remove(key) - } + edit { prefs -> prefs.remove(key) } } }, scope = scope, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/AccountSecretsEncryptedStores.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountSecretsEncryptedStores.kt similarity index 55% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/AccountSecretsEncryptedStores.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountSecretsEncryptedStores.kt index b9318a4713..60300c1498 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/AccountSecretsEncryptedStores.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountSecretsEncryptedStores.kt @@ -18,61 +18,67 @@ * 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.model.preferences +package com.vitorpamplona.amethyst.commons.model.preferences import androidx.datastore.preferences.core.PreferenceDataStoreFactory import androidx.datastore.preferences.core.stringPreferencesKey -import com.vitorpamplona.amethyst.commons.model.preferences.UpdatablePropertyFlow -import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.amethyst.commons.util.platformFileSystem import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import com.vitorpamplona.quartz.utils.cache.LargeCache import kotlinx.coroutines.CoroutineScope -import java.io.File +import okio.Path +/** + * The per-account secret store: one encrypted DataStore per npub, at + * `/datastore/.secrets_pb`, for secrets that are not the identity + * key — wallet connection strings, NIP-46 bunker material. + * + * **Private keys do not belong here.** They live in + * [com.vitorpamplona.amethyst.commons.keystorage.SecureKeyStorage], which backs + * them with the OS credential manager (Keychain, Credential Manager, Secret + * Service) rather than a file this process can read, and which desktop already + * uses. Two stores for one identity key would be one store too many, and the + * weaker one would win by being convenient. + * + * Separate from [AccountPreferenceStores] so ordinary settings stay cheap to + * read: every value here pays an encrypt/decrypt, which on a StrongBox-backed + * device runs at roughly 68 KB/s. + */ class AccountSecretsEncryptedStores( - val rootFilesDir: () -> File, + val rootFilesDir: () -> Path, val scope: CoroutineScope, + private val encryption: SecretEncryption = SecretEncryption(), ) { - companion object Companion { - val encryption = KeyStoreEncryption() - val key = stringPreferencesKey("privKey") + companion object { val nwc = stringPreferencesKey("nwc") } private val storeCache = LargeCache() - fun file(npub: String) = File(rootFilesDir(), "datastore/$npub.secrets") + fun file(npub: String): Path = rootFilesDir() / "datastore" / "$npub.secrets_pb" - private fun getDataStore(npub: String): EncryptedDataStore = + fun getDataStore(npub: String): EncryptedDataStore = storeCache.getOrCreate(npub) { EncryptedDataStore( - PreferenceDataStoreFactory.create( - produceFile = { file(npub) }, - ), + PreferenceDataStoreFactory.createWithPath(produceFile = { file(npub) }), encryption, scope = scope, ) } - suspend fun getPrivateKey(npub: String): String? = getDataStore(npub).get(key) - - suspend fun savePrivateKey( - npub: String, - value: HexKey, - ) { - getDataStore(npub).save(key, value) - } - - suspend fun nwc(npub: String): UpdatablePropertyFlow = + fun nwc(npub: String): UpdatablePropertyFlow = getDataStore(npub).getProperty( key = nwc, parser = Nip47WalletConnect.Nip47URI::parser, serializer = Nip47WalletConnect.Nip47URI::serializer, ) + /** See [AccountPreferenceStores.removeAccount] — the cached handle goes first. */ fun removeAccount(npub: String): Boolean { - val deleted = file(npub).delete() storeCache.remove(npub) - return deleted + val path = file(npub) + if (!platformFileSystem.exists(path)) return false + platformFileSystem.delete(path) + return true } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/EncryptedDataStore.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/EncryptedDataStore.kt similarity index 64% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/EncryptedDataStore.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/EncryptedDataStore.kt index 49091d62c7..710102f222 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/EncryptedDataStore.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/EncryptedDataStore.kt @@ -18,46 +18,44 @@ * 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.model.preferences +package com.vitorpamplona.amethyst.commons.model.preferences import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.emptyPreferences -import com.vitorpamplona.amethyst.commons.model.preferences.UpdatablePropertyFlow import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.map -import java.io.IOException +import okio.IOException import kotlin.io.encoding.Base64 +/** + * A DataStore whose values are encrypted with [SecretEncryption] and stored + * Base64-encoded, for anything that must not sit in plaintext on disk. + * + * Keys stay in the clear — only values are encrypted — so the set of keys an + * account has is visible even though their contents are not. + */ class EncryptedDataStore( private val store: DataStore, - private val encryption: KeyStoreEncryption = KeyStoreEncryption(), + private val encryption: SecretEncryption = SecretEncryption(), private val scope: CoroutineScope, ) { - private fun decode(str: String): ByteArray = Base64.decode(str) + private fun encrypt(value: String): String = Base64.encode(encryption.encrypt(value.encodeToByteArray())) - private fun encode(bytes: ByteArray): String = Base64.encode(bytes) - - private fun encrypt(value: String): String = encode(encryption.encrypt(value.toByteArray())) - - private fun decrypt(value: String): String = encryption.decrypt(decode(value)).contentToString() + private fun decrypt(value: String): String? = encryption.decrypt(Base64.decode(value))?.decodeToString() suspend fun remove(key: Preferences.Key) { - store.edit { prefs -> - prefs.remove(key) - } + store.edit { prefs -> prefs.remove(key) } } suspend fun save( key: Preferences.Key, value: String, ) { - store.edit { prefs -> - prefs[key] = encrypt(value) - } + store.edit { prefs -> prefs[key] = encrypt(value) } } suspend fun get(key: Preferences.Key): String? = @@ -79,26 +77,12 @@ class EncryptedDataStore( .catch { e -> if (e is IOException) emit(emptyPreferences()) else throw e }.map { prefs -> - val value = prefs[key] - if (value != null) { - val decrypted = decrypt(value) - if (decrypted.isNotBlank()) { - parser(decrypted) - } else { - null - } - } else { - null - } + prefs[key]?.let { decrypt(it) }?.takeIf { it.isNotBlank() }?.let(parser) }, update = { newValue -> - if (newValue != null) { - val serialized = serializer(newValue) - if (serialized.isNotBlank()) { - save(key, serialized) - } else { - remove(key) - } + val serialized = newValue?.let(serializer) + if (serialized != null && serialized.isNotBlank()) { + save(key, serialized) } else { remove(key) } diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/SecretEncryption.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/SecretEncryption.kt new file mode 100644 index 0000000000..22ad8f450e --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/SecretEncryption.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.preferences + +/** + * Symmetric encryption for data this app stores at rest — account secrets, the + * Marmot group state, message bodies. + * + * Declared for `jvmAndroid` rather than `commonMain` on purpose: Android backs + * it with the hardware-held AndroidKeyStore and desktop with a key file the OS + * user owns, and those are the two targets that store secrets today. An Apple + * actual belongs with the first iOS build that needs one, written against the + * Keychain — not stubbed here, where nothing would exercise it. + * + * Implementations must be safe to call from several coroutines at once. + */ +expect class SecretEncryption() { + /** Returns the ciphertext with whatever nonce/IV the implementation needs prefixed. */ + fun encrypt(bytes: ByteArray): ByteArray + + /** Inverse of [encrypt]. Throws if the input is not what [encrypt] produced. */ + fun decrypt(bytes: ByteArray): ByteArray? +} diff --git a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/SecretEncryption.jvm.kt b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/SecretEncryption.jvm.kt new file mode 100644 index 0000000000..a1c9246919 --- /dev/null +++ b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/SecretEncryption.jvm.kt @@ -0,0 +1,146 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.preferences + +import com.vitorpamplona.amethyst.commons.util.restrictToOwner +import com.vitorpamplona.quartz.utils.Log +import java.io.File +import java.security.SecureRandom +import javax.crypto.Cipher +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.SecretKeySpec + +/** + * Desktop [SecretEncryption]: the same AES-256-GCM as Android, but with the key + * held in a file this OS user owns instead of in hardware. + * + * That difference is real and worth stating plainly. On Android the key lives + * in the AndroidKeyStore and never enters app memory, so a copy of the data + * files is useless without the device. Here the key sits next to the data, + * readable by anything running as this user — encryption at rest that survives + * a stolen disk or a careless backup, not a compromised account. The JVM has no + * portable hardware-backed keystore to do better with; a per-OS keyring binding + * (as `cli`'s SecretStore does for credentials) is the upgrade path. + */ +actual class SecretEncryption internal constructor( + private val keyFile: File, +) { + /** Production entry point: the key file this OS user owns. */ + actual constructor() : this(defaultKeyFile()) + + companion object { + private const val TAG = "SecretEncryption" + private const val TRANSFORMATION = "AES/GCM/NoPadding" + private const val ALGORITHM = "AES" + private const val KEY_SIZE_BYTES = 32 + private const val GCM_IV_LENGTH = 12 + private const val GCM_TAG_LENGTH_BITS = 128 + private const val KEY_FILE_NAME = "secret.key" + + /** Where this OS keeps per-user application data. */ + internal fun defaultKeyFile(): File { + val home = System.getProperty("user.home") ?: "." + val os = System.getProperty("os.name").orEmpty().lowercase() + val dir = + when { + os.contains("mac") || os.contains("darwin") -> + File(home, "Library/Application Support/Amethyst") + os.contains("win") -> + File(System.getenv("APPDATA") ?: "$home\\AppData\\Roaming", "Amethyst") + else -> + File(System.getenv("XDG_DATA_HOME")?.takeIf { it.isNotBlank() } ?: "$home/.local/share", "amethyst") + } + return File(dir, KEY_FILE_NAME) + } + } + + // A Cipher holds the state of the operation in progress, so two coroutines + // encrypting through one instance would corrupt each other's output. One + // per thread, matching the Android actual. + private val ciphers = ThreadLocal.withInitial { Cipher.getInstance(TRANSFORMATION) } + + @Volatile + private var cachedKey: SecretKey? = null + + private fun getKey(): SecretKey = + cachedKey ?: synchronized(this) { + cachedKey ?: loadOrCreateKey().also { cachedKey = it } + } + + private fun loadOrCreateKey(): SecretKey { + if (keyFile.exists()) { + val bytes = keyFile.readBytes() + if (bytes.size == KEY_SIZE_BYTES) return SecretKeySpec(bytes, ALGORITHM) + // A truncated or padded key file cannot decrypt anything already + // written; replacing it silently would strand that data under a key + // nobody holds. Fail loudly instead. + throw IllegalStateException( + "Key file ${keyFile.absolutePath} is ${bytes.size} bytes, expected $KEY_SIZE_BYTES. " + + "Refusing to overwrite it — move it aside to start fresh.", + ) + } + return createKey() + } + + private fun createKey(): SecretKey { + Log.d(TAG) { "Creating a new AES key at ${keyFile.absolutePath}" } + val bytes = ByteArray(KEY_SIZE_BYTES).also { SecureRandom().nextBytes(it) } + + keyFile.parentFile?.let { parent -> + parent.mkdirs() + parent.restrictToOwner(TAG) + } + // Narrow the file before the key goes in: created at the default umask + // and chmodded afterwards, the key would be world-readable in between. + keyFile.createNewFile() + keyFile.restrictToOwner(TAG) + keyFile.writeBytes(bytes) + + return SecretKeySpec(bytes, ALGORITHM) + } + + actual fun encrypt(bytes: ByteArray): ByteArray { + try { + val cipher = ciphers.get() + cipher.init(Cipher.ENCRYPT_MODE, getKey()) + return cipher.iv + cipher.doFinal(bytes) + } catch (e: Exception) { + cachedKey = null + Log.e(TAG, "encrypt() failed: ${e.message}", e) + throw e + } + } + + actual fun decrypt(bytes: ByteArray): ByteArray? { + try { + val iv = bytes.copyOfRange(0, GCM_IV_LENGTH) + val data = bytes.copyOfRange(GCM_IV_LENGTH, bytes.size) + val cipher = ciphers.get() + cipher.init(Cipher.DECRYPT_MODE, getKey(), GCMParameterSpec(GCM_TAG_LENGTH_BITS, iv)) + return cipher.doFinal(data) + } catch (e: Exception) { + cachedKey = null + Log.e(TAG, "decrypt() failed (input ${bytes.size} bytes): ${e.message}", e) + throw e + } + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/EncryptedDataStoreTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/EncryptedDataStoreTest.kt new file mode 100644 index 0000000000..68eddfbf94 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/EncryptedDataStoreTest.kt @@ -0,0 +1,134 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.preferences + +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.stringPreferencesKey +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.test.runTest +import okio.Path.Companion.toOkioPath +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +/** + * Round-trip coverage for the encrypted store. + * + * The store shipped with `decrypt` reading + * `encryption.decrypt(...).contentToString()`, which renders a ByteArray as + * `"[104, 101, 108]"` instead of decoding it, so every value read back was the + * debug rendering of its own bytes. Nothing caught it because the class had no + * callers. [saveAndGetRoundTrips] is the test that fails against that version. + */ +class EncryptedDataStoreTest { + @get:Rule + val folder = TemporaryFolder() + + private val key = stringPreferencesKey("nwc") + + private var seq = 0 + + /** + * A store over its own pair of fresh paths. + * + * The files are named, never created: DataStore writes them itself, and an + * empty file left behind by `newFile()` is not a valid preferences_pb. The + * path is resolved once and captured, because `produceFile` may be invoked + * more than once and must answer the same file every time. + */ + private fun store(scope: CoroutineScope): EncryptedDataStore { + val n = seq++ + val dataFile = File(folder.root, "secrets_$n.preferences_pb") + val keyFile = File(folder.root, "secret_$n.key") + return EncryptedDataStore( + PreferenceDataStoreFactory.createWithPath(scope = scope, produceFile = { dataFile.toOkioPath() }), + SecretEncryption(keyFile), + scope = scope, + ) + } + + @Test + fun saveAndGetRoundTrips() = + runTest { + val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + val subject = store(scope) + val nsec = "e5e2b1d3f6a94c8d7b0e1f2a3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d" + + subject.save(key, nsec) + + assertEquals(nsec, subject.get(key)) + } + + @Test + fun missingKeyReadsBackAsNull() = + runTest { + val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + assertNull(store(scope).get(key)) + } + + @Test + fun valuesAreNotStoredInPlaintext() = + runTest { + val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + val file = File(folder.root, "plaintext_check.preferences_pb") + val subject = + EncryptedDataStore( + PreferenceDataStoreFactory.createWithPath(scope = scope, produceFile = { file.toOkioPath() }), + SecretEncryption(File(folder.root, "plaintext_check.key")), + scope = scope, + ) + val secret = "correct-horse-battery-staple" + + subject.save(key, secret) + + val onDisk = file.readBytes().decodeToString() + assertEquals("the secret must not be readable in the store file", false, onDisk.contains(secret)) + } + + @Test + fun overwriteReplacesTheValue() = + runTest { + val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + val subject = store(scope) + + subject.save(key, "first") + subject.save(key, "second") + + assertEquals("second", subject.get(key)) + } + + @Test + fun removeClearsTheValue() = + runTest { + val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + val subject = store(scope) + + subject.save(key, "value") + subject.remove(key) + + assertNull(subject.get(key)) + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/SecretEncryptionTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/SecretEncryptionTest.kt new file mode 100644 index 0000000000..854eef57ab --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/SecretEncryptionTest.kt @@ -0,0 +1,118 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.preferences + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import java.nio.file.Files +import java.nio.file.attribute.PosixFilePermission + +class SecretEncryptionTest { + @get:Rule + val folder = TemporaryFolder() + + private fun subject(name: String = "secret.key") = SecretEncryption(File(folder.root, name)) + + @Test + fun roundTripsBytes() { + val encryption = subject() + val plaintext = "an nsec, a wallet string, a group state".encodeToByteArray() + + assertArrayEquals(plaintext, encryption.decrypt(encryption.encrypt(plaintext))) + } + + @Test + fun roundTripsEmptyInput() { + val encryption = subject() + + assertArrayEquals(ByteArray(0), encryption.decrypt(encryption.encrypt(ByteArray(0)))) + } + + @Test + fun ciphertextDiffersFromPlaintext() { + val plaintext = "correct-horse-battery-staple".encodeToByteArray() + + val ciphertext = subject().encrypt(plaintext) + + assertFalse(ciphertext.decodeToString().contains("correct-horse")) + } + + /** GCM must never reuse an IV under the same key, so the same input encrypts differently each time. */ + @Test + fun encryptingTwiceProducesDifferentCiphertext() { + val encryption = subject() + val plaintext = "same input".encodeToByteArray() + + assertNotEquals( + encryption.encrypt(plaintext).toList(), + encryption.encrypt(plaintext).toList(), + ) + } + + /** A second instance over the same key file must read the first one's output. */ + @Test + fun keyPersistsAcrossInstances() { + val plaintext = "survives a restart".encodeToByteArray() + val ciphertext = subject().encrypt(plaintext) + + assertArrayEquals(plaintext, subject().decrypt(ciphertext)) + } + + /** A different key file must not decrypt — otherwise the key is not doing anything. */ + @Test + fun aDifferentKeyCannotDecrypt() { + val ciphertext = subject("first.key").encrypt("secret".encodeToByteArray()) + + assertThrows(Exception::class.java) { subject("second.key").decrypt(ciphertext) } + } + + @Test + fun keyFileIsOwnerOnly() { + subject().encrypt("x".encodeToByteArray()) + + val perms = Files.getPosixFilePermissions(File(folder.root, "secret.key").toPath()) + assertEquals(setOf(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE), perms) + } + + /** + * A wrong-sized key file means data already on disk was written under a key + * we no longer have. Overwriting it would strand that data silently. + */ + @Test + fun refusesToOverwriteAMalformedKeyFile() { + val keyFile = File(folder.root, "truncated.key") + keyFile.writeBytes(ByteArray(7)) + + val error = + assertThrows(IllegalStateException::class.java) { + SecretEncryption(keyFile).encrypt("x".encodeToByteArray()) + } + assertTrue(error.message!!.contains("Refusing to overwrite")) + } +} From 3c8581717d99cd0bffbe34cb20728f1ca4cf1d08 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 15:48:48 +0000 Subject: [PATCH 02/50] refactor: move the 31 top-nav follow-list prefs to DataStore Step 2a of the SharedPreferences -> DataStore migration. The top-nav filter selections were 31 near-identical SharedPreferences reads and 31 writes spread across LocalPreferences. They become a table: FollowListSlot names each feed with the key it has always been stored under and the filter it falls back to, and TopNavFollowListStore (in commons/commonMain, so desktop gets it too) reads and writes the set. LocalPreferences keeps mapping the slots onto the named AccountSettings fields, so AccountSettings itself is unchanged. The slot table was generated from the live loader rather than transcribed, because the defaults are not uniform and the differences matter: products and geocaches open to what is nearby, badges and relay-group discovery to the user's own, most others to Global. Values keep the same JSON encoding, so a migrated store and a legacy one hold byte-identical strings. Existing users are carried over by CopyOnceMigration, which copies the legacy values into the new store the first time it is read. It copies rather than moves, deliberately: SharedPreferencesMigration, the stock implementation, deletes each key it migrates, making the change a one-way door where a rolled-back build finds the settings gone. A marker key in the destination records that the copy ran, so the legacy data stays readable and rollback still works. Deleting it is a later, separate decision. Two traps worth naming, both hit while writing this: - androidx.datastore declares its own `infix to` on Preferences.Key, so `slot.key to value` silently builds a Preferences.Pair rather than the kotlin Pair that toMap() needs. Both sites now construct Pair(...) explicitly. - kotlinx serializes by STATIC type, so JsonMapper.toJson(TopFilter.Global) encodes the concrete object with no polymorphic discriminator and cannot be read back as a TopFilter. Production call sites are safe because they read a StateFlow.value; the tests now go through a helper that widens to TopFilter, which is what caught it. 8 tests cover the per-slot defaults (including that they have not collapsed to a single value), that saved values read back, that an unparseable entry costs only that feed's filter, that the prefKeys are the ones the app has always written, that the migration copies, and that it does not run twice and clobber a later edit. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AXvKXakvup4inNFfAhhr4L --- .../amethyst/LocalPreferences.kt | 180 +++++++++----- .../preferences/AccountPreferenceStores.kt | 11 +- .../model/preferences/CopyOnceMigration.kt | 66 ++++++ .../preferences/TopNavFollowListStore.kt | 141 +++++++++++ .../preferences/TopNavFollowListStoreTest.kt | 223 ++++++++++++++++++ 5 files changed, 555 insertions(+), 66 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/CopyOnceMigration.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/TopNavFollowListStore.kt create mode 100644 commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/TopNavFollowListStoreTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index a76ebb2365..467f07eb97 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -34,6 +34,10 @@ import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerName import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupViewMode import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntry import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntryNorm +import com.vitorpamplona.amethyst.commons.model.preferences.AccountPreferenceStores +import com.vitorpamplona.amethyst.commons.model.preferences.CopyOnceMigration +import com.vitorpamplona.amethyst.commons.model.preferences.FollowListSlot +import com.vitorpamplona.amethyst.commons.model.preferences.TopNavFollowListStore import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy import com.vitorpamplona.amethyst.model.AccountSettings @@ -85,6 +89,7 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable +import okio.Path.Companion.toOkioPath import java.io.File // Release mode (!BuildConfig.DEBUG) always uses encrypted preferences @@ -249,6 +254,40 @@ object LocalPreferences { private val savedAccountsMutex = Mutex() private val cachedAccounts: MutableMap = mutableMapOf() + /** + * The per-account DataStore, and the top-nav filter selections inside it. + * + * Each account's store carries a [CopyOnceMigration] that lifts the filters + * out of that account's legacy encrypted SharedPreferences the first time + * the store is read. The copy leaves the legacy keys in place, so a build + * that reads the old location still works — see [CopyOnceMigration]. + */ + private val accountStores: AccountPreferenceStores by lazy { + AccountPreferenceStores( + rootFilesDir = { + Amethyst.instance.appContext.filesDir + .toOkioPath() + }, + migrations = { npub -> listOf(followListMigration(npub)) }, + ) + } + + private fun followListStore(npub: String) = TopNavFollowListStore(accountStores.getDataStore(npub)) + + private fun followListMigration(npub: String) = + CopyOnceMigration("migrated.followLists") { + withContext(Dispatchers.IO) { + val legacy = encryptedPreferences(npub) + // Pair(...) and not `slot.key to it`: androidx.datastore declares its + // own infix `to` on Preferences.Key, which would build a + // Preferences.Pair instead of the kotlin Pair toMap() needs. + FollowListSlot.entries + .mapNotNull { slot -> + legacy.getString(slot.prefKey, null)?.let { Pair(slot.key, it) } + }.toMap() + } + } + // Global master switch for the always-on notification service ("Background // notification service"). Default ON: existing users keep current behavior, and // per-account participation decides who actually stays active. @@ -526,38 +565,8 @@ object LocalPreferences { putString(PrefKeys.NIP46_TRANSPORT_KEY, settings.nip46TransportKey.value) putStringSet(PrefKeys.NIP46_SEEN_IDS, settings.nip46SeenRequestIds.value) - putString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, JsonMapper.toJson(settings.defaultHomeFollowList.value)) - putString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultStoriesFollowList.value)) - putString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, JsonMapper.toJson(settings.defaultNotificationFollowList.value)) - putString(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, JsonMapper.toJson(settings.defaultDiscoveryFollowList.value)) - - putString(PrefKeys.DEFAULT_POLLS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultPollsFollowList.value)) - putString(PrefKeys.DEFAULT_PICTURES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultPicturesFollowList.value)) - putString(PrefKeys.DEFAULT_RELAY_GROUPS_DISCOVERY_FOLLOW_LIST, JsonMapper.toJson(settings.defaultRelayGroupsDiscoveryFollowList.value)) - putString(PrefKeys.DEFAULT_NAPPLETS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultNappletsFollowList.value)) - putString(PrefKeys.DEFAULT_NSITES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultNsitesFollowList.value)) - putString(PrefKeys.DEFAULT_WORKOUTS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultWorkoutsFollowList.value)) - putString(PrefKeys.DEFAULT_GIT_REPOSITORIES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultGitRepositoriesFollowList.value)) - putString(PrefKeys.DEFAULT_HIGHLIGHTS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultHighlightsFollowList.value)) - putString(PrefKeys.DEFAULT_CALENDARS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultCalendarsFollowList.value)) - putString(PrefKeys.DEFAULT_PRODUCTS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultProductsFollowList.value)) - putString(PrefKeys.DEFAULT_GEOCACHES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultGeocachesFollowList.value)) - putString(PrefKeys.DEFAULT_SHORTS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultShortsFollowList.value)) - putString(PrefKeys.DEFAULT_PUBLIC_CHATS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultPublicChatsFollowList.value)) - putString(PrefKeys.DEFAULT_LIVE_STREAMS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultLiveStreamsFollowList.value)) - putString(PrefKeys.DEFAULT_NESTS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultNestsFollowList.value)) - putString(PrefKeys.DEFAULT_LONGS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultLongsFollowList.value)) - putString(PrefKeys.DEFAULT_ARTICLES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultArticlesFollowList.value)) - putString(PrefKeys.DEFAULT_MUSIC_TRACKS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultMusicTracksFollowList.value)) - putString(PrefKeys.DEFAULT_MUSIC_PLAYLISTS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultMusicPlaylistsFollowList.value)) - putString(PrefKeys.DEFAULT_PODCAST_EPISODES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultPodcastEpisodesFollowList.value)) - putString(PrefKeys.DEFAULT_PODCASTS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultPodcastsFollowList.value)) - putString(PrefKeys.DEFAULT_SOFTWARE_APPS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultSoftwareAppsFollowList.value)) - putString(PrefKeys.DEFAULT_BADGES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultBadgesFollowList.value)) - putString(PrefKeys.DEFAULT_BROWSE_EMOJI_SETS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultBrowseEmojiSetsFollowList.value)) - putString(PrefKeys.DEFAULT_COMMUNITIES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultCommunitiesFollowList.value)) - putString(PrefKeys.DEFAULT_FOLLOW_PACKS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultFollowPacksFollowList.value)) - putString(PrefKeys.DEFAULT_APP_RECOMMENDATIONS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultAppRecommendationsFollowList.value)) + // top-nav filters now live in the account's DataStore; written below, + // outside this edit block, because that write is suspend. val walletEntries = settings.nwcWallets.value.mapNotNull { it.denormalize() } if (walletEntries.isNotEmpty()) { @@ -678,6 +687,41 @@ object LocalPreferences { ) } } + followListStore(settings.keyPair.pubKey.toNpub()).saveAll( + mapOf( + FollowListSlot.HOME to settings.defaultHomeFollowList.value, + FollowListSlot.STORIES to settings.defaultStoriesFollowList.value, + FollowListSlot.NOTIFICATION to settings.defaultNotificationFollowList.value, + FollowListSlot.DISCOVERY to settings.defaultDiscoveryFollowList.value, + FollowListSlot.POLLS to settings.defaultPollsFollowList.value, + FollowListSlot.PICTURES to settings.defaultPicturesFollowList.value, + FollowListSlot.RELAY_GROUPS_DISCOVERY to settings.defaultRelayGroupsDiscoveryFollowList.value, + FollowListSlot.NAPPLETS to settings.defaultNappletsFollowList.value, + FollowListSlot.NSITES to settings.defaultNsitesFollowList.value, + FollowListSlot.WORKOUTS to settings.defaultWorkoutsFollowList.value, + FollowListSlot.GIT_REPOSITORIES to settings.defaultGitRepositoriesFollowList.value, + FollowListSlot.HIGHLIGHTS to settings.defaultHighlightsFollowList.value, + FollowListSlot.CALENDARS to settings.defaultCalendarsFollowList.value, + FollowListSlot.PRODUCTS to settings.defaultProductsFollowList.value, + FollowListSlot.GEOCACHES to settings.defaultGeocachesFollowList.value, + FollowListSlot.SHORTS to settings.defaultShortsFollowList.value, + FollowListSlot.PUBLIC_CHATS to settings.defaultPublicChatsFollowList.value, + FollowListSlot.LIVE_STREAMS to settings.defaultLiveStreamsFollowList.value, + FollowListSlot.NESTS to settings.defaultNestsFollowList.value, + FollowListSlot.LONGS to settings.defaultLongsFollowList.value, + FollowListSlot.ARTICLES to settings.defaultArticlesFollowList.value, + FollowListSlot.MUSIC_TRACKS to settings.defaultMusicTracksFollowList.value, + FollowListSlot.MUSIC_PLAYLISTS to settings.defaultMusicPlaylistsFollowList.value, + FollowListSlot.PODCAST_EPISODES to settings.defaultPodcastEpisodesFollowList.value, + FollowListSlot.PODCASTS to settings.defaultPodcastsFollowList.value, + FollowListSlot.SOFTWARE_APPS to settings.defaultSoftwareAppsFollowList.value, + FollowListSlot.BADGES to settings.defaultBadgesFollowList.value, + FollowListSlot.BROWSE_EMOJI_SETS to settings.defaultBrowseEmojiSetsFollowList.value, + FollowListSlot.COMMUNITIES to settings.defaultCommunitiesFollowList.value, + FollowListSlot.FOLLOW_PACKS to settings.defaultFollowPacksFollowList.value, + FollowListSlot.APP_RECOMMENDATIONS to settings.defaultAppRecommendationsFollowList.value, + ), + ) } Log.d("LocalPreferences", "Saved to encrypted storage") } @@ -810,7 +854,7 @@ object LocalPreferences { val viewedPollResultNoteIdsStr = getString(PrefKeys.VIEWED_POLL_RESULT_NOTE_IDS, null) val localRelayServers = getStringSet(PrefKeys.LOCAL_RELAY_SERVERS, null) ?: setOf() - val followListPrefs = loadFollowListPrefs() + val followListPrefs = toFollowListPrefs(followListStore(keyPair.pubKey.toNpub()).load()) val zapPaymentRequestServerStr = getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null) val nwcWalletsStr = getString(PrefKeys.NWC_WALLETS, null) @@ -1160,39 +1204,45 @@ object LocalPreferences { return migrated } - private fun SharedPreferences.loadFollowListPrefs(): FollowListPrefs = + /** + * Maps the store's slot table onto the named fields [AccountSettings] + * still expects. `getValue` is intentional: [TopNavFollowListStore.load] + * returns every slot, so a missing one is a bug in this mapping rather + * than a user with no saved filter, and should fail loudly. + */ + private fun SharedPreferences.toFollowListPrefs(filters: Map): FollowListPrefs = FollowListPrefs( - home = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, null), TopFilter.AllFollows), - stories = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, null), TopFilter.Global), - notification = migrateNotificationFilter(parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, null), TopFilter.Selected)), - discovery = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, null), TopFilter.Global), - polls = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_POLLS_FOLLOW_LIST, null), TopFilter.Global), - pictures = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_PICTURES_FOLLOW_LIST, null), TopFilter.Global), - relayGroupsDiscovery = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_RELAY_GROUPS_DISCOVERY_FOLLOW_LIST, null), TopFilter.Mine), - napplets = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_NAPPLETS_FOLLOW_LIST, null), TopFilter.Global), - nsites = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_NSITES_FOLLOW_LIST, null), TopFilter.Global), - workouts = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_WORKOUTS_FOLLOW_LIST, null), TopFilter.Global), - gitRepositories = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_GIT_REPOSITORIES_FOLLOW_LIST, null), TopFilter.Global), - highlights = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_HIGHLIGHTS_FOLLOW_LIST, null), TopFilter.Global), - calendars = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_CALENDARS_FOLLOW_LIST, null), TopFilter.Global), - products = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_PRODUCTS_FOLLOW_LIST, null), TopFilter.AroundMe), - geocaches = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_GEOCACHES_FOLLOW_LIST, null), TopFilter.AroundMe), - shorts = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_SHORTS_FOLLOW_LIST, null), TopFilter.Global), - publicChats = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_PUBLIC_CHATS_FOLLOW_LIST, null), TopFilter.Global), - liveStreams = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_LIVE_STREAMS_FOLLOW_LIST, null), TopFilter.Global), - nests = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_NESTS_FOLLOW_LIST, null), TopFilter.Global), - longs = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_LONGS_FOLLOW_LIST, null), TopFilter.Global), - articles = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_ARTICLES_FOLLOW_LIST, null), TopFilter.AllFollows), - musicTracks = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_MUSIC_TRACKS_FOLLOW_LIST, null), TopFilter.Global), - musicPlaylists = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_MUSIC_PLAYLISTS_FOLLOW_LIST, null), TopFilter.Global), - podcastEpisodes = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_PODCAST_EPISODES_FOLLOW_LIST, null), TopFilter.Global), - podcasts = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_PODCASTS_FOLLOW_LIST, null), TopFilter.Global), - softwareApps = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_SOFTWARE_APPS_FOLLOW_LIST, null), TopFilter.Global), - badges = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_BADGES_FOLLOW_LIST, null), TopFilter.Mine), - browseEmojiSets = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_BROWSE_EMOJI_SETS_FOLLOW_LIST, null), TopFilter.Global), - communities = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_COMMUNITIES_FOLLOW_LIST, null), TopFilter.AllFollows), - followPacks = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_FOLLOW_PACKS_FOLLOW_LIST, null), TopFilter.Global), - appRecommendations = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_APP_RECOMMENDATIONS_FOLLOW_LIST, null), TopFilter.Global), + home = filters.getValue(FollowListSlot.HOME), + stories = filters.getValue(FollowListSlot.STORIES), + notification = migrateNotificationFilter(filters.getValue(FollowListSlot.NOTIFICATION)), + discovery = filters.getValue(FollowListSlot.DISCOVERY), + polls = filters.getValue(FollowListSlot.POLLS), + pictures = filters.getValue(FollowListSlot.PICTURES), + relayGroupsDiscovery = filters.getValue(FollowListSlot.RELAY_GROUPS_DISCOVERY), + napplets = filters.getValue(FollowListSlot.NAPPLETS), + nsites = filters.getValue(FollowListSlot.NSITES), + workouts = filters.getValue(FollowListSlot.WORKOUTS), + gitRepositories = filters.getValue(FollowListSlot.GIT_REPOSITORIES), + highlights = filters.getValue(FollowListSlot.HIGHLIGHTS), + calendars = filters.getValue(FollowListSlot.CALENDARS), + products = filters.getValue(FollowListSlot.PRODUCTS), + geocaches = filters.getValue(FollowListSlot.GEOCACHES), + shorts = filters.getValue(FollowListSlot.SHORTS), + publicChats = filters.getValue(FollowListSlot.PUBLIC_CHATS), + liveStreams = filters.getValue(FollowListSlot.LIVE_STREAMS), + nests = filters.getValue(FollowListSlot.NESTS), + longs = filters.getValue(FollowListSlot.LONGS), + articles = filters.getValue(FollowListSlot.ARTICLES), + musicTracks = filters.getValue(FollowListSlot.MUSIC_TRACKS), + musicPlaylists = filters.getValue(FollowListSlot.MUSIC_PLAYLISTS), + podcastEpisodes = filters.getValue(FollowListSlot.PODCAST_EPISODES), + podcasts = filters.getValue(FollowListSlot.PODCASTS), + softwareApps = filters.getValue(FollowListSlot.SOFTWARE_APPS), + badges = filters.getValue(FollowListSlot.BADGES), + browseEmojiSets = filters.getValue(FollowListSlot.BROWSE_EMOJI_SETS), + communities = filters.getValue(FollowListSlot.COMMUNITIES), + followPacks = filters.getValue(FollowListSlot.FOLLOW_PACKS), + appRecommendations = filters.getValue(FollowListSlot.APP_RECOMMENDATIONS), ) private inline fun parseOrNull(value: String?): T? { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountPreferenceStores.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountPreferenceStores.kt index ed544ac080..af03d7de4d 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountPreferenceStores.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountPreferenceStores.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.commons.model.preferences +import androidx.datastore.core.DataMigration import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.PreferenceDataStoreFactory import androidx.datastore.preferences.core.Preferences @@ -38,9 +39,14 @@ import okio.Path * * Built on [PreferenceDataStoreFactory.createWithPath], the okio-based factory, * because the `java.io.File` overloads are absent on Apple targets. + * + * [migrations] runs once per account, before that account's store answers its + * first read. Android supplies one that copies out of the legacy + * SharedPreferences; front ends with no history supply none. */ class AccountPreferenceStores( val rootFilesDir: () -> Path, + private val migrations: (npub: String) -> List> = { emptyList() }, ) { private val storeCache = LargeCache>() @@ -48,7 +54,10 @@ class AccountPreferenceStores( fun getDataStore(npub: String): DataStore = storeCache.getOrCreate(npub) { - PreferenceDataStoreFactory.createWithPath(produceFile = { file(npub) }) + PreferenceDataStoreFactory.createWithPath( + migrations = migrations(npub), + produceFile = { file(npub) }, + ) } /** diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/CopyOnceMigration.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/CopyOnceMigration.kt new file mode 100644 index 0000000000..3ab3ba82b7 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/CopyOnceMigration.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.preferences + +import androidx.datastore.core.DataMigration +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey + +/** + * Copies values from an older store into this one, once, the first time the + * DataStore is read. + * + * Deliberately a copy and not a move. `SharedPreferencesMigration`, the stock + * implementation, deletes each key it migrates — which is how it knows not to + * run twice, and which makes the migration a one-way door: a build that rolls + * back to reading the old store finds the user's settings gone. Here a marker + * key in the *destination* records that the copy happened, so the source is + * left untouched and a rollback still works. Deleting the legacy data is a + * separate decision, taken once the migration has shipped and held. + * + * [read] is only called when the migration actually runs, so the cost of + * opening the legacy store is not paid on every launch. + * + * @param markerName key recording, in this store, that the copy has run. + * Distinct per migration, so several can run against the same store. + * @param read the legacy values, already mapped onto this store's keys. A key + * absent here is left absent rather than written blank, so it keeps reading + * as "unset" and falls back to its default. + */ +class CopyOnceMigration( + markerName: String, + private val read: suspend () -> Map, String>, +) : DataMigration { + private val marker = booleanPreferencesKey(markerName) + + override suspend fun shouldMigrate(currentData: Preferences): Boolean = currentData[marker] != true + + override suspend fun migrate(currentData: Preferences): Preferences { + val updated = currentData.toMutablePreferences() + + read().forEach { (key, value) -> updated[key] = value } + updated[marker] = true + + return updated.toPreferences() + } + + override suspend fun cleanUp() = Unit +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/TopNavFollowListStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/TopNavFollowListStore.kt new file mode 100644 index 0000000000..1e07d3154c --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/TopNavFollowListStore.kt @@ -0,0 +1,141 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.preferences + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.stringPreferencesKey +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter +import com.vitorpamplona.quartz.nip01Core.core.JsonMapper +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.first +import okio.IOException + +/** + * Every top-nav feed whose author filter the user can change, with the + * preference key it has always been stored under and the filter it falls back + * to when unset. + * + * The keys are the strings the Android app wrote from its first release, so + * this table is a compatibility surface: renaming an entry silently resets that + * feed for everyone who had customised it. + * + * Defaults are not uniform and the differences are deliberate — [PRODUCTS] and + * [GEOCACHES] open to what is physically nearby, [BADGES] and + * [RELAY_GROUPS_DISCOVERY] to the user's own, most others to Global. + */ +enum class FollowListSlot( + val prefKey: String, + val default: TopFilter, +) { + HOME("defaultHomeFollowList", TopFilter.AllFollows), + STORIES("defaultStoriesFollowList", TopFilter.Global), + NOTIFICATION("defaultNotificationFollowList", TopFilter.Selected), + DISCOVERY("defaultDiscoveryFollowList", TopFilter.Global), + POLLS("defaultPollsFollowList", TopFilter.Global), + PICTURES("defaultPicturesFollowList", TopFilter.Global), + RELAY_GROUPS_DISCOVERY("defaultRelayGroupsDiscoveryFollowList", TopFilter.Mine), + NAPPLETS("defaultNappletsFollowList", TopFilter.Global), + NSITES("defaultNsitesFollowList", TopFilter.Global), + WORKOUTS("defaultWorkoutsFollowList", TopFilter.Global), + GIT_REPOSITORIES("defaultGitRepositoriesFollowList", TopFilter.Global), + HIGHLIGHTS("defaultHighlightsFollowList", TopFilter.Global), + CALENDARS("defaultCalendarsFollowList", TopFilter.Global), + PRODUCTS("defaultProductsFollowList", TopFilter.AroundMe), + GEOCACHES("defaultGeocachesFollowList", TopFilter.AroundMe), + SHORTS("defaultShortsFollowList", TopFilter.Global), + PUBLIC_CHATS("defaultPublicChatsFollowList", TopFilter.Global), + LIVE_STREAMS("defaultLiveStreamsFollowList", TopFilter.Global), + NESTS("defaultNestsFollowList", TopFilter.Global), + LONGS("defaultLongsFollowList", TopFilter.Global), + ARTICLES("defaultArticlesFollowList", TopFilter.AllFollows), + MUSIC_TRACKS("defaultMusicTracksFollowList", TopFilter.Global), + MUSIC_PLAYLISTS("defaultMusicPlaylistsFollowList", TopFilter.Global), + PODCAST_EPISODES("defaultPodcastEpisodesFollowList", TopFilter.Global), + PODCASTS("defaultPodcastsFollowList", TopFilter.Global), + SOFTWARE_APPS("defaultSoftwareAppsFollowList", TopFilter.Global), + BADGES("defaultBadgesFollowList", TopFilter.Mine), + BROWSE_EMOJI_SETS("defaultBrowseEmojiSetsFollowList", TopFilter.Global), + COMMUNITIES("defaultCommunitiesFollowList", TopFilter.AllFollows), + FOLLOW_PACKS("defaultFollowPacksFollowList", TopFilter.Global), + APP_RECOMMENDATIONS("defaultAppRecommendationsFollowList", TopFilter.Global), + ; + + val key: Preferences.Key = stringPreferencesKey(prefKey) +} + +/** + * The per-account top-nav filter selections, stored one key per feed. + * + * Values are [TopFilter] as JSON, the same encoding the SharedPreferences + * implementation used, so a migrated store and a legacy one hold byte-identical + * strings. + */ +class TopNavFollowListStore( + private val store: DataStore, +) { + /** + * Every slot's current filter, falling back to [FollowListSlot.default] + * where the key is unset or unreadable. + * + * A value that fails to parse yields the default rather than propagating: + * one corrupt entry should cost the user that feed's filter, not the whole + * account load. + */ + suspend fun load(): Map { + val prefs = + store.data + .catch { e -> if (e is IOException) emit(emptyPreferences()) else throw e } + .first() + + return FollowListSlot.entries.associateWith { slot -> parse(prefs[slot.key], slot) } + } + + /** Writes every slot in one edit, so a crash cannot leave a half-applied set. */ + suspend fun saveAll(values: Map) { + store.edit { prefs -> + values.forEach { (slot, filter) -> prefs[slot.key] = JsonMapper.toJson(filter) } + } + } + + suspend fun save( + slot: FollowListSlot, + filter: TopFilter, + ) { + store.edit { prefs -> prefs[slot.key] = JsonMapper.toJson(filter) } + } + + private fun parse( + value: String?, + slot: FollowListSlot, + ): TopFilter { + if (value.isNullOrEmpty() || value == "null") return slot.default + return try { + JsonMapper.fromJson(value) + } catch (e: Exception) { + Log.w("TopNavFollowListStore") { "Could not decode ${slot.prefKey}; falling back to its default: ${e.message}" } + slot.default + } + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/TopNavFollowListStoreTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/TopNavFollowListStoreTest.kt new file mode 100644 index 0000000000..59f8a81d38 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/TopNavFollowListStoreTest.kt @@ -0,0 +1,223 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.preferences + +import androidx.datastore.core.DataMigration +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter +import com.vitorpamplona.quartz.nip01Core.core.JsonMapper +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.test.runTest +import okio.Path.Companion.toOkioPath +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +class TopNavFollowListStoreTest { + @get:Rule + val folder = TemporaryFolder() + + private var seq = 0 + + private fun store( + scope: CoroutineScope, + migrations: List> = emptyList(), + ): TopNavFollowListStore { + val file = File(folder.root, "acct_${seq++}.preferences_pb") + return TopNavFollowListStore( + PreferenceDataStoreFactory.createWithPath( + scope = scope, + migrations = migrations, + produceFile = { file.toOkioPath() }, + ), + ) + } + + private fun scope() = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + /** + * Encodes as the app does. + * + * The parameter type matters: kotlinx serializes by STATIC type, so + * `encode(TopFilter.Global)` sees the concrete object and writes + * `{}` with no polymorphic discriminator, which cannot be read back as a + * TopFilter. Widening to TopFilter here is what the production call sites do + * by reading a `StateFlow.value`. + */ + private fun encode(filter: TopFilter): String = JsonMapper.toJson(filter) + + /** An empty store must hand back each slot's documented default, not null and not a blanket Global. */ + @Test + fun emptyStoreReturnsEachSlotsOwnDefault() = + runTest { + val loaded = store(scope()).load() + + assertEquals(FollowListSlot.entries.size, loaded.size) + FollowListSlot.entries.forEach { slot -> + assertEquals("default for ${slot.prefKey}", slot.default, loaded.getValue(slot)) + } + } + + /** The defaults are not uniform — a regression that collapsed them would pass the test above. */ + @Test + fun defaultsAreNotAllTheSame() { + val defaults = FollowListSlot.entries.map { it.default }.toSet() + + assertTrue("expected several distinct defaults, got $defaults", defaults.size >= 4) + assertEquals(TopFilter.AllFollows, FollowListSlot.HOME.default) + assertEquals(TopFilter.Selected, FollowListSlot.NOTIFICATION.default) + assertEquals(TopFilter.AroundMe, FollowListSlot.GEOCACHES.default) + assertEquals(TopFilter.Mine, FollowListSlot.BADGES.default) + } + + @Test + fun savedValuesReadBack() = + runTest { + val subject = store(scope()) + + subject.save(FollowListSlot.HOME, TopFilter.Global) + + assertEquals(TopFilter.Global, subject.load().getValue(FollowListSlot.HOME)) + assertEquals("other slots keep their defaults", TopFilter.Selected, subject.load().getValue(FollowListSlot.NOTIFICATION)) + } + + @Test + fun saveAllWritesEverySlot() = + runTest { + val subject = store(scope()) + + subject.saveAll(FollowListSlot.entries.associateWith { TopFilter.Mine }) + + assertTrue(subject.load().values.all { it == TopFilter.Mine }) + } + + /** A corrupt entry costs that one feed's filter, not the whole account load. */ + @Test + fun anUnparseableValueFallsBackToThatSlotsDefault() = + runTest { + val scope = scope() + val file = File(folder.root, "corrupt.preferences_pb") + val raw = + PreferenceDataStoreFactory.createWithPath(scope = scope, produceFile = { file.toOkioPath() }) + raw.updateData { prefs -> + prefs.toMutablePreferences().apply { + this[FollowListSlot.HOME.key] = "{ not json" + this[FollowListSlot.STORIES.key] = encode(TopFilter.Mine) + } + } + + val loaded = TopNavFollowListStore(raw).load() + + assertEquals(FollowListSlot.HOME.default, loaded.getValue(FollowListSlot.HOME)) + assertEquals("a sibling slot still loads", TopFilter.Mine, loaded.getValue(FollowListSlot.STORIES)) + } + + /** The prefKeys are a compatibility surface: renaming one silently resets that feed for everyone. */ + @Test + fun prefKeysAreTheOnesTheAndroidAppHasAlwaysWritten() { + assertEquals("defaultHomeFollowList", FollowListSlot.HOME.prefKey) + assertEquals("defaultNotificationFollowList", FollowListSlot.NOTIFICATION.prefKey) + assertEquals("defaultAppRecommendationsFollowList", FollowListSlot.APP_RECOMMENDATIONS.prefKey) + assertEquals(31, FollowListSlot.entries.size) + assertEquals( + "prefKeys must be unique", + 31, + FollowListSlot.entries + .map { it.prefKey } + .toSet() + .size, + ) + } + + // ── migration ────────────────────────────────────────────────────── + + @Test + fun migrationCopiesLegacyValuesOnFirstRead() = + runTest { + val legacy = + mapOf( + Pair(FollowListSlot.HOME.key, encode(TopFilter.Global)), + Pair(FollowListSlot.BADGES.key, encode(TopFilter.AllFollows)), + ) + + val loaded = store(scope(), listOf(CopyOnceMigration("migrated.followLists") { legacy })).load() + + assertEquals(TopFilter.Global, loaded.getValue(FollowListSlot.HOME)) + assertEquals(TopFilter.AllFollows, loaded.getValue(FollowListSlot.BADGES)) + assertEquals("unmigrated slots keep defaults", FollowListSlot.STORIES.default, loaded.getValue(FollowListSlot.STORIES)) + } + + /** + * The migration must not run a second time and overwrite what the user has + * changed since — the marker key in the destination is what prevents it. + * + * Each open gets its own scope, cancelled before the next: DataStore + * refuses two live instances over one file, which is exactly why + * [AccountPreferenceStores] caches one per account. + */ + @Test + fun migrationDoesNotClobberLaterEdits() = + runTest { + val file = File(folder.root, "once.preferences_pb") + var reads = 0 + val legacy = mapOf(Pair(FollowListSlot.HOME.key, encode(TopFilter.Global))) + + suspend fun withStore(block: suspend (TopNavFollowListStore) -> T): T { + val scope = scope() + try { + return block( + TopNavFollowListStore( + PreferenceDataStoreFactory.createWithPath( + scope = scope, + migrations = + listOf( + CopyOnceMigration("migrated.followLists") { + reads++ + legacy + }, + ), + produceFile = { file.toOkioPath() }, + ), + ), + ) + } finally { + scope.cancel() + } + } + + assertEquals(TopFilter.Global, withStore { it.load().getValue(FollowListSlot.HOME) }) + withStore { it.save(FollowListSlot.HOME, TopFilter.Mine) } + + assertEquals( + "the user's later choice survives a reopen", + TopFilter.Mine, + withStore { it.load().getValue(FollowListSlot.HOME) }, + ) + assertEquals("the legacy store is read once", 1, reads) + } +} From 668e84b4ef74bb194a73d778078554c321ae22c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 16:15:15 +0000 Subject: [PATCH 03/50] refactor: move the 26 cached account events to DataStore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 2b of the SharedPreferences -> DataStore migration, finishing the bulk keys. LatestEventSlot names the account-level events cached for a cold start — the user's own metadata, contact list, relay lists, mute list and the rest — and LatestEventCacheStore reads and writes them, replacing 26 getString calls and 26 putOrRemove calls in LocalPreferences. The store is deliberately untyped, moving strings rather than events. Each slot holds a different event type and the app parses them in parallel with the right parser for each, so encoding stays at the call site. That also keeps the stored bytes identical to what the SharedPreferences implementation wrote (OptimizedJsonMapper, not JsonMapper), which is what lets the migration be a straight copy. A null value removes its key rather than storing a blank, so an event the account no longer has stops being served from cache instead of lingering as a stale copy. Carried over by the same CopyOnceMigration as the follow lists, under its own marker key so the two migrate independently. Losing this cache is not fatal — the events re-fetch from relays — but it costs a blank first screen, so the key names are still treated as a compatibility surface and asserted in tests. 6 tests: an empty store reports no slots rather than empty strings, JSON reads back verbatim, null removes, slots are independent, the keys are the ones the app has always written, and the migration copies only what the legacy store had. Known-flaky test note: :amethyst:testPlayDebugUnitTest intermittently fails Nip46ConsentInfoBuilderTest with UncaughtExceptionsBeforeTest — an exception leaking from an earlier test in the same worker. Reproduced on an unmodified main before this series started; it passes on re-run. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AXvKXakvup4inNFfAhhr4L --- .../amethyst/LocalPreferences.kt | 130 +++++++++-------- .../preferences/LatestEventCacheStore.kt | 112 +++++++++++++++ .../preferences/LatestEventCacheStoreTest.kt | 131 ++++++++++++++++++ 3 files changed, 316 insertions(+), 57 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/LatestEventCacheStore.kt create mode 100644 commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/LatestEventCacheStoreTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 467f07eb97..2f990985e8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -37,6 +37,8 @@ import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntr import com.vitorpamplona.amethyst.commons.model.preferences.AccountPreferenceStores import com.vitorpamplona.amethyst.commons.model.preferences.CopyOnceMigration import com.vitorpamplona.amethyst.commons.model.preferences.FollowListSlot +import com.vitorpamplona.amethyst.commons.model.preferences.LatestEventCacheStore +import com.vitorpamplona.amethyst.commons.model.preferences.LatestEventSlot import com.vitorpamplona.amethyst.commons.model.preferences.TopNavFollowListStore import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy @@ -268,12 +270,14 @@ object LocalPreferences { Amethyst.instance.appContext.filesDir .toOkioPath() }, - migrations = { npub -> listOf(followListMigration(npub)) }, + migrations = { npub -> listOf(followListMigration(npub), latestEventMigration(npub)) }, ) } private fun followListStore(npub: String) = TopNavFollowListStore(accountStores.getDataStore(npub)) + private fun latestEventStore(npub: String) = LatestEventCacheStore(accountStores.getDataStore(npub)) + private fun followListMigration(npub: String) = CopyOnceMigration("migrated.followLists") { withContext(Dispatchers.IO) { @@ -288,6 +292,17 @@ object LocalPreferences { } } + private fun latestEventMigration(npub: String) = + CopyOnceMigration("migrated.latestEvents") { + withContext(Dispatchers.IO) { + val legacy = encryptedPreferences(npub) + LatestEventSlot.entries + .mapNotNull { slot -> + legacy.getString(slot.prefKey, null)?.let { Pair(slot.key, it) } + }.toMap() + } + } + // Global master switch for the always-on notification service ("Background // notification service"). Default ON: existing users keep current behavior, and // per-account participation decides who actually stays active. @@ -591,8 +606,6 @@ object LocalPreferences { // Remove legacy key after migration remove(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER) - putOrRemove(PrefKeys.LATEST_CONTACT_LIST, settings.backupContactList) - // The undecided conflicts themselves, not just the backups they hold back. // Without these the card vanishes on the next launch and the user never // answers the question the backup is still waiting on. @@ -603,40 +616,12 @@ object LocalPreferences { putString(PrefKeys.OPEN_BACKUP_CONFLICTS, BackupConflictStorage.encode(openConflicts)) } - putOrRemove(PrefKeys.LATEST_USER_METADATA, settings.backupUserMetadata) - putOrRemove(PrefKeys.LATEST_DM_RELAY_LIST, settings.backupDMRelayList) - putOrRemove(PrefKeys.LATEST_NIP65_RELAY_LIST, settings.backupNIP65RelayList) - putOrRemove(PrefKeys.LATEST_SEARCH_RELAY_LIST, settings.backupSearchRelayList) - putOrRemove(PrefKeys.LATEST_INDEX_RELAY_LIST, settings.backupIndexRelayList) - putOrRemove(PrefKeys.LATEST_RELAY_FEEDS_LIST, settings.backupRelayFeedsList) - putOrRemove(PrefKeys.LATEST_BLOCKED_RELAY_LIST, settings.backupBlockedRelayList) - putOrRemove(PrefKeys.LATEST_TRUSTED_RELAY_LIST, settings.backupTrustedRelayList) - if (settings.localRelayServers.value.isNotEmpty()) { putStringSet(PrefKeys.LOCAL_RELAY_SERVERS, settings.localRelayServers.value) } else { remove(PrefKeys.LOCAL_RELAY_SERVERS) } - putOrRemove(PrefKeys.LATEST_MUTE_LIST, settings.backupMuteList) - putOrRemove(PrefKeys.LATEST_PRIVATE_HOME_RELAY_LIST, settings.backupPrivateHomeRelayList) - putOrRemove(PrefKeys.LATEST_APP_SPECIFIC_DATA, settings.backupAppSpecificData) - - putOrRemove(PrefKeys.LATEST_CHANNEL_LIST, settings.backupChannelList) - putOrRemove(PrefKeys.LATEST_COMMUNITY_LIST, settings.backupCommunityList) - putOrRemove(PrefKeys.LATEST_HASHTAG_LIST, settings.backupHashtagList) - putOrRemove(PrefKeys.LATEST_GEOHASH_LIST, settings.backupGeohashList) - putOrRemove(PrefKeys.LATEST_EPHEMERAL_LIST, settings.backupEphemeralChatList) - putOrRemove(PrefKeys.LATEST_RELAY_GROUP_LIST, settings.backupRelayGroupList) - putOrRemove(PrefKeys.LATEST_CONCORD_LIST, settings.backupConcordList) - putOrRemove(PrefKeys.LATEST_TRUST_PROVIDER_LIST, settings.backupTrustProviderList) - putOrRemove(PrefKeys.LATEST_KEY_PACKAGE_RELAY_LIST, settings.backupKeyPackageRelayList) - putOrRemove(PrefKeys.LATEST_FAVORITE_ALGO_FEEDS_LIST, settings.backupFavoriteAlgoFeedsList) - putOrRemove(PrefKeys.LATEST_PAYMENT_TARGETS, settings.backupNipA3PaymentTargets) - putOrRemove(PrefKeys.LATEST_BOLT12_OFFERS, settings.backupBolt12Offers) - putOrRemove(PrefKeys.LATEST_CASHU_WALLET, settings.backupCashuWallet) - putOrRemove(PrefKeys.LATEST_NUTZAP_INFO, settings.backupNutzapInfo) - 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) @@ -687,6 +672,36 @@ object LocalPreferences { ) } } + latestEventStore(settings.keyPair.pubKey.toNpub()).saveAll( + mapOf( + LatestEventSlot.CONTACT_LIST to settings.backupContactList?.let { OptimizedJsonMapper.toJson(it) }, + LatestEventSlot.USER_METADATA to settings.backupUserMetadata?.let { OptimizedJsonMapper.toJson(it) }, + LatestEventSlot.DM_RELAY_LIST to settings.backupDMRelayList?.let { OptimizedJsonMapper.toJson(it) }, + LatestEventSlot.NIP65_RELAY_LIST to settings.backupNIP65RelayList?.let { OptimizedJsonMapper.toJson(it) }, + LatestEventSlot.SEARCH_RELAY_LIST to settings.backupSearchRelayList?.let { OptimizedJsonMapper.toJson(it) }, + LatestEventSlot.INDEX_RELAY_LIST to settings.backupIndexRelayList?.let { OptimizedJsonMapper.toJson(it) }, + LatestEventSlot.RELAY_FEEDS_LIST to settings.backupRelayFeedsList?.let { OptimizedJsonMapper.toJson(it) }, + LatestEventSlot.BLOCKED_RELAY_LIST to settings.backupBlockedRelayList?.let { OptimizedJsonMapper.toJson(it) }, + LatestEventSlot.TRUSTED_RELAY_LIST to settings.backupTrustedRelayList?.let { OptimizedJsonMapper.toJson(it) }, + LatestEventSlot.MUTE_LIST to settings.backupMuteList?.let { OptimizedJsonMapper.toJson(it) }, + LatestEventSlot.PRIVATE_HOME_RELAY_LIST to settings.backupPrivateHomeRelayList?.let { OptimizedJsonMapper.toJson(it) }, + LatestEventSlot.APP_SPECIFIC_DATA to settings.backupAppSpecificData?.let { OptimizedJsonMapper.toJson(it) }, + LatestEventSlot.CHANNEL_LIST to settings.backupChannelList?.let { OptimizedJsonMapper.toJson(it) }, + LatestEventSlot.COMMUNITY_LIST to settings.backupCommunityList?.let { OptimizedJsonMapper.toJson(it) }, + LatestEventSlot.HASHTAG_LIST to settings.backupHashtagList?.let { OptimizedJsonMapper.toJson(it) }, + LatestEventSlot.GEOHASH_LIST to settings.backupGeohashList?.let { OptimizedJsonMapper.toJson(it) }, + LatestEventSlot.EPHEMERAL_LIST to settings.backupEphemeralChatList?.let { OptimizedJsonMapper.toJson(it) }, + LatestEventSlot.RELAY_GROUP_LIST to settings.backupRelayGroupList?.let { OptimizedJsonMapper.toJson(it) }, + LatestEventSlot.CONCORD_LIST to settings.backupConcordList?.let { OptimizedJsonMapper.toJson(it) }, + LatestEventSlot.TRUST_PROVIDER_LIST to settings.backupTrustProviderList?.let { OptimizedJsonMapper.toJson(it) }, + LatestEventSlot.KEY_PACKAGE_RELAY_LIST to settings.backupKeyPackageRelayList?.let { OptimizedJsonMapper.toJson(it) }, + LatestEventSlot.FAVORITE_ALGO_FEEDS_LIST to settings.backupFavoriteAlgoFeedsList?.let { OptimizedJsonMapper.toJson(it) }, + LatestEventSlot.PAYMENT_TARGETS to settings.backupNipA3PaymentTargets?.let { OptimizedJsonMapper.toJson(it) }, + LatestEventSlot.BOLT12_OFFERS to settings.backupBolt12Offers?.let { OptimizedJsonMapper.toJson(it) }, + LatestEventSlot.CASHU_WALLET to settings.backupCashuWallet?.let { OptimizedJsonMapper.toJson(it) }, + LatestEventSlot.NUTZAP_INFO to settings.backupNutzapInfo?.let { OptimizedJsonMapper.toJson(it) }, + ), + ) followListStore(settings.keyPair.pubKey.toNpub()).saveAll( mapOf( FollowListSlot.HOME to settings.defaultHomeFollowList.value, @@ -865,32 +880,33 @@ object LocalPreferences { val pendingAttestationsStr = getString(PrefKeys.PENDING_ATTESTATIONS, null) val openBackupConflictsStr = getString(PrefKeys.OPEN_BACKUP_CONFLICTS, null) - val latestUserMetadataStr = getString(PrefKeys.LATEST_USER_METADATA, null) - val latestContactListStr = getString(PrefKeys.LATEST_CONTACT_LIST, null) - val latestDmRelayListStr = getString(PrefKeys.LATEST_DM_RELAY_LIST, null) - val latestNip65RelayListStr = getString(PrefKeys.LATEST_NIP65_RELAY_LIST, null) - val latestSearchRelayListStr = getString(PrefKeys.LATEST_SEARCH_RELAY_LIST, null) - val latestIndexRelayListStr = getString(PrefKeys.LATEST_INDEX_RELAY_LIST, null) - val latestRelayFeedsListStr = getString(PrefKeys.LATEST_RELAY_FEEDS_LIST, null) - val latestBlockedRelayListStr = getString(PrefKeys.LATEST_BLOCKED_RELAY_LIST, null) - val latestTrustedRelayListStr = getString(PrefKeys.LATEST_TRUSTED_RELAY_LIST, null) - val latestMuteListStr = getString(PrefKeys.LATEST_MUTE_LIST, null) - val latestPrivateHomeRelayListStr = getString(PrefKeys.LATEST_PRIVATE_HOME_RELAY_LIST, null) - val latestAppSpecificDataStr = getString(PrefKeys.LATEST_APP_SPECIFIC_DATA, null) - val latestChannelListStr = getString(PrefKeys.LATEST_CHANNEL_LIST, null) - val latestCommunityListStr = getString(PrefKeys.LATEST_COMMUNITY_LIST, null) - val latestHashtagListStr = getString(PrefKeys.LATEST_HASHTAG_LIST, null) - val latestGeohashListStr = getString(PrefKeys.LATEST_GEOHASH_LIST, null) - val latestEphemeralListStr = getString(PrefKeys.LATEST_EPHEMERAL_LIST, null) - val latestRelayGroupListStr = getString(PrefKeys.LATEST_RELAY_GROUP_LIST, null) - val latestConcordListStr = getString(PrefKeys.LATEST_CONCORD_LIST, null) - val latestTrustProviderListStr = getString(PrefKeys.LATEST_TRUST_PROVIDER_LIST, null) - val latestKeyPackageRelayListStr = getString(PrefKeys.LATEST_KEY_PACKAGE_RELAY_LIST, null) - val latestFavoriteAlgoFeedsListStr = getString(PrefKeys.LATEST_FAVORITE_ALGO_FEEDS_LIST, null) - val latestPaymentTargetsStr = getString(PrefKeys.LATEST_PAYMENT_TARGETS, null) - val latestBolt12OffersStr = getString(PrefKeys.LATEST_BOLT12_OFFERS, null) - val latestCashuWalletStr = getString(PrefKeys.LATEST_CASHU_WALLET, null) - val latestNutzapInfoStr = getString(PrefKeys.LATEST_NUTZAP_INFO, null) + val latestEvents = latestEventStore(keyPair.pubKey.toNpub()).load() + val latestUserMetadataStr = latestEvents[LatestEventSlot.USER_METADATA] + val latestContactListStr = latestEvents[LatestEventSlot.CONTACT_LIST] + val latestDmRelayListStr = latestEvents[LatestEventSlot.DM_RELAY_LIST] + val latestNip65RelayListStr = latestEvents[LatestEventSlot.NIP65_RELAY_LIST] + val latestSearchRelayListStr = latestEvents[LatestEventSlot.SEARCH_RELAY_LIST] + val latestIndexRelayListStr = latestEvents[LatestEventSlot.INDEX_RELAY_LIST] + val latestRelayFeedsListStr = latestEvents[LatestEventSlot.RELAY_FEEDS_LIST] + val latestBlockedRelayListStr = latestEvents[LatestEventSlot.BLOCKED_RELAY_LIST] + val latestTrustedRelayListStr = latestEvents[LatestEventSlot.TRUSTED_RELAY_LIST] + val latestMuteListStr = latestEvents[LatestEventSlot.MUTE_LIST] + val latestPrivateHomeRelayListStr = latestEvents[LatestEventSlot.PRIVATE_HOME_RELAY_LIST] + val latestAppSpecificDataStr = latestEvents[LatestEventSlot.APP_SPECIFIC_DATA] + val latestChannelListStr = latestEvents[LatestEventSlot.CHANNEL_LIST] + val latestCommunityListStr = latestEvents[LatestEventSlot.COMMUNITY_LIST] + val latestHashtagListStr = latestEvents[LatestEventSlot.HASHTAG_LIST] + val latestGeohashListStr = latestEvents[LatestEventSlot.GEOHASH_LIST] + val latestEphemeralListStr = latestEvents[LatestEventSlot.EPHEMERAL_LIST] + val latestRelayGroupListStr = latestEvents[LatestEventSlot.RELAY_GROUP_LIST] + val latestConcordListStr = latestEvents[LatestEventSlot.CONCORD_LIST] + val latestTrustProviderListStr = latestEvents[LatestEventSlot.TRUST_PROVIDER_LIST] + val latestKeyPackageRelayListStr = latestEvents[LatestEventSlot.KEY_PACKAGE_RELAY_LIST] + val latestFavoriteAlgoFeedsListStr = latestEvents[LatestEventSlot.FAVORITE_ALGO_FEEDS_LIST] + val latestPaymentTargetsStr = latestEvents[LatestEventSlot.PAYMENT_TARGETS] + val latestBolt12OffersStr = latestEvents[LatestEventSlot.BOLT12_OFFERS] + val latestCashuWalletStr = latestEvents[LatestEventSlot.CASHU_WALLET] + val latestNutzapInfoStr = latestEvents[LatestEventSlot.NUTZAP_INFO] val lastReadPerRouteStr = getString(PrefKeys.LAST_READ_PER_ROUTE, null) Log.d("LocalPreferences") { "Load account from file $npub - before parsing events" } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/LatestEventCacheStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/LatestEventCacheStore.kt new file mode 100644 index 0000000000..b86f434ee0 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/LatestEventCacheStore.kt @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.preferences + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.stringPreferencesKey +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.first +import okio.IOException + +/** + * The account-level events cached on disk so a cold start can show something + * before any relay answers — the user's own metadata, contact list, relay + * lists, mute list and the rest. + * + * As with [FollowListSlot], the key strings are the ones the Android app has + * always written, so renaming one throws away that cache for existing users. + * Losing it is not fatal — the event is re-fetched from relays — but it costs a + * blank first screen, so the names are still a compatibility surface. + */ +enum class LatestEventSlot( + val prefKey: String, +) { + CONTACT_LIST("latestContactList"), + USER_METADATA("latestUserMetadata"), + DM_RELAY_LIST("latestDMRelayList"), + NIP65_RELAY_LIST("latestNIP65RelayList"), + SEARCH_RELAY_LIST("latestSearchRelayList"), + INDEX_RELAY_LIST("latestIndexRelayList"), + RELAY_FEEDS_LIST("latestRelayFeedsList"), + BLOCKED_RELAY_LIST("latestBlockedRelayList"), + TRUSTED_RELAY_LIST("latestTrustedRelayList"), + MUTE_LIST("latestMuteList"), + PRIVATE_HOME_RELAY_LIST("latestPrivateHomeRelayList"), + APP_SPECIFIC_DATA("latestAppSpecificData"), + CHANNEL_LIST("latestChannelList"), + COMMUNITY_LIST("latestCommunityList"), + HASHTAG_LIST("latestHashtagList"), + GEOHASH_LIST("latestGeohashList"), + EPHEMERAL_LIST("latestEphemeralChatList"), + RELAY_GROUP_LIST("latestRelayGroupList"), + CONCORD_LIST("latestConcordList"), + TRUST_PROVIDER_LIST("latestTrustProviderList"), + KEY_PACKAGE_RELAY_LIST("latestKeyPackageRelayList"), + FAVORITE_ALGO_FEEDS_LIST("latestFavoriteAlgoFeedsList"), + PAYMENT_TARGETS("latestPaymentTargets"), + BOLT12_OFFERS("latestBolt12Offers"), + CASHU_WALLET("latestCashuWallet"), + NUTZAP_INFO("latestNutzapInfo"), + ; + + val key: Preferences.Key = stringPreferencesKey(prefKey) +} + +/** + * Raw storage for [LatestEventSlot], deliberately untyped. + * + * Each slot holds a different event type and the app parses them in parallel + * with the right parser for each, so this store moves strings and leaves + * encoding to the caller. That also keeps the bytes identical to what the + * SharedPreferences implementation wrote, which the migration relies on. + */ +class LatestEventCacheStore( + private val store: DataStore, +) { + /** Only the slots actually present; an absent slot means nothing was cached. */ + suspend fun load(): Map { + val prefs = + store.data + .catch { e -> if (e is IOException) emit(emptyPreferences()) else throw e } + .first() + + return LatestEventSlot.entries + .mapNotNull { slot -> + prefs[slot.key]?.let { Pair(slot, it) } + }.toMap() + } + + /** + * Writes every slot in one edit. A null value removes the key, so an event + * the account no longer has stops being served from cache instead of + * lingering as a stale copy. + */ + suspend fun saveAll(values: Map) { + store.edit { prefs -> + values.forEach { (slot, json) -> + if (json != null) prefs[slot.key] = json else prefs.remove(slot.key) + } + } + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/LatestEventCacheStoreTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/LatestEventCacheStoreTest.kt new file mode 100644 index 0000000000..66bc5122ab --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/LatestEventCacheStoreTest.kt @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.preferences + +import androidx.datastore.core.DataMigration +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.test.runTest +import okio.Path.Companion.toOkioPath +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +class LatestEventCacheStoreTest { + @get:Rule + val folder = TemporaryFolder() + + private var seq = 0 + + private fun store(migrations: List> = emptyList()): LatestEventCacheStore { + val file = File(folder.root, "cache_${seq++}.preferences_pb") + return LatestEventCacheStore( + PreferenceDataStoreFactory.createWithPath( + scope = CoroutineScope(Dispatchers.IO + SupervisorJob()), + migrations = migrations, + produceFile = { file.toOkioPath() }, + ), + ) + } + + /** An absent slot means nothing was cached — not an empty string that would fail to parse. */ + @Test + fun anEmptyStoreReturnsNoSlots() = + runTest { + assertTrue(store().load().isEmpty()) + } + + @Test + fun savedJsonReadsBackVerbatim() = + runTest { + val subject = store() + val json = """{"id":"abc","kind":0,"content":"{}"}""" + + subject.saveAll(mapOf(LatestEventSlot.USER_METADATA to json)) + + assertEquals(json, subject.load()[LatestEventSlot.USER_METADATA]) + } + + /** + * A null must remove the key. Leaving the previous value behind would keep + * serving an event the account no longer has. + */ + @Test + fun aNullValueRemovesTheSlot() = + runTest { + val subject = store() + subject.saveAll(mapOf(LatestEventSlot.MUTE_LIST to """{"kind":10000}""")) + + subject.saveAll(mapOf(LatestEventSlot.MUTE_LIST to null)) + + assertFalse(subject.load().containsKey(LatestEventSlot.MUTE_LIST)) + } + + @Test + fun slotsAreIndependent() = + runTest { + val subject = store() + + subject.saveAll( + mapOf( + LatestEventSlot.CONTACT_LIST to """{"kind":3}""", + LatestEventSlot.MUTE_LIST to null, + ), + ) + + assertEquals("""{"kind":3}""", subject.load()[LatestEventSlot.CONTACT_LIST]) + assertFalse(subject.load().containsKey(LatestEventSlot.MUTE_LIST)) + } + + @Test + fun prefKeysMatchWhatTheAppHasAlwaysWritten() { + assertEquals("latestUserMetadata", LatestEventSlot.USER_METADATA.prefKey) + assertEquals("latestContactList", LatestEventSlot.CONTACT_LIST.prefKey) + assertEquals("latestNIP65RelayList", LatestEventSlot.NIP65_RELAY_LIST.prefKey) + assertEquals(26, LatestEventSlot.entries.size) + assertEquals( + "prefKeys must be unique", + 26, + LatestEventSlot.entries + .map { it.prefKey } + .toSet() + .size, + ) + } + + @Test + fun migrationCopiesTheLegacyCache() = + runTest { + val legacy = mapOf(Pair(LatestEventSlot.USER_METADATA.key, """{"kind":0}""")) + + val loaded = store(listOf(CopyOnceMigration("migrated.latestEvents") { legacy })).load() + + assertEquals("""{"kind":0}""", loaded[LatestEventSlot.USER_METADATA]) + assertFalse("slots absent from the legacy store stay absent", loaded.containsKey(LatestEventSlot.MUTE_LIST)) + } +} From f6a34e1f5f3d601795bb9ecd2041938a7ceb6829 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 16:42:12 +0000 Subject: [PATCH 04/50] refactor: move the per-account setting groups to DataStore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 3 of the SharedPreferences -> DataStore migration: the 28 remaining per-account settings, as five domain stores in commons/commonMain rather than one file. UploadSettingsStore 6 what is stripped, mirrored, cached, uploaded DialogDismissalStore 9 what the account has dismissed for good RelayAuthStore 5 when to answer a NIP-42 AUTH challenge FeedVisibilityStore 5 which feeds show and how they are laid out NotificationPrefsStore 5 this account's share of notification settings Each group loads and saves in one edit, so a crash cannot half-apply it. Defaults are the risky part and are asserted per group: they must match what SharedPreferences returned for a missing key, because an account that never touched a setting has no stored value and takes the default forever after. Several are `true` (strip location, mirror uploads, local blossom cache, three of the four AUTH trust flags, show messages in notifications, calls enabled), so a group that defaulted everything to false would quietly turn features off for every existing user. Two keys are kept out of NotificationPrefs and its bulk save, with their own accessors instead: - notif_global_to_curated_migrated is a one-shot migration marker, not a user setting. A bulk save carrying a stale copy could re-run the migration or wrongly suppress it. - last_read_per_route is written on its own path as the user reads things, at a different cadence from the settings around it. Caught while verifying rather than by the compiler: readInboxPrefs() was left reading DEFAULT_RELAY_AUTH_POLICY, the four AUTH trust flags and the view-mode keys from the legacy store, while their writes had already moved to DataStore. Compiles clean, and silently loses every change to those nine settings. It now takes RelayAuth and FeedVisibility. The check that found it — every migrated key must have exactly one remaining reference, the migration's own read — now passes for all 28. innerLoadCurrentAccountFromEncryptedStorage hit the JVM's 64KB method limit: it was already 341 lines and each suspend call adds a state to the generated coroutine state machine, so seven separate store loads pushed it over. They are now one call returning AccountStoreData. That function wants decomposing on its own merits; this is the minimum to get under the limit. 12 tests covering defaults, round trips including string sets, that a null string field clears its key rather than leaving the old value, that the two special-cased keys survive a bulk save, and that the key strings are the ones the app has always written. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AXvKXakvup4inNFfAhhr4L --- .../amethyst/LocalPreferences.kt | 357 ++++++++++++------ .../model/preferences/CopyOnceMigration.kt | 15 +- .../model/preferences/DialogDismissalStore.kt | 106 ++++++ .../model/preferences/FeedVisibilityStore.kt | 89 +++++ .../preferences/NotificationPrefsStore.kt | 111 ++++++ .../model/preferences/RelayAuthStore.kt | 89 +++++ .../model/preferences/UploadSettingsStore.kt | 90 +++++ .../preferences/AccountSettingStoresTest.kt | 221 +++++++++++ .../preferences/LatestEventCacheStoreTest.kt | 2 +- .../preferences/TopNavFollowListStoreTest.kt | 6 +- 10 files changed, 960 insertions(+), 126 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/DialogDismissalStore.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/FeedVisibilityStore.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/NotificationPrefsStore.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/RelayAuthStore.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/UploadSettingsStore.kt create mode 100644 commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountSettingStoresTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 2f990985e8..ce40ff0157 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -36,10 +36,20 @@ import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntr import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntryNorm import com.vitorpamplona.amethyst.commons.model.preferences.AccountPreferenceStores import com.vitorpamplona.amethyst.commons.model.preferences.CopyOnceMigration +import com.vitorpamplona.amethyst.commons.model.preferences.DialogDismissal +import com.vitorpamplona.amethyst.commons.model.preferences.DialogDismissalStore +import com.vitorpamplona.amethyst.commons.model.preferences.FeedVisibility +import com.vitorpamplona.amethyst.commons.model.preferences.FeedVisibilityStore import com.vitorpamplona.amethyst.commons.model.preferences.FollowListSlot import com.vitorpamplona.amethyst.commons.model.preferences.LatestEventCacheStore import com.vitorpamplona.amethyst.commons.model.preferences.LatestEventSlot +import com.vitorpamplona.amethyst.commons.model.preferences.NotificationPrefs +import com.vitorpamplona.amethyst.commons.model.preferences.NotificationPrefsStore +import com.vitorpamplona.amethyst.commons.model.preferences.RelayAuth +import com.vitorpamplona.amethyst.commons.model.preferences.RelayAuthStore import com.vitorpamplona.amethyst.commons.model.preferences.TopNavFollowListStore +import com.vitorpamplona.amethyst.commons.model.preferences.UploadSettings +import com.vitorpamplona.amethyst.commons.model.preferences.UploadSettingsStore import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy import com.vitorpamplona.amethyst.model.AccountSettings @@ -270,7 +280,10 @@ object LocalPreferences { Amethyst.instance.appContext.filesDir .toOkioPath() }, - migrations = { npub -> listOf(followListMigration(npub), latestEventMigration(npub)) }, + migrations = { npub -> + listOf(followListMigration(npub), latestEventMigration(npub)) + + listOf(uploadSettingsMigration(npub), dialogDismissalMigration(npub), relayAuthMigration(npub), feedVisibilityMigration(npub), notificationPrefsMigration(npub)) + }, ) } @@ -278,28 +291,126 @@ object LocalPreferences { private fun latestEventStore(npub: String) = LatestEventCacheStore(accountStores.getDataStore(npub)) - private fun followListMigration(npub: String) = - CopyOnceMigration("migrated.followLists") { + private fun uploadSettingsStore(npub: String) = UploadSettingsStore(accountStores.getDataStore(npub)) + + private fun dialogDismissalStore(npub: String) = DialogDismissalStore(accountStores.getDataStore(npub)) + + private fun relayAuthStore(npub: String) = RelayAuthStore(accountStores.getDataStore(npub)) + + private fun feedVisibilityStore(npub: String) = FeedVisibilityStore(accountStores.getDataStore(npub)) + + private fun notificationPrefsStore(npub: String) = NotificationPrefsStore(accountStores.getDataStore(npub)) + + private fun uploadSettingsMigration(npub: String) = + CopyOnceMigration("migrated.uploadSettings") { out -> withContext(Dispatchers.IO) { val legacy = encryptedPreferences(npub) - // Pair(...) and not `slot.key to it`: androidx.datastore declares its - // own infix `to` on Preferences.Key, which would build a - // Preferences.Pair instead of the kotlin Pair toMap() needs. - FollowListSlot.entries - .mapNotNull { slot -> - legacy.getString(slot.prefKey, null)?.let { Pair(slot.key, it) } - }.toMap() + if (legacy.contains(PrefKeys.STRIP_LOCATION_ON_UPLOAD)) out[UploadSettingsStore.stripLocationOnUpload] = legacy.getBoolean(PrefKeys.STRIP_LOCATION_ON_UPLOAD, false) + if (legacy.contains(PrefKeys.OPTIMIZE_MEDIA_ON_UPLOAD)) out[UploadSettingsStore.optimizeMediaOnUpload] = legacy.getBoolean(PrefKeys.OPTIMIZE_MEDIA_ON_UPLOAD, false) + if (legacy.contains(PrefKeys.MIRROR_UPLOADS_TO_ALL_SERVERS)) out[UploadSettingsStore.mirrorUploadsToAllServers] = legacy.getBoolean(PrefKeys.MIRROR_UPLOADS_TO_ALL_SERVERS, false) + if (legacy.contains(PrefKeys.USE_LOCAL_BLOSSOM_CACHE)) out[UploadSettingsStore.useLocalBlossomCache] = legacy.getBoolean(PrefKeys.USE_LOCAL_BLOSSOM_CACHE, false) + if (legacy.contains(PrefKeys.LOCAL_BLOSSOM_CACHE_PROFILE_PICTURES_ONLY)) out[UploadSettingsStore.localBlossomCacheProfilePicturesOnly] = legacy.getBoolean(PrefKeys.LOCAL_BLOSSOM_CACHE_PROFILE_PICTURES_ONLY, false) + legacy.getString(PrefKeys.DEFAULT_FILE_SERVER, null)?.let { out[UploadSettingsStore.defaultFileServerJson] = it } + } + } + + private fun dialogDismissalMigration(npub: String) = + CopyOnceMigration("migrated.dialogDismissal") { out -> + withContext(Dispatchers.IO) { + val legacy = encryptedPreferences(npub) + if (legacy.contains(PrefKeys.HIDE_DELETE_REQUEST_DIALOG)) out[DialogDismissalStore.hideDeleteRequestDialog] = legacy.getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false) + if (legacy.contains(PrefKeys.HIDE_BLOCK_ALERT_DIALOG)) out[DialogDismissalStore.hideBlockAlertDialog] = legacy.getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false) + if (legacy.contains(PrefKeys.HIDE_NIP_17_WARNING_DIALOG)) out[DialogDismissalStore.hideNip17WarningDialog] = legacy.getBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, false) + if (legacy.contains(PrefKeys.HIDE_COMMUNITY_RULES_VIOLATIONS)) out[DialogDismissalStore.hideCommunityRulesViolations] = legacy.getBoolean(PrefKeys.HIDE_COMMUNITY_RULES_VIOLATIONS, false) + legacy.getStringSet(PrefKeys.DISMISSED_POLL_NOTE_IDS, null)?.let { out[DialogDismissalStore.dismissedPollNoteIds] = it } + legacy.getStringSet(PrefKeys.DISMISSED_CHANNEL_INVITES, null)?.let { out[DialogDismissalStore.dismissedChannelInvites] = it } + legacy.getStringSet(PrefKeys.MUTED_PUBLIC_CHATS, null)?.let { out[DialogDismissalStore.mutedPublicChats] = it } + legacy.getStringSet(PrefKeys.HAS_DONATED_IN_VERSION, null)?.let { out[DialogDismissalStore.hasDonatedInVersion] = it } + legacy.getString(PrefKeys.VIEWED_POLL_RESULT_NOTE_IDS, null)?.let { out[DialogDismissalStore.viewedPollResultNoteIdsJson] = it } + } + } + + private fun relayAuthMigration(npub: String) = + CopyOnceMigration("migrated.relayAuth") { out -> + withContext(Dispatchers.IO) { + val legacy = encryptedPreferences(npub) + legacy.getString(PrefKeys.DEFAULT_RELAY_AUTH_POLICY, null)?.let { out[RelayAuthStore.policyName] = it } + if (legacy.contains(PrefKeys.RELAY_AUTH_TRUST_MY_RELAYS)) out[RelayAuthStore.trustMyRelays] = legacy.getBoolean(PrefKeys.RELAY_AUTH_TRUST_MY_RELAYS, false) + if (legacy.contains(PrefKeys.RELAY_AUTH_TRUST_READ_FOLLOWS)) out[RelayAuthStore.trustReadFollows] = legacy.getBoolean(PrefKeys.RELAY_AUTH_TRUST_READ_FOLLOWS, false) + if (legacy.contains(PrefKeys.RELAY_AUTH_TRUST_MESSAGE_FOLLOWS)) out[RelayAuthStore.trustMessageFollows] = legacy.getBoolean(PrefKeys.RELAY_AUTH_TRUST_MESSAGE_FOLLOWS, false) + if (legacy.contains(PrefKeys.RELAY_AUTH_TRUST_MESSAGE_STRANGERS)) out[RelayAuthStore.trustMessageStrangers] = legacy.getBoolean(PrefKeys.RELAY_AUTH_TRUST_MESSAGE_STRANGERS, false) + } + } + + private fun feedVisibilityMigration(npub: String) = + CopyOnceMigration("migrated.feedVisibility") { out -> + withContext(Dispatchers.IO) { + val legacy = encryptedPreferences(npub) + legacy.getString(PrefKeys.DISABLED_CHAT_FEEDS, null)?.let { out[FeedVisibilityStore.disabledChatFeeds] = it } + legacy.getString(PrefKeys.DISABLED_HOME_FEED_TYPES, null)?.let { out[FeedVisibilityStore.disabledHomeFeedTypes] = it } + legacy.getString(PrefKeys.RELAY_GROUP_VIEW_MODE, null)?.let { out[FeedVisibilityStore.relayGroupViewMode] = it } + legacy.getString(PrefKeys.CONCORD_VIEW_MODE, null)?.let { out[FeedVisibilityStore.concordViewMode] = it } + if (legacy.contains(PrefKeys.CALLS_ENABLED)) out[FeedVisibilityStore.callsEnabled] = legacy.getBoolean(PrefKeys.CALLS_ENABLED, false) + } + } + + private fun notificationPrefsMigration(npub: String) = + CopyOnceMigration("migrated.notificationPrefs") { out -> + withContext(Dispatchers.IO) { + val legacy = encryptedPreferences(npub) + if (legacy.contains(PrefKeys.ALWAYS_ON_NOTIFICATION_SERVICE)) out[NotificationPrefsStore.alwaysOnService] = legacy.getBoolean(PrefKeys.ALWAYS_ON_NOTIFICATION_SERVICE, false) + if (legacy.contains(PrefKeys.SHOW_MESSAGES_IN_NOTIFICATIONS)) out[NotificationPrefsStore.showMessagesInNotifications] = legacy.getBoolean(PrefKeys.SHOW_MESSAGES_IN_NOTIFICATIONS, false) + if (legacy.contains(PrefKeys.SPLIT_NOTIFICATIONS_ENABLED)) out[NotificationPrefsStore.splitNotificationsEnabled] = legacy.getBoolean(PrefKeys.SPLIT_NOTIFICATIONS_ENABLED, false) + } + } + + /** + * Everything the account's DataStore holds, read in one hop. + * + * Loaded as a group rather than store by store because + * [innerLoadCurrentAccountFromEncryptedStorage] is already near the JVM's + * 64KB method limit: every suspend call inside it adds a state to the + * generated coroutine state machine, and seven separate loads pushed it + * over. One call, one state. + */ + private class AccountStoreData( + val followLists: Map, + val latestEvents: Map, + val uploadSettings: UploadSettings, + val dialogDismissal: DialogDismissal, + val relayAuth: RelayAuth, + val feedVisibility: FeedVisibility, + val notificationPrefs: NotificationPrefs, + ) + + private suspend fun loadAccountStores(npub: String) = + AccountStoreData( + followLists = followListStore(npub).load(), + latestEvents = latestEventStore(npub).load(), + uploadSettings = uploadSettingsStore(npub).load(), + dialogDismissal = dialogDismissalStore(npub).load(), + relayAuth = relayAuthStore(npub).load(), + feedVisibility = feedVisibilityStore(npub).load(), + notificationPrefs = notificationPrefsStore(npub).load(), + ) + + private fun followListMigration(npub: String) = + CopyOnceMigration("migrated.followLists") { out -> + withContext(Dispatchers.IO) { + val legacy = encryptedPreferences(npub) + FollowListSlot.entries.forEach { slot -> + legacy.getString(slot.prefKey, null)?.let { out[slot.key] = it } + } } } private fun latestEventMigration(npub: String) = - CopyOnceMigration("migrated.latestEvents") { + CopyOnceMigration("migrated.latestEvents") { out -> withContext(Dispatchers.IO) { val legacy = encryptedPreferences(npub) - LatestEventSlot.entries - .mapNotNull { slot -> - legacy.getString(slot.prefKey, null)?.let { Pair(slot.key, it) } - }.toMap() + LatestEventSlot.entries.forEach { slot -> + legacy.getString(slot.prefKey, null)?.let { out[slot.key] = it } + } } } @@ -564,17 +675,6 @@ object LocalPreferences { } settings.keyPair.pubKey.let { putString(PrefKeys.NOSTR_PUBKEY, it.toHexKey()) } - putString( - PrefKeys.DEFAULT_FILE_SERVER, - JsonMapper.toJson(settings.defaultFileServer), - ) - - putBoolean(PrefKeys.STRIP_LOCATION_ON_UPLOAD, settings.stripLocationOnUpload) - putBoolean(PrefKeys.USE_LOCAL_BLOSSOM_CACHE, settings.useLocalBlossomCache.value) - putBoolean(PrefKeys.LOCAL_BLOSSOM_CACHE_PROFILE_PICTURES_ONLY, settings.localBlossomCacheProfilePicturesOnly.value) - putBoolean(PrefKeys.MIRROR_UPLOADS_TO_ALL_SERVERS, settings.mirrorUploadsToAllServers.value) - putBoolean(PrefKeys.OPTIMIZE_MEDIA_ON_UPLOAD, settings.optimizeMediaOnUpload.value) - putBoolean(PrefKeys.HIDE_COMMUNITY_RULES_VIOLATIONS, settings.hideCommunityRulesViolations.value) putBoolean(PrefKeys.NIP46_SIGNER_ENABLED, settings.nip46SignerEnabled.value) putString(PrefKeys.NIP46_BUNKER_SECRET, settings.nip46BunkerSecret.value) putString(PrefKeys.NIP46_TRANSPORT_KEY, settings.nip46TransportKey.value) @@ -622,22 +722,6 @@ object LocalPreferences { remove(PrefKeys.LOCAL_RELAY_SERVERS) } - 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) - putBoolean(PrefKeys.CALLS_ENABLED, settings.callsEnabled.value) - putBoolean(PrefKeys.ALWAYS_ON_NOTIFICATION_SERVICE, settings.alwaysOnNotificationService.value) - putString(PrefKeys.DEFAULT_RELAY_AUTH_POLICY, settings.defaultRelayAuthPolicy.value.name) - putString(PrefKeys.RELAY_GROUP_VIEW_MODE, settings.relayGroupViewMode.value.name) - putString(PrefKeys.CONCORD_VIEW_MODE, settings.concordViewMode.value.name) - putString(PrefKeys.DISABLED_CHAT_FEEDS, ChatFeedType.encode(ChatFeedType.ALL - settings.enabledChatFeeds.value)) - putString(PrefKeys.DISABLED_HOME_FEED_TYPES, HomeFeedType.encode(HomeFeedType.ALL - settings.enabledHomeFeedTypes.value)) - putBoolean(PrefKeys.RELAY_AUTH_TRUST_MY_RELAYS, settings.relayAuthTrustMyRelaysAndVenues.value) - putBoolean(PrefKeys.RELAY_AUTH_TRUST_READ_FOLLOWS, settings.relayAuthTrustReadFollows.value) - putBoolean(PrefKeys.RELAY_AUTH_TRUST_MESSAGE_FOLLOWS, settings.relayAuthTrustMessageFollows.value) - putBoolean(PrefKeys.RELAY_AUTH_TRUST_MESSAGE_STRANGERS, settings.relayAuthTrustMessageStrangers.value) - putBoolean(PrefKeys.SPLIT_NOTIFICATIONS_ENABLED, settings.splitNotificationsEnabled.value) - putBoolean(PrefKeys.SHOW_MESSAGES_IN_NOTIFICATIONS, settings.showMessagesInNotifications.value) // Any account that reaches a save has its notification filter in its // post-split meaning, so stamp it as migrated. This keeps the one-shot // Global -> Selected rewrite from ever touching it again and preserves a @@ -657,14 +741,6 @@ object LocalPreferences { PrefKeys.LAST_READ_PER_ROUTE, JsonMapper.toJson(regularMap), ) - putStringSet(PrefKeys.HAS_DONATED_IN_VERSION, settings.hasDonatedInVersion.value) - putStringSet(PrefKeys.DISMISSED_POLL_NOTE_IDS, settings.dismissedPollNoteIds.value) - putStringSet(PrefKeys.DISMISSED_CHANNEL_INVITES, settings.dismissedChannelInvites.value) - putStringSet(PrefKeys.MUTED_PUBLIC_CHATS, settings.mutedPublicChats.value) - putString( - PrefKeys.VIEWED_POLL_RESULT_NOTE_IDS, - JsonMapper.toJson(settings.viewedPollResultNoteIds.value), - ) putString( PrefKeys.PENDING_ATTESTATIONS, @@ -672,6 +748,54 @@ object LocalPreferences { ) } } + uploadSettingsStore(settings.keyPair.pubKey.toNpub()).save( + UploadSettings( + stripLocationOnUpload = settings.stripLocationOnUpload, + optimizeMediaOnUpload = settings.optimizeMediaOnUpload.value, + mirrorUploadsToAllServers = settings.mirrorUploadsToAllServers.value, + useLocalBlossomCache = settings.useLocalBlossomCache.value, + localBlossomCacheProfilePicturesOnly = settings.localBlossomCacheProfilePicturesOnly.value, + defaultFileServerJson = JsonMapper.toJson(settings.defaultFileServer), + ), + ) + dialogDismissalStore(settings.keyPair.pubKey.toNpub()).save( + DialogDismissal( + hideDeleteRequestDialog = settings.hideDeleteRequestDialog, + hideBlockAlertDialog = settings.hideBlockAlertDialog, + hideNip17WarningDialog = settings.hideNIP17WarningDialog, + hideCommunityRulesViolations = settings.hideCommunityRulesViolations.value, + dismissedPollNoteIds = settings.dismissedPollNoteIds.value, + dismissedChannelInvites = settings.dismissedChannelInvites.value, + mutedPublicChats = settings.mutedPublicChats.value, + hasDonatedInVersion = settings.hasDonatedInVersion.value, + viewedPollResultNoteIdsJson = JsonMapper.toJson(settings.viewedPollResultNoteIds.value), + ), + ) + relayAuthStore(settings.keyPair.pubKey.toNpub()).save( + RelayAuth( + policyName = settings.defaultRelayAuthPolicy.value.name, + trustMyRelays = settings.relayAuthTrustMyRelaysAndVenues.value, + trustReadFollows = settings.relayAuthTrustReadFollows.value, + trustMessageFollows = settings.relayAuthTrustMessageFollows.value, + trustMessageStrangers = settings.relayAuthTrustMessageStrangers.value, + ), + ) + feedVisibilityStore(settings.keyPair.pubKey.toNpub()).save( + FeedVisibility( + disabledChatFeeds = ChatFeedType.encode(ChatFeedType.ALL - settings.enabledChatFeeds.value), + disabledHomeFeedTypes = HomeFeedType.encode(HomeFeedType.ALL - settings.enabledHomeFeedTypes.value), + relayGroupViewMode = settings.relayGroupViewMode.value.name, + concordViewMode = settings.concordViewMode.value.name, + callsEnabled = settings.callsEnabled.value, + ), + ) + notificationPrefsStore(settings.keyPair.pubKey.toNpub()).save( + NotificationPrefs( + alwaysOnService = settings.alwaysOnNotificationService.value, + showMessagesInNotifications = settings.showMessagesInNotifications.value, + splitNotificationsEnabled = settings.splitNotificationsEnabled.value, + ), + ) latestEventStore(settings.keyPair.pubKey.toNpub()).saveAll( mapOf( LatestEventSlot.CONTACT_LIST to settings.backupContactList?.let { OptimizedJsonMapper.toJson(it) }, @@ -839,74 +963,75 @@ object LocalPreferences { val keyPair = KeyPair(privKey = privKey?.hexToByteArray(), pubKey = pubKey.hexToByteArray()) + val stores = loadAccountStores(keyPair.pubKey.toNpub()) + Log.d("LocalPreferences") { "Load account from file $npub - keys ready" } - val stripLocationOnUpload = getBoolean(PrefKeys.STRIP_LOCATION_ON_UPLOAD, true) - val useLocalBlossomCache = getBoolean(PrefKeys.USE_LOCAL_BLOSSOM_CACHE, true) - val localBlossomCacheProfilePicturesOnly = getBoolean(PrefKeys.LOCAL_BLOSSOM_CACHE_PROFILE_PICTURES_ONLY, false) - val mirrorUploadsToAllServers = getBoolean(PrefKeys.MIRROR_UPLOADS_TO_ALL_SERVERS, true) - val optimizeMediaOnUpload = getBoolean(PrefKeys.OPTIMIZE_MEDIA_ON_UPLOAD, false) - val hideCommunityRulesViolations = getBoolean(PrefKeys.HIDE_COMMUNITY_RULES_VIOLATIONS, false) + val stripLocationOnUpload = stores.uploadSettings.stripLocationOnUpload + val useLocalBlossomCache = stores.uploadSettings.useLocalBlossomCache + val localBlossomCacheProfilePicturesOnly = stores.uploadSettings.localBlossomCacheProfilePicturesOnly + val mirrorUploadsToAllServers = stores.uploadSettings.mirrorUploadsToAllServers + val optimizeMediaOnUpload = stores.uploadSettings.optimizeMediaOnUpload + val hideCommunityRulesViolations = stores.dialogDismissal.hideCommunityRulesViolations val nip46SignerEnabled = getBoolean(PrefKeys.NIP46_SIGNER_ENABLED, false) val nip46BunkerSecret = getString(PrefKeys.NIP46_BUNKER_SECRET, "") ?: "" val nip46TransportKey = getString(PrefKeys.NIP46_TRANSPORT_KEY, "") ?: "" val nip46SeenRequestIds = getStringSet(PrefKeys.NIP46_SEEN_IDS, null) ?: setOf() - 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 callsEnabled = getBoolean(PrefKeys.CALLS_ENABLED, true) - val alwaysOnNotificationService = getBoolean(PrefKeys.ALWAYS_ON_NOTIFICATION_SERVICE, false) + val hideDeleteRequestDialog = stores.dialogDismissal.hideDeleteRequestDialog + val hideBlockAlertDialog = stores.dialogDismissal.hideBlockAlertDialog + val hideNIP17WarningDialog = stores.dialogDismissal.hideNip17WarningDialog + val callsEnabled = stores.feedVisibility.callsEnabled + val alwaysOnNotificationService = stores.notificationPrefs.alwaysOnService // Read as a group via a helper: this load lambda sits right at the JVM's // per-method bytecode limit (see the note above the awaits below), so keeping // these heavy string/enum decodes out of it preserves headroom. - val inboxPrefs = readInboxPrefs() - val splitNotificationsEnabled = getBoolean(PrefKeys.SPLIT_NOTIFICATIONS_ENABLED, false) - val showMessagesInNotifications = getBoolean(PrefKeys.SHOW_MESSAGES_IN_NOTIFICATIONS, true) - val hasDonatedInVersion = getStringSet(PrefKeys.HAS_DONATED_IN_VERSION, null) ?: setOf() - val dismissedPollNoteIds = getStringSet(PrefKeys.DISMISSED_POLL_NOTE_IDS, null) ?: setOf() - val dismissedChannelInvites = getStringSet(PrefKeys.DISMISSED_CHANNEL_INVITES, null) ?: setOf() - val mutedPublicChats = getStringSet(PrefKeys.MUTED_PUBLIC_CHATS, null) ?: setOf() - val viewedPollResultNoteIdsStr = getString(PrefKeys.VIEWED_POLL_RESULT_NOTE_IDS, null) + val inboxPrefs = readInboxPrefs(stores.relayAuth, stores.feedVisibility) + val splitNotificationsEnabled = stores.notificationPrefs.splitNotificationsEnabled + val showMessagesInNotifications = stores.notificationPrefs.showMessagesInNotifications + val hasDonatedInVersion = stores.dialogDismissal.hasDonatedInVersion + val dismissedPollNoteIds = stores.dialogDismissal.dismissedPollNoteIds + val dismissedChannelInvites = stores.dialogDismissal.dismissedChannelInvites + val mutedPublicChats = stores.dialogDismissal.mutedPublicChats + val viewedPollResultNoteIdsStr = stores.dialogDismissal.viewedPollResultNoteIdsJson val localRelayServers = getStringSet(PrefKeys.LOCAL_RELAY_SERVERS, null) ?: setOf() - val followListPrefs = toFollowListPrefs(followListStore(keyPair.pubKey.toNpub()).load()) + val followListPrefs = toFollowListPrefs(stores.followLists) val zapPaymentRequestServerStr = getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null) val nwcWalletsStr = getString(PrefKeys.NWC_WALLETS, null) val defaultNwcWalletIdStr = getString(PrefKeys.DEFAULT_NWC_WALLET_ID, null) val clinkDebitWalletsStr = getString(PrefKeys.CLINK_DEBIT_WALLETS, null) val defaultPaymentSourceIdStr = getString(PrefKeys.DEFAULT_PAYMENT_SOURCE_ID, null) - val defaultFileServerStr = getString(PrefKeys.DEFAULT_FILE_SERVER, null) + val defaultFileServerStr = stores.uploadSettings.defaultFileServerJson val pendingAttestationsStr = getString(PrefKeys.PENDING_ATTESTATIONS, null) val openBackupConflictsStr = getString(PrefKeys.OPEN_BACKUP_CONFLICTS, null) - val latestEvents = latestEventStore(keyPair.pubKey.toNpub()).load() - val latestUserMetadataStr = latestEvents[LatestEventSlot.USER_METADATA] - val latestContactListStr = latestEvents[LatestEventSlot.CONTACT_LIST] - val latestDmRelayListStr = latestEvents[LatestEventSlot.DM_RELAY_LIST] - val latestNip65RelayListStr = latestEvents[LatestEventSlot.NIP65_RELAY_LIST] - val latestSearchRelayListStr = latestEvents[LatestEventSlot.SEARCH_RELAY_LIST] - val latestIndexRelayListStr = latestEvents[LatestEventSlot.INDEX_RELAY_LIST] - val latestRelayFeedsListStr = latestEvents[LatestEventSlot.RELAY_FEEDS_LIST] - val latestBlockedRelayListStr = latestEvents[LatestEventSlot.BLOCKED_RELAY_LIST] - val latestTrustedRelayListStr = latestEvents[LatestEventSlot.TRUSTED_RELAY_LIST] - val latestMuteListStr = latestEvents[LatestEventSlot.MUTE_LIST] - val latestPrivateHomeRelayListStr = latestEvents[LatestEventSlot.PRIVATE_HOME_RELAY_LIST] - val latestAppSpecificDataStr = latestEvents[LatestEventSlot.APP_SPECIFIC_DATA] - val latestChannelListStr = latestEvents[LatestEventSlot.CHANNEL_LIST] - val latestCommunityListStr = latestEvents[LatestEventSlot.COMMUNITY_LIST] - val latestHashtagListStr = latestEvents[LatestEventSlot.HASHTAG_LIST] - val latestGeohashListStr = latestEvents[LatestEventSlot.GEOHASH_LIST] - val latestEphemeralListStr = latestEvents[LatestEventSlot.EPHEMERAL_LIST] - val latestRelayGroupListStr = latestEvents[LatestEventSlot.RELAY_GROUP_LIST] - val latestConcordListStr = latestEvents[LatestEventSlot.CONCORD_LIST] - val latestTrustProviderListStr = latestEvents[LatestEventSlot.TRUST_PROVIDER_LIST] - val latestKeyPackageRelayListStr = latestEvents[LatestEventSlot.KEY_PACKAGE_RELAY_LIST] - val latestFavoriteAlgoFeedsListStr = latestEvents[LatestEventSlot.FAVORITE_ALGO_FEEDS_LIST] - val latestPaymentTargetsStr = latestEvents[LatestEventSlot.PAYMENT_TARGETS] - val latestBolt12OffersStr = latestEvents[LatestEventSlot.BOLT12_OFFERS] - val latestCashuWalletStr = latestEvents[LatestEventSlot.CASHU_WALLET] - val latestNutzapInfoStr = latestEvents[LatestEventSlot.NUTZAP_INFO] + val latestUserMetadataStr = stores.latestEvents[LatestEventSlot.USER_METADATA] + val latestContactListStr = stores.latestEvents[LatestEventSlot.CONTACT_LIST] + val latestDmRelayListStr = stores.latestEvents[LatestEventSlot.DM_RELAY_LIST] + val latestNip65RelayListStr = stores.latestEvents[LatestEventSlot.NIP65_RELAY_LIST] + val latestSearchRelayListStr = stores.latestEvents[LatestEventSlot.SEARCH_RELAY_LIST] + val latestIndexRelayListStr = stores.latestEvents[LatestEventSlot.INDEX_RELAY_LIST] + val latestRelayFeedsListStr = stores.latestEvents[LatestEventSlot.RELAY_FEEDS_LIST] + val latestBlockedRelayListStr = stores.latestEvents[LatestEventSlot.BLOCKED_RELAY_LIST] + val latestTrustedRelayListStr = stores.latestEvents[LatestEventSlot.TRUSTED_RELAY_LIST] + val latestMuteListStr = stores.latestEvents[LatestEventSlot.MUTE_LIST] + val latestPrivateHomeRelayListStr = stores.latestEvents[LatestEventSlot.PRIVATE_HOME_RELAY_LIST] + val latestAppSpecificDataStr = stores.latestEvents[LatestEventSlot.APP_SPECIFIC_DATA] + val latestChannelListStr = stores.latestEvents[LatestEventSlot.CHANNEL_LIST] + val latestCommunityListStr = stores.latestEvents[LatestEventSlot.COMMUNITY_LIST] + val latestHashtagListStr = stores.latestEvents[LatestEventSlot.HASHTAG_LIST] + val latestGeohashListStr = stores.latestEvents[LatestEventSlot.GEOHASH_LIST] + val latestEphemeralListStr = stores.latestEvents[LatestEventSlot.EPHEMERAL_LIST] + val latestRelayGroupListStr = stores.latestEvents[LatestEventSlot.RELAY_GROUP_LIST] + val latestConcordListStr = stores.latestEvents[LatestEventSlot.CONCORD_LIST] + val latestTrustProviderListStr = stores.latestEvents[LatestEventSlot.TRUST_PROVIDER_LIST] + val latestKeyPackageRelayListStr = stores.latestEvents[LatestEventSlot.KEY_PACKAGE_RELAY_LIST] + val latestFavoriteAlgoFeedsListStr = stores.latestEvents[LatestEventSlot.FAVORITE_ALGO_FEEDS_LIST] + val latestPaymentTargetsStr = stores.latestEvents[LatestEventSlot.PAYMENT_TARGETS] + val latestBolt12OffersStr = stores.latestEvents[LatestEventSlot.BOLT12_OFFERS] + val latestCashuWalletStr = stores.latestEvents[LatestEventSlot.CASHU_WALLET] + val latestNutzapInfoStr = stores.latestEvents[LatestEventSlot.NUTZAP_INFO] val lastReadPerRouteStr = getString(PrefKeys.LAST_READ_PER_ROUTE, null) Log.d("LocalPreferences") { "Load account from file $npub - before parsing events" } @@ -1342,20 +1467,22 @@ private class InboxPrefs( val relayAuthTrustMessageStrangers: Boolean, ) -private fun SharedPreferences.readInboxPrefs() = - InboxPrefs( - // Missing key = an account saved before this setting existed. Those keep CUSTOM; only - // brand-new logins get the ALWAYS default from AccountSettings' constructor. - defaultRelayAuthPolicy = - getString(PrefKeys.DEFAULT_RELAY_AUTH_POLICY, null) - ?.let { runCatching { RelayAuthPolicy.valueOf(it) }.getOrNull() } - ?: RelayAuthPolicy.CUSTOM, - relayGroupViewMode = RelayGroupViewMode.fromName(getString(PrefKeys.RELAY_GROUP_VIEW_MODE, null)), - concordViewMode = ConcordViewMode.fromName(getString(PrefKeys.CONCORD_VIEW_MODE, null)), - enabledChatFeeds = ChatFeedType.ALL - ChatFeedType.decode(getString(PrefKeys.DISABLED_CHAT_FEEDS, null)), - enabledHomeFeedTypes = HomeFeedType.ALL - HomeFeedType.decode(getString(PrefKeys.DISABLED_HOME_FEED_TYPES, null)), - relayAuthTrustMyRelays = getBoolean(PrefKeys.RELAY_AUTH_TRUST_MY_RELAYS, true), - relayAuthTrustReadFollows = getBoolean(PrefKeys.RELAY_AUTH_TRUST_READ_FOLLOWS, true), - relayAuthTrustMessageFollows = getBoolean(PrefKeys.RELAY_AUTH_TRUST_MESSAGE_FOLLOWS, true), - relayAuthTrustMessageStrangers = getBoolean(PrefKeys.RELAY_AUTH_TRUST_MESSAGE_STRANGERS, false), - ) +private fun readInboxPrefs( + relayAuth: RelayAuth, + feedVisibility: FeedVisibility, +) = InboxPrefs( + // Missing key = an account saved before this setting existed. Those keep CUSTOM; only + // brand-new logins get the ALWAYS default from AccountSettings' constructor. + defaultRelayAuthPolicy = + relayAuth.policyName + ?.let { runCatching { RelayAuthPolicy.valueOf(it) }.getOrNull() } + ?: RelayAuthPolicy.CUSTOM, + relayGroupViewMode = RelayGroupViewMode.fromName(feedVisibility.relayGroupViewMode), + concordViewMode = ConcordViewMode.fromName(feedVisibility.concordViewMode), + enabledChatFeeds = ChatFeedType.ALL - ChatFeedType.decode(feedVisibility.disabledChatFeeds), + enabledHomeFeedTypes = HomeFeedType.ALL - HomeFeedType.decode(feedVisibility.disabledHomeFeedTypes), + relayAuthTrustMyRelays = relayAuth.trustMyRelays, + relayAuthTrustReadFollows = relayAuth.trustReadFollows, + relayAuthTrustMessageFollows = relayAuth.trustMessageFollows, + relayAuthTrustMessageStrangers = relayAuth.trustMessageStrangers, +) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/CopyOnceMigration.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/CopyOnceMigration.kt index 3ab3ba82b7..d1a3be3e89 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/CopyOnceMigration.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/CopyOnceMigration.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.commons.model.preferences import androidx.datastore.core.DataMigration +import androidx.datastore.preferences.core.MutablePreferences import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey @@ -36,18 +37,18 @@ import androidx.datastore.preferences.core.booleanPreferencesKey * left untouched and a rollback still works. Deleting the legacy data is a * separate decision, taken once the migration has shipped and held. * - * [read] is only called when the migration actually runs, so the cost of - * opening the legacy store is not paid on every launch. + * [copy] receives the destination and writes whatever it has, so a migration + * can carry strings, booleans, int and string sets alike. It is only called + * when the migration actually runs, so opening the legacy store is not a cost + * paid on every launch. A key it does not write is left absent rather than + * written blank, so it keeps reading as "unset" and falls back to its default. * * @param markerName key recording, in this store, that the copy has run. * Distinct per migration, so several can run against the same store. - * @param read the legacy values, already mapped onto this store's keys. A key - * absent here is left absent rather than written blank, so it keeps reading - * as "unset" and falls back to its default. */ class CopyOnceMigration( markerName: String, - private val read: suspend () -> Map, String>, + private val copy: suspend (MutablePreferences) -> Unit, ) : DataMigration { private val marker = booleanPreferencesKey(markerName) @@ -56,7 +57,7 @@ class CopyOnceMigration( override suspend fun migrate(currentData: Preferences): Preferences { val updated = currentData.toMutablePreferences() - read().forEach { (key, value) -> updated[key] = value } + copy(updated) updated[marker] = true return updated.toPreferences() diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/DialogDismissalStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/DialogDismissalStore.kt new file mode 100644 index 0000000000..870a2bc2c1 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/DialogDismissalStore.kt @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.preferences + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.core.stringSetPreferencesKey +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.first +import okio.IOException + +/** + * What this account has dismissed and does not want shown again — + * confirmation dialogs, individual polls and invites, the donation card. + * + * Everything here defaults to "not dismissed", so a lost value costs the user + * one more prompt rather than hiding something they never dismissed. + * + * Defaults match what the SharedPreferences implementation returned for a + * missing key, so an account that never touched a setting behaves identically + * before and after the migration. + */ +data class DialogDismissal( + val hideDeleteRequestDialog: Boolean = false, + val hideBlockAlertDialog: Boolean = false, + val hideNip17WarningDialog: Boolean = false, + val hideCommunityRulesViolations: Boolean = false, + val dismissedPollNoteIds: Set = emptySet(), + val dismissedChannelInvites: Set = emptySet(), + val mutedPublicChats: Set = emptySet(), + val hasDonatedInVersion: Set = emptySet(), + val viewedPollResultNoteIdsJson: String? = null, +) + +/** Reads and writes [DialogDismissal] in the account's DataStore. */ +class DialogDismissalStore( + private val store: DataStore, +) { + companion object { + val hideDeleteRequestDialog = booleanPreferencesKey("hide_delete_request_dialog") + val hideBlockAlertDialog = booleanPreferencesKey("hide_block_alert_dialog") + val hideNip17WarningDialog = booleanPreferencesKey("hide_nip24_warning_dialog") + val hideCommunityRulesViolations = booleanPreferencesKey("hideCommunityRulesViolations") + val dismissedPollNoteIds = stringSetPreferencesKey("dismissedPollNoteIds") + val dismissedChannelInvites = stringSetPreferencesKey("dismissedChannelInvites") + val mutedPublicChats = stringSetPreferencesKey("mutedPublicChats") + val hasDonatedInVersion = stringSetPreferencesKey("hasDonatedInVersion") + val viewedPollResultNoteIdsJson = stringPreferencesKey("viewedPollResultNoteIds") + } + + suspend fun load(): DialogDismissal { + val prefs = + store.data + .catch { e -> if (e is IOException) emit(emptyPreferences()) else throw e } + .first() + + return DialogDismissal( + hideDeleteRequestDialog = prefs[hideDeleteRequestDialog] ?: false, + hideBlockAlertDialog = prefs[hideBlockAlertDialog] ?: false, + hideNip17WarningDialog = prefs[hideNip17WarningDialog] ?: false, + hideCommunityRulesViolations = prefs[hideCommunityRulesViolations] ?: false, + dismissedPollNoteIds = prefs[dismissedPollNoteIds] ?: emptySet(), + dismissedChannelInvites = prefs[dismissedChannelInvites] ?: emptySet(), + mutedPublicChats = prefs[mutedPublicChats] ?: emptySet(), + hasDonatedInVersion = prefs[hasDonatedInVersion] ?: emptySet(), + viewedPollResultNoteIdsJson = prefs[viewedPollResultNoteIdsJson], + ) + } + + /** Writes the whole group in one edit, so a crash cannot half-apply it. */ + suspend fun save(value: DialogDismissal) { + store.edit { prefs -> + prefs[hideDeleteRequestDialog] = value.hideDeleteRequestDialog + prefs[hideBlockAlertDialog] = value.hideBlockAlertDialog + prefs[hideNip17WarningDialog] = value.hideNip17WarningDialog + prefs[hideCommunityRulesViolations] = value.hideCommunityRulesViolations + prefs[dismissedPollNoteIds] = value.dismissedPollNoteIds + prefs[dismissedChannelInvites] = value.dismissedChannelInvites + prefs[mutedPublicChats] = value.mutedPublicChats + prefs[hasDonatedInVersion] = value.hasDonatedInVersion + value.viewedPollResultNoteIdsJson.let { if (it != null) prefs[viewedPollResultNoteIdsJson] = it else prefs.remove(viewedPollResultNoteIdsJson) } + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/FeedVisibilityStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/FeedVisibilityStore.kt new file mode 100644 index 0000000000..f99321a914 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/FeedVisibilityStore.kt @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.preferences + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.stringPreferencesKey +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.first +import okio.IOException + +/** + * Which feeds this account shows and how they are laid out. + + * The two "disabled" keys store what is switched OFF, not what is on, so an + * absent value means everything is enabled and a feed type added later defaults + * to on for accounts that customised before it existed. + * + * Defaults match what the SharedPreferences implementation returned for a + * missing key, so an account that never touched a setting behaves identically + * before and after the migration. + */ +data class FeedVisibility( + val disabledChatFeeds: String? = null, + val disabledHomeFeedTypes: String? = null, + val relayGroupViewMode: String? = null, + val concordViewMode: String? = null, + val callsEnabled: Boolean = true, +) + +/** Reads and writes [FeedVisibility] in the account's DataStore. */ +class FeedVisibilityStore( + private val store: DataStore, +) { + companion object { + val disabledChatFeeds = stringPreferencesKey("disabled_chat_feeds") + val disabledHomeFeedTypes = stringPreferencesKey("disabled_home_feed_types") + val relayGroupViewMode = stringPreferencesKey("relay_group_view_mode") + val concordViewMode = stringPreferencesKey("concord_view_mode") + val callsEnabled = booleanPreferencesKey("calls_enabled") + } + + suspend fun load(): FeedVisibility { + val prefs = + store.data + .catch { e -> if (e is IOException) emit(emptyPreferences()) else throw e } + .first() + + return FeedVisibility( + disabledChatFeeds = prefs[disabledChatFeeds], + disabledHomeFeedTypes = prefs[disabledHomeFeedTypes], + relayGroupViewMode = prefs[relayGroupViewMode], + concordViewMode = prefs[concordViewMode], + callsEnabled = prefs[callsEnabled] ?: true, + ) + } + + /** Writes the whole group in one edit, so a crash cannot half-apply it. */ + suspend fun save(value: FeedVisibility) { + store.edit { prefs -> + value.disabledChatFeeds.let { if (it != null) prefs[disabledChatFeeds] = it else prefs.remove(disabledChatFeeds) } + value.disabledHomeFeedTypes.let { if (it != null) prefs[disabledHomeFeedTypes] = it else prefs.remove(disabledHomeFeedTypes) } + value.relayGroupViewMode.let { if (it != null) prefs[relayGroupViewMode] = it else prefs.remove(relayGroupViewMode) } + value.concordViewMode.let { if (it != null) prefs[concordViewMode] = it else prefs.remove(concordViewMode) } + prefs[callsEnabled] = value.callsEnabled + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/NotificationPrefsStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/NotificationPrefsStore.kt new file mode 100644 index 0000000000..e6de5d5c83 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/NotificationPrefsStore.kt @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.preferences + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.stringPreferencesKey +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.first +import okio.IOException + +/** + * This account's share of the notification settings. + + * The global on/off switch is not here — it lives in plain, non-encrypted + * storage because the restart layer must read it synchronously from a fresh + * process (boot receiver, WorkManager) before any account is loaded. + * + * Defaults match what the SharedPreferences implementation returned for a + * missing key, so an account that never touched a setting behaves identically + * before and after the migration. + */ +data class NotificationPrefs( + val alwaysOnService: Boolean = false, + val showMessagesInNotifications: Boolean = true, + val splitNotificationsEnabled: Boolean = false, +) + +/** Reads and writes [NotificationPrefs] in the account's DataStore. */ +class NotificationPrefsStore( + private val store: DataStore, +) { + companion object { + val alwaysOnService = booleanPreferencesKey("always_on_notification_service") + val showMessagesInNotifications = booleanPreferencesKey("show_messages_in_notifications") + val splitNotificationsEnabled = booleanPreferencesKey("split_notifications_enabled") + val globalToCuratedMigrated = booleanPreferencesKey("notif_global_to_curated_migrated") + val lastReadPerRouteJson = stringPreferencesKey("last_read_per_route") + } + + private suspend fun read(): Preferences = + store.data + .catch { e -> if (e is IOException) emit(emptyPreferences()) else throw e } + .first() + + suspend fun load(): NotificationPrefs { + val prefs = read() + + return NotificationPrefs( + alwaysOnService = prefs[alwaysOnService] ?: false, + showMessagesInNotifications = prefs[showMessagesInNotifications] ?: true, + splitNotificationsEnabled = prefs[splitNotificationsEnabled] ?: false, + ) + } + + /** + * Whether the one-shot global-to-curated notification filter migration has + * already run for this account. + * + * Kept out of [NotificationPrefs] and its bulk save because it is not a + * user setting: it is written once, by that migration, and a bulk save that + * carried a stale copy could re-run the migration or wrongly suppress it. + */ + suspend fun hasRunGlobalToCuratedMigration(): Boolean = read()[globalToCuratedMigrated] ?: false + + suspend fun markGlobalToCuratedMigrated() { + store.edit { prefs -> prefs[globalToCuratedMigrated] = true } + } + + /** + * The per-route read markers, as JSON. + * + * Written on its own path as the user reads things, at a different cadence + * from the settings above, so it is not part of [save]. + */ + suspend fun lastReadPerRoute(): String? = read()[lastReadPerRouteJson] + + suspend fun saveLastReadPerRoute(json: String) { + store.edit { prefs -> prefs[lastReadPerRouteJson] = json } + } + + /** Writes the whole group in one edit, so a crash cannot half-apply it. */ + suspend fun save(value: NotificationPrefs) { + store.edit { prefs -> + prefs[alwaysOnService] = value.alwaysOnService + prefs[showMessagesInNotifications] = value.showMessagesInNotifications + prefs[splitNotificationsEnabled] = value.splitNotificationsEnabled + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/RelayAuthStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/RelayAuthStore.kt new file mode 100644 index 0000000000..1f18ed793b --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/RelayAuthStore.kt @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.preferences + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.stringPreferencesKey +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.first +import okio.IOException + +/** + * When this account answers a relay's NIP-42 AUTH challenge. + + * The policy key is absent for accounts saved before the setting existed; the + * caller maps that absence to CUSTOM rather than to the constructor default, so + * an existing user's behaviour does not change under them. + * + * Defaults match what the SharedPreferences implementation returned for a + * missing key, so an account that never touched a setting behaves identically + * before and after the migration. + */ +data class RelayAuth( + val policyName: String? = null, + val trustMyRelays: Boolean = true, + val trustReadFollows: Boolean = true, + val trustMessageFollows: Boolean = true, + val trustMessageStrangers: Boolean = false, +) + +/** Reads and writes [RelayAuth] in the account's DataStore. */ +class RelayAuthStore( + private val store: DataStore, +) { + companion object { + val policyName = stringPreferencesKey("default_relay_auth_policy") + val trustMyRelays = booleanPreferencesKey("relay_auth_trust_my_relays") + val trustReadFollows = booleanPreferencesKey("relay_auth_trust_read_follows") + val trustMessageFollows = booleanPreferencesKey("relay_auth_trust_message_follows") + val trustMessageStrangers = booleanPreferencesKey("relay_auth_trust_message_strangers") + } + + suspend fun load(): RelayAuth { + val prefs = + store.data + .catch { e -> if (e is IOException) emit(emptyPreferences()) else throw e } + .first() + + return RelayAuth( + policyName = prefs[policyName], + trustMyRelays = prefs[trustMyRelays] ?: true, + trustReadFollows = prefs[trustReadFollows] ?: true, + trustMessageFollows = prefs[trustMessageFollows] ?: true, + trustMessageStrangers = prefs[trustMessageStrangers] ?: false, + ) + } + + /** Writes the whole group in one edit, so a crash cannot half-apply it. */ + suspend fun save(value: RelayAuth) { + store.edit { prefs -> + value.policyName.let { if (it != null) prefs[policyName] = it else prefs.remove(policyName) } + prefs[trustMyRelays] = value.trustMyRelays + prefs[trustReadFollows] = value.trustReadFollows + prefs[trustMessageFollows] = value.trustMessageFollows + prefs[trustMessageStrangers] = value.trustMessageStrangers + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/UploadSettingsStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/UploadSettingsStore.kt new file mode 100644 index 0000000000..c63769dae7 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/UploadSettingsStore.kt @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.preferences + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.stringPreferencesKey +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.first +import okio.IOException + +/** + * How this account uploads media: which server, what is stripped, what is + * mirrored and what is cached locally. + * + * Defaults match what the SharedPreferences implementation returned for a + * missing key, so an account that never touched a setting behaves identically + * before and after the migration. + */ +data class UploadSettings( + val stripLocationOnUpload: Boolean = true, + val optimizeMediaOnUpload: Boolean = false, + val mirrorUploadsToAllServers: Boolean = true, + val useLocalBlossomCache: Boolean = true, + val localBlossomCacheProfilePicturesOnly: Boolean = false, + val defaultFileServerJson: String? = null, +) + +/** Reads and writes [UploadSettings] in the account's DataStore. */ +class UploadSettingsStore( + private val store: DataStore, +) { + companion object { + val stripLocationOnUpload = booleanPreferencesKey("stripLocationOnUpload") + val optimizeMediaOnUpload = booleanPreferencesKey("optimizeMediaOnUpload") + val mirrorUploadsToAllServers = booleanPreferencesKey("mirrorUploadsToAllServers") + val useLocalBlossomCache = booleanPreferencesKey("useLocalBlossomCache") + val localBlossomCacheProfilePicturesOnly = booleanPreferencesKey("localBlossomCacheProfilePicturesOnly") + val defaultFileServerJson = stringPreferencesKey("defaultFileServer") + } + + suspend fun load(): UploadSettings { + val prefs = + store.data + .catch { e -> if (e is IOException) emit(emptyPreferences()) else throw e } + .first() + + return UploadSettings( + stripLocationOnUpload = prefs[stripLocationOnUpload] ?: true, + optimizeMediaOnUpload = prefs[optimizeMediaOnUpload] ?: false, + mirrorUploadsToAllServers = prefs[mirrorUploadsToAllServers] ?: true, + useLocalBlossomCache = prefs[useLocalBlossomCache] ?: true, + localBlossomCacheProfilePicturesOnly = prefs[localBlossomCacheProfilePicturesOnly] ?: false, + defaultFileServerJson = prefs[defaultFileServerJson], + ) + } + + /** Writes the whole group in one edit, so a crash cannot half-apply it. */ + suspend fun save(value: UploadSettings) { + store.edit { prefs -> + prefs[stripLocationOnUpload] = value.stripLocationOnUpload + prefs[optimizeMediaOnUpload] = value.optimizeMediaOnUpload + prefs[mirrorUploadsToAllServers] = value.mirrorUploadsToAllServers + prefs[useLocalBlossomCache] = value.useLocalBlossomCache + prefs[localBlossomCacheProfilePicturesOnly] = value.localBlossomCacheProfilePicturesOnly + value.defaultFileServerJson.let { if (it != null) prefs[defaultFileServerJson] = it else prefs.remove(defaultFileServerJson) } + } + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountSettingStoresTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountSettingStoresTest.kt new file mode 100644 index 0000000000..e15c451da8 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountSettingStoresTest.kt @@ -0,0 +1,221 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.preferences + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.test.runTest +import okio.Path.Companion.toOkioPath +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +/** + * The five per-account setting groups. + * + * The defaults are the point: they must match what the SharedPreferences + * implementation returned for a missing key, because an account that never + * touched a setting has no stored value and gets the default forever after. + * Several are `true`, so a group that defaulted everything to `false` would + * silently turn features off for every existing user. + */ +class AccountSettingStoresTest { + @get:Rule + val folder = TemporaryFolder() + + private var seq = 0 + + private fun raw(): DataStore { + val file = File(folder.root, "group_${seq++}.preferences_pb") + return PreferenceDataStoreFactory.createWithPath( + scope = CoroutineScope(Dispatchers.IO + SupervisorJob()), + produceFile = { file.toOkioPath() }, + ) + } + + @Test + fun uploadSettingsDefaultsMatchTheLegacyOnes() = + runTest { + val loaded = UploadSettingsStore(raw()).load() + + assertEquals(true, loaded.stripLocationOnUpload) + assertEquals(false, loaded.optimizeMediaOnUpload) + assertEquals(true, loaded.mirrorUploadsToAllServers) + assertEquals(true, loaded.useLocalBlossomCache) + assertEquals(false, loaded.localBlossomCacheProfilePicturesOnly) + assertNull(loaded.defaultFileServerJson) + } + + @Test + fun uploadSettingsRoundTrip() = + runTest { + val store = UploadSettingsStore(raw()) + val value = + UploadSettings( + stripLocationOnUpload = false, + optimizeMediaOnUpload = true, + mirrorUploadsToAllServers = false, + useLocalBlossomCache = false, + localBlossomCacheProfilePicturesOnly = true, + defaultFileServerJson = """{"name":"x"}""", + ) + + store.save(value) + + assertEquals(value, store.load()) + } + + @Test + fun dialogDismissalDefaultsToNothingDismissed() = + runTest { + val loaded = DialogDismissalStore(raw()).load() + + assertEquals(false, loaded.hideDeleteRequestDialog) + assertEquals(false, loaded.hideBlockAlertDialog) + assertEquals(false, loaded.hideNip17WarningDialog) + assertEquals(false, loaded.hideCommunityRulesViolations) + assertTrue(loaded.dismissedPollNoteIds.isEmpty()) + assertTrue(loaded.dismissedChannelInvites.isEmpty()) + assertTrue(loaded.mutedPublicChats.isEmpty()) + assertTrue(loaded.hasDonatedInVersion.isEmpty()) + } + + @Test + fun dialogDismissalRoundTripsSets() = + runTest { + val store = DialogDismissalStore(raw()) + val value = + DialogDismissal( + hideDeleteRequestDialog = true, + dismissedPollNoteIds = setOf("a", "b"), + mutedPublicChats = setOf("chat1"), + hasDonatedInVersion = setOf("1.2.3"), + ) + + store.save(value) + + assertEquals(value, store.load()) + } + + /** Three of these default to true — trusting by default — so a wrong default weakens AUTH behaviour. */ + @Test + fun relayAuthDefaults() = + runTest { + val loaded = RelayAuthStore(raw()).load() + + assertNull("absent means CUSTOM, decided by the caller", loaded.policyName) + assertEquals(true, loaded.trustMyRelays) + assertEquals(true, loaded.trustReadFollows) + assertEquals(true, loaded.trustMessageFollows) + assertEquals(false, loaded.trustMessageStrangers) + } + + @Test + fun relayAuthRoundTrip() = + runTest { + val store = RelayAuthStore(raw()) + val value = RelayAuth("ALWAYS", trustMyRelays = false, trustMessageStrangers = true) + + store.save(value) + + assertEquals(value, store.load()) + } + + /** The disabled-feed keys store what is OFF, so absent must mean everything on. */ + @Test + fun feedVisibilityDefaultsToEverythingEnabled() = + runTest { + val loaded = FeedVisibilityStore(raw()).load() + + assertNull(loaded.disabledChatFeeds) + assertNull(loaded.disabledHomeFeedTypes) + assertEquals(true, loaded.callsEnabled) + } + + @Test + fun notificationPrefsDefaults() = + runTest { + val loaded = NotificationPrefsStore(raw()).load() + + assertEquals(false, loaded.alwaysOnService) + assertEquals(true, loaded.showMessagesInNotifications) + assertEquals(false, loaded.splitNotificationsEnabled) + } + + /** + * The one-shot migration marker is not a user setting, so it must not ride + * along in the bulk save where a stale copy could re-run or suppress it. + */ + @Test + fun theGlobalToCuratedMarkerIsIndependentOfTheBulkSave() = + runTest { + val store = NotificationPrefsStore(raw()) + + assertEquals(false, store.hasRunGlobalToCuratedMigration()) + store.markGlobalToCuratedMigrated() + store.save(NotificationPrefs(alwaysOnService = true)) + + assertEquals("a later bulk save must not clear it", true, store.hasRunGlobalToCuratedMigration()) + assertEquals(true, store.load().alwaysOnService) + } + + @Test + fun lastReadPerRouteIsIndependentOfTheBulkSave() = + runTest { + val store = NotificationPrefsStore(raw()) + + store.saveLastReadPerRoute("""{"home":1}""") + store.save(NotificationPrefs(splitNotificationsEnabled = true)) + + assertEquals("""{"home":1}""", store.lastReadPerRoute()) + } + + /** A null string field must clear its key rather than leave the old value behind. */ + @Test + fun aNullStringFieldClearsTheKey() = + runTest { + val store = FeedVisibilityStore(raw()) + + store.save(FeedVisibility(disabledChatFeeds = "a,b")) + store.save(FeedVisibility(disabledChatFeeds = null)) + + assertNull(store.load().disabledChatFeeds) + } + + /** Key strings are a compatibility surface — renaming one resets that setting for everyone. */ + @Test + fun keyNamesMatchTheLegacyOnes() { + assertEquals("stripLocationOnUpload", UploadSettingsStore.stripLocationOnUpload.name) + assertEquals("hide_delete_request_dialog", DialogDismissalStore.hideDeleteRequestDialog.name) + assertEquals("hide_nip24_warning_dialog", DialogDismissalStore.hideNip17WarningDialog.name) + assertEquals("default_relay_auth_policy", RelayAuthStore.policyName.name) + assertEquals("disabled_chat_feeds", FeedVisibilityStore.disabledChatFeeds.name) + assertEquals("always_on_notification_service", NotificationPrefsStore.alwaysOnService.name) + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/LatestEventCacheStoreTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/LatestEventCacheStoreTest.kt index 66bc5122ab..32d0003c47 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/LatestEventCacheStoreTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/LatestEventCacheStoreTest.kt @@ -123,7 +123,7 @@ class LatestEventCacheStoreTest { runTest { val legacy = mapOf(Pair(LatestEventSlot.USER_METADATA.key, """{"kind":0}""")) - val loaded = store(listOf(CopyOnceMigration("migrated.latestEvents") { legacy })).load() + val loaded = store(listOf(CopyOnceMigration("migrated.latestEvents") { out -> legacy.forEach { (k, v) -> out[k] = v } })).load() assertEquals("""{"kind":0}""", loaded[LatestEventSlot.USER_METADATA]) assertFalse("slots absent from the legacy store stay absent", loaded.containsKey(LatestEventSlot.MUTE_LIST)) diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/TopNavFollowListStoreTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/TopNavFollowListStoreTest.kt index 59f8a81d38..1a08dc1eb7 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/TopNavFollowListStoreTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/TopNavFollowListStoreTest.kt @@ -165,7 +165,7 @@ class TopNavFollowListStoreTest { Pair(FollowListSlot.BADGES.key, encode(TopFilter.AllFollows)), ) - val loaded = store(scope(), listOf(CopyOnceMigration("migrated.followLists") { legacy })).load() + val loaded = store(scope(), listOf(CopyOnceMigration("migrated.followLists") { out -> legacy.forEach { (k, v) -> out[k] = v } })).load() assertEquals(TopFilter.Global, loaded.getValue(FollowListSlot.HOME)) assertEquals(TopFilter.AllFollows, loaded.getValue(FollowListSlot.BADGES)) @@ -196,9 +196,9 @@ class TopNavFollowListStoreTest { scope = scope, migrations = listOf( - CopyOnceMigration("migrated.followLists") { + CopyOnceMigration("migrated.followLists") { out -> reads++ - legacy + legacy.forEach { (k, v) -> out[k] = v } }, ), produceFile = { file.toOkioPath() }, From 9a6be77841ae13f5869e77473b638eb359a2a8e0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 17:38:53 +0000 Subject: [PATCH 05/50] refactor: move the calendar reminder stores to DataStore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 4 of the SharedPreferences -> DataStore migration. CalendarReminderPrefs and CalendarReminderStore become CalendarReminderSettingsStore and CalendarReminderLogStore in commons/commonMain, carried over by CopyOnceMigration as before. The log keeps storing the event-start time a reminder fired for rather than a bare flag, which is what makes a moved meeting work: if the author changes the start, the stored value no longer matches and a fresh reminder fires instead of the new time being silently skipped. forgetBefore now also checks the key prefix, so a future setting sharing that store cannot be pruned away by a stale cutoff. The settings screen was the only synchronous reader. It now collects the store's flow instead of reading once in a remember{}, so the first frame shows defaults and the stored values arrive right after — and the screen stays correct if the worker path changes a value while it is open. The other two call sites (CoroutineWorker.doWork, an applicationIOScope launch) were already suspending. Two things in this step were NOT migrated, and the reasons are now in the code rather than in a commit message nobody reads again: - NOTIFICATION_SERVICE_ENABLED cannot move. DataStore is suspend-only, while NotificationRelayService.isEnabled(context) is a synchronous Boolean read from Service and BroadcastReceiver entry points in freshly started processes (boot, watchdog, WorkManager). Making it suspend would leave the restart layers unable to consult it, so a saved OFF would be missed on cold boot and the service would resurrect itself. Plain SharedPreferences is the only store here that answers synchronously on any thread. - ChessDismissedGamesStorage stays too. ChessLobbyLogic reads it in a property initializer and writes it from two non-suspending functions, so migrating means seeding the dismissed set asynchronously — and until that load lands, a game the user already dismissed reappears. That is a visible regression bought with consistency alone: this store is already an expect/actual with a working iOS implementation, so unlike the rest of the preference layer it blocks no target. The account roster (CURRENT_ACCOUNT, SAVED_ACCOUNTS, ALL_ACCOUNT_INFO) is deferred to the secrets step rather than moved here. It currently lives in the encrypted global file; moving it to a plain store would put the list of an installation's npubs in cleartext, so it belongs with the encrypted-store work, not with device settings. 8 tests, porting what the deleted Android CalendarReminderPrefsTest covered plus the two behaviours above. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AXvKXakvup4inNFfAhhr4L --- .../com/vitorpamplona/amethyst/AppModules.kt | 4 +- .../amethyst/LocalPreferences.kt | 9 + .../service/calendar/CalendarReminderPrefs.kt | 66 ----- .../service/calendar/CalendarReminderStore.kt | 84 ------- .../calendar/CalendarReminderStores.kt | 76 ++++++ .../calendar/CalendarReminderWorker.kt | 10 +- .../CalendarReminderSettingsScreen.kt | 29 ++- .../calendar/CalendarReminderPrefsTest.kt | 237 ------------------ .../nip64Chess/ChessDismissedGamesStorage.kt | 15 ++ .../preferences/CalendarReminderLogStore.kt | 88 +++++++ .../CalendarReminderSettingsStore.kt | 89 +++++++ .../preferences/CalendarReminderStoresTest.kt | 144 +++++++++++ 12 files changed, 446 insertions(+), 405 deletions(-) delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderPrefs.kt delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderStore.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderStores.kt delete mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/CalendarReminderPrefsTest.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/CalendarReminderLogStore.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/CalendarReminderSettingsStore.kt create mode 100644 commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/CalendarReminderStoresTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 84f2e38472..00e18e0019 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -75,8 +75,8 @@ import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilde import com.vitorpamplona.amethyst.model.torState.AccountsTorStateConnector import com.vitorpamplona.amethyst.model.torState.TorRelayState import com.vitorpamplona.amethyst.napplet.DataStoreNappletPermissionStore -import com.vitorpamplona.amethyst.service.calendar.CalendarReminderPrefs import com.vitorpamplona.amethyst.service.calendar.CalendarReminderWorker +import com.vitorpamplona.amethyst.service.calendar.calendarReminderSettings import com.vitorpamplona.amethyst.service.cast.CastRegistry import com.vitorpamplona.amethyst.service.connectivity.ConnectivityManager import com.vitorpamplona.amethyst.service.crashreports.CrashReportCache @@ -1297,7 +1297,7 @@ class AppModules( Filter(kinds = listOf(CalendarDateSlotEvent.KIND, CalendarTimeSlotEvent.KIND)), ).conflate() .collect { - if (CalendarReminderPrefs(appContext).isEnabled() && + if (appContext.calendarReminderSettings().load().enabled && CalendarReminderWorker.couldStillFire(CalendarReminderWorker.acceptedRsvpsInCache(), TimeUtils.now()) ) { CalendarReminderWorker.schedule(appContext) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index ce40ff0157..dabf6b10e0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -414,6 +414,15 @@ object LocalPreferences { } } + // NOT migrated to DataStore, and cannot be: DataStore is suspend-only, while + // NotificationRelayService.isEnabled(context) is a synchronous Boolean read + // from Service and BroadcastReceiver entry points in freshly started + // processes (boot, watchdog, WorkManager). Making it suspend would mean the + // restart layers could not consult it at all, and a saved OFF would be + // missed on cold boot — the service would resurrect itself. Plain + // SharedPreferences is the only store here that answers synchronously on + // any thread, so this key stays on it deliberately. + // // Global master switch for the always-on notification service ("Background // notification service"). Default ON: existing users keep current behavior, and // per-account participation decides who actually stays active. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderPrefs.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderPrefs.kt deleted file mode 100644 index 737cffdadb..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderPrefs.kt +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.service.calendar - -import android.content.Context -import android.content.SharedPreferences -import androidx.core.content.edit - -/** - * Device-wide preferences for the calendar reminder worker. - * - * Stored at device scope (rather than per-account) because the worker that consults them runs - * globally — multiplexing per-account preferences would require account-context plumbing into - * WorkManager that the rest of the app doesn't have. A user who flips between two accounts on - * the same device shares the same lead-time and enabled-state. Per-account preferences could be - * a follow-up if anyone asks. - */ -class CalendarReminderPrefs( - context: Context, -) { - private val prefs: SharedPreferences = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) - - fun isEnabled(): Boolean = prefs.getBoolean(KEY_ENABLED, DEFAULT_ENABLED) - - fun setEnabled(enabled: Boolean) { - prefs.edit { putBoolean(KEY_ENABLED, enabled) } - } - - fun leadMinutes(): Int = prefs.getInt(KEY_LEAD_MINUTES, DEFAULT_LEAD_MINUTES) - - fun setLeadMinutes(minutes: Int) { - prefs.edit { putInt(KEY_LEAD_MINUTES, minutes) } - } - - companion object { - const val DEFAULT_LEAD_MINUTES = 15 - const val DEFAULT_ENABLED = true - - // Choices presented in the settings UI. Anchored to the worker cadence — lead times - // smaller than the cadence (15 min) can't be honoured reliably; 60 is the largest the - // UX shape supports without an extra "hours" picker. - val LEAD_TIME_CHOICES = listOf(5, 15, 30, 60) - - private const val PREF_NAME = "amethyst_calendar_reminder_prefs" - private const val KEY_ENABLED = "enabled" - private const val KEY_LEAD_MINUTES = "lead_minutes" - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderStore.kt deleted file mode 100644 index 81c3ad882f..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderStore.kt +++ /dev/null @@ -1,84 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.service.calendar - -import android.content.Context -import android.content.SharedPreferences -import androidx.core.content.edit - -/** - * Persistent "I've already notified for this event" set. Backed by [SharedPreferences] because - * the worker that consults it runs in the app process and the dataset is tiny (≤ a few hundred - * IDs at most). Without persistence, every worker run after a restart would re-notify for the - * same upcoming event until it started, since LocalCache has no memory of past reminders. - * - * Keys are event ids (the 32-byte hex from a 31922/31923 appointment). Values aren't used; only - * presence in the set matters. Entries are pruned by [forgetBefore] when the worker has just - * fired so the store doesn't grow unbounded over time. - */ -class CalendarReminderStore( - context: Context, -) { - private val prefs: SharedPreferences = - context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) - - /** - * Returns true when we've previously notified for this exact event-start pairing. If the - * author updates the appointment to a new start time, the stored value won't match and - * we'll fire a fresh reminder for the new time — that's the desired behaviour: a moved - * meeting shouldn't be silently skipped. - */ - fun wasNotified( - eventId: String, - eventStartSeconds: Long, - ): Boolean = prefs.getLong(keyFor(eventId), Long.MIN_VALUE) == eventStartSeconds - - fun markNotified( - eventId: String, - eventStartSeconds: Long, - ) { - prefs.edit { putLong(keyFor(eventId), eventStartSeconds) } - } - - /** - * Drops any entry whose recorded event-start time is older than [cutoffSeconds]. Called - * after each worker run so the store stays bounded — events that have long since ended - * can't fire a second reminder, so their entries are dead weight. - */ - fun forgetBefore(cutoffSeconds: Long) { - val editor = prefs.edit() - var changed = false - prefs.all.forEach { (key, value) -> - if (value is Long && value < cutoffSeconds) { - editor.remove(key) - changed = true - } - } - if (changed) editor.apply() - } - - companion object { - private const val PREF_NAME = "amethyst_calendar_reminders" - private const val KEY_PREFIX = "notified:" - - private fun keyFor(eventId: String) = KEY_PREFIX + eventId - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderStores.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderStores.kt new file mode 100644 index 0000000000..e7105b31b8 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderStores.kt @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.calendar + +import android.content.Context +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import com.vitorpamplona.amethyst.commons.model.preferences.CalendarReminderLogStore +import com.vitorpamplona.amethyst.commons.model.preferences.CalendarReminderSettings +import com.vitorpamplona.amethyst.commons.model.preferences.CalendarReminderSettingsStore +import com.vitorpamplona.amethyst.commons.model.preferences.CopyOnceMigration + +/** + * Android wiring for the two calendar-reminder stores. + * + * The store classes live in commons; only the file location and the one-off + * lift out of the legacy SharedPreferences are Android's business. + */ +private const val LEGACY_SETTINGS_FILE = "amethyst_calendar_reminder_prefs" +private const val LEGACY_LOG_FILE = "amethyst_calendar_reminders" + +private val Context.calendarReminderSettingsData by preferencesDataStore( + name = "calendar_reminder_settings", + produceMigrations = { context -> + listOf( + CopyOnceMigration("migrated.calendarReminderSettings") { out -> + val legacy = context.getSharedPreferences(LEGACY_SETTINGS_FILE, Context.MODE_PRIVATE) + if (legacy.contains("enabled")) { + out[booleanPreferencesKey("enabled")] = legacy.getBoolean("enabled", CalendarReminderSettings.DEFAULT_ENABLED) + } + if (legacy.contains("lead_minutes")) { + out[intPreferencesKey("lead_minutes")] = legacy.getInt("lead_minutes", CalendarReminderSettings.DEFAULT_LEAD_MINUTES) + } + }, + ) + }, +) + +private val Context.calendarReminderLogData by preferencesDataStore( + name = "calendar_reminder_log", + produceMigrations = { context -> + listOf( + CopyOnceMigration("migrated.calendarReminderLog") { out -> + val legacy = context.getSharedPreferences(LEGACY_LOG_FILE, Context.MODE_PRIVATE) + // Values are the event-start times the reminders fired for; anything + // else in the file is not ours and is left behind. + legacy.all.forEach { (key, value) -> + if (value is Long) out[CalendarReminderLogStore.keyFor(key.removePrefix("notified:"))] = value + } + }, + ) + }, +) + +fun Context.calendarReminderSettings() = CalendarReminderSettingsStore(calendarReminderSettingsData) + +fun Context.calendarReminderLog() = CalendarReminderLogStore(calendarReminderLogData) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderWorker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderWorker.kt index 0ec27f7e71..4916a4f8ed 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderWorker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderWorker.kt @@ -65,8 +65,8 @@ class CalendarReminderWorker( ) : CoroutineWorker(appContext, params) { override suspend fun doWork(): Result { runCatching { Amethyst.instance.resourceUsage.add(UsageKeys.workerRuns("calendarReminder"), 1) } - val prefs = CalendarReminderPrefs(applicationContext) - if (!prefs.isEnabled()) { + val settings = applicationContext.calendarReminderSettings().load() + if (!settings.enabled) { Log.d(TAG) { "Reminders disabled; ending periodic chain." } // The settings toggle re-schedules on enable; no reason to keep // waking the process while the feature is off. @@ -74,8 +74,8 @@ class CalendarReminderWorker( return Result.success() } val now = TimeUtils.now() - val windowEnd = now + prefs.leadMinutes() * 60L - val store = CalendarReminderStore(applicationContext) + val windowEnd = now + settings.leadMinutes * 60L + val store = applicationContext.calendarReminderLog() // Walk every kind-31925 RSVP authored by an account on this device. We don't have a // multi-account "all logged-in pubkeys" view here, so we accept any RSVP that's @@ -83,7 +83,7 @@ class CalendarReminderWorker( // silently break notifications for account switching during the lead window. val acceptedRsvps = acceptedRsvpsInCache() - Log.d(TAG) { "Worker scanning ${acceptedRsvps.size} accepted RSVPs (now=$now, lead=${prefs.leadMinutes()}m)" } + Log.d(TAG) { "Worker scanning ${acceptedRsvps.size} accepted RSVPs (now=$now, lead=${settings.leadMinutes}m)" } acceptedRsvps.forEach { rsvp -> val targetAddress = rsvp.calendarEventAddress() ?: return@forEach diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarReminderSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarReminderSettingsScreen.kt index ebae9478f0..14b8917612 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarReminderSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarReminderSettingsScreen.kt @@ -34,14 +34,15 @@ import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.preferences.CalendarReminderSettings import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.calendar_reminder_settings_enabled_subtitle import com.vitorpamplona.amethyst.commons.resources.calendar_reminder_settings_enabled_title @@ -50,8 +51,8 @@ import com.vitorpamplona.amethyst.commons.resources.calendar_reminder_settings_l import com.vitorpamplona.amethyst.commons.resources.calendar_reminder_settings_lead_title import com.vitorpamplona.amethyst.commons.resources.calendar_reminder_settings_title import com.vitorpamplona.amethyst.commons.resources.settings_section_reminders -import com.vitorpamplona.amethyst.service.calendar.CalendarReminderPrefs import com.vitorpamplona.amethyst.service.calendar.CalendarReminderWorker +import com.vitorpamplona.amethyst.service.calendar.calendarReminderSettings import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.ui.pluralStringRes @@ -60,14 +61,22 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsDivider import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsSection import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsSwitchTile import com.vitorpamplona.amethyst.ui.stringRes +import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable fun CalendarReminderSettingsScreen(nav: INav) { val context = LocalContext.current - val prefs = remember { CalendarReminderPrefs(context) } - var enabled by remember { mutableStateOf(prefs.isEnabled()) } - var leadMinutes by remember { mutableIntStateOf(prefs.leadMinutes()) } + val scope = rememberCoroutineScope() + val store = remember(context) { context.calendarReminderSettings() } + + // DataStore reads are suspend, so the first frame renders the defaults and + // the stored values arrive right after. Collecting the flow rather than + // reading once also keeps the screen correct if the worker path or another + // screen changes a value while this one is open. + val settings by store.flow.collectAsStateWithLifecycle(CalendarReminderSettings()) + val enabled = settings.enabled + val leadMinutes = settings.leadMinutes Scaffold( topBar = { @@ -92,8 +101,7 @@ fun CalendarReminderSettingsScreen(nav: INav) { description = Res.string.calendar_reminder_settings_enabled_subtitle, checked = enabled, onCheckedChange = { - enabled = it - prefs.setEnabled(it) + scope.launch { store.setEnabled(it) } // Cancel eagerly on disable so the periodic worker stops waking the // process; re-enabling re-schedules immediately, and the ACCEPTED-RSVP // observer in AppModules re-schedules on the next relevant RSVP too. @@ -110,15 +118,14 @@ fun CalendarReminderSettingsScreen(nav: INav) { title = stringRes(Res.string.calendar_reminder_settings_lead_title), description = stringRes(Res.string.calendar_reminder_settings_lead_subtitle), ) { - val choices = CalendarReminderPrefs.LEAD_TIME_CHOICES + val choices = CalendarReminderSettings.LEAD_TIME_CHOICES SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { choices.forEachIndexed { index, choice -> SegmentedButton( selected = choice == leadMinutes, enabled = enabled, onClick = { - leadMinutes = choice - prefs.setLeadMinutes(choice) + scope.launch { store.setLeadMinutes(choice) } }, shape = SegmentedButtonDefaults.itemShape(index = index, count = choices.size), icon = {}, diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/CalendarReminderPrefsTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/CalendarReminderPrefsTest.kt deleted file mode 100644 index 56006280bd..0000000000 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/CalendarReminderPrefsTest.kt +++ /dev/null @@ -1,237 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.calendar - -import android.content.Context -import android.content.SharedPreferences -import com.vitorpamplona.amethyst.service.calendar.CalendarReminderPrefs -import com.vitorpamplona.amethyst.service.calendar.CalendarReminderStore -import io.mockk.every -import io.mockk.mockk -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Test - -/** - * Unit tests for the device-level reminder preferences and the per-event "already notified" - * store. Backed by an in-memory fake [SharedPreferences] so the test runs on the JVM without - * needing Robolectric. - */ -class CalendarReminderPrefsTest { - private lateinit var fakePrefs: FakeSharedPreferences - private lateinit var ctx: Context - - @Before - fun setUp() { - fakePrefs = FakeSharedPreferences() - ctx = mockk() - every { ctx.getSharedPreferences(any(), any()) } returns fakePrefs - } - - @Test - fun prefs_defaultsMatchPublicConstants() { - val prefs = CalendarReminderPrefs(ctx) - // Defaults are the contract callers in AppModules rely on — flipping these without an - // explicit migration would silently re-enable reminders for users who had turned them - // off (or vice versa). - assertEquals(CalendarReminderPrefs.DEFAULT_ENABLED, prefs.isEnabled()) - assertEquals(CalendarReminderPrefs.DEFAULT_LEAD_MINUTES, prefs.leadMinutes()) - } - - @Test - fun prefs_setEnabled_roundTrips() { - val prefs = CalendarReminderPrefs(ctx) - prefs.setEnabled(false) - assertFalse(prefs.isEnabled()) - prefs.setEnabled(true) - assertTrue(prefs.isEnabled()) - } - - @Test - fun prefs_setLeadMinutes_roundTrips() { - val prefs = CalendarReminderPrefs(ctx) - prefs.setLeadMinutes(30) - assertEquals(30, prefs.leadMinutes()) - } - - @Test - fun store_wasNotified_isFalseByDefault() { - val store = CalendarReminderStore(ctx) - assertFalse(store.wasNotified("event-a", 1_000_000L)) - } - - @Test - fun store_markNotified_makesWasNotifiedTrueForSameStart() { - val store = CalendarReminderStore(ctx) - store.markNotified("event-a", 1_000_000L) - assertTrue(store.wasNotified("event-a", 1_000_000L)) - } - - @Test - fun store_wasNotified_isFalseWhenStartChanges() { - // Regression test for the "moved meeting" case: if the author updates the appointment - // with a new start, the store should not silently swallow the new reminder. - val store = CalendarReminderStore(ctx) - store.markNotified("event-a", 1_000_000L) - assertFalse(store.wasNotified("event-a", 2_000_000L)) - } - - @Test - fun store_forgetBefore_dropsOldEntries() { - val store = CalendarReminderStore(ctx) - store.markNotified("old", 1_000_000L) - store.markNotified("recent", 5_000_000L) - store.forgetBefore(3_000_000L) - assertFalse(store.wasNotified("old", 1_000_000L)) - assertTrue(store.wasNotified("recent", 5_000_000L)) - } -} - -/** - * Bare-bones in-memory implementation of [SharedPreferences] sufficient for the prefs/store - * round-trip tests. apply() is synchronous here — fine because the production code never relies - * on apply()'s async semantics. - */ -private class FakeSharedPreferences : SharedPreferences { - private val data = mutableMapOf() - - override fun getAll(): MutableMap = data - - override fun getString( - key: String, - defValue: String?, - ): String? = data[key] as? String ?: defValue - - override fun getStringSet( - key: String, - defValues: MutableSet?, - ): MutableSet? { - @Suppress("UNCHECKED_CAST") - return data[key] as? MutableSet ?: defValues - } - - override fun getInt( - key: String, - defValue: Int, - ): Int = (data[key] as? Int) ?: defValue - - override fun getLong( - key: String, - defValue: Long, - ): Long = (data[key] as? Long) ?: defValue - - override fun getFloat( - key: String, - defValue: Float, - ): Float = (data[key] as? Float) ?: defValue - - override fun getBoolean( - key: String, - defValue: Boolean, - ): Boolean = (data[key] as? Boolean) ?: defValue - - override fun contains(key: String): Boolean = data.containsKey(key) - - override fun edit(): SharedPreferences.Editor = FakeEditor(data) - - override fun registerOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) = Unit - - override fun unregisterOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) = Unit -} - -private class FakeEditor( - private val data: MutableMap, -) : SharedPreferences.Editor { - private val pending = mutableMapOf() - private val removed = mutableSetOf() - private var clearAll = false - - override fun putString( - key: String, - value: String?, - ): SharedPreferences.Editor { - pending[key] = value - return this - } - - override fun putStringSet( - key: String, - values: MutableSet?, - ): SharedPreferences.Editor { - pending[key] = values - return this - } - - override fun putInt( - key: String, - value: Int, - ): SharedPreferences.Editor { - pending[key] = value - return this - } - - override fun putLong( - key: String, - value: Long, - ): SharedPreferences.Editor { - pending[key] = value - return this - } - - override fun putFloat( - key: String, - value: Float, - ): SharedPreferences.Editor { - pending[key] = value - return this - } - - override fun putBoolean( - key: String, - value: Boolean, - ): SharedPreferences.Editor { - pending[key] = value - return this - } - - override fun remove(key: String): SharedPreferences.Editor { - removed.add(key) - return this - } - - override fun clear(): SharedPreferences.Editor { - clearAll = true - return this - } - - override fun commit(): Boolean { - apply() - return true - } - - override fun apply() { - if (clearAll) data.clear() - removed.forEach { data.remove(it) } - data.putAll(pending) - } -} diff --git a/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessDismissedGamesStorage.kt b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessDismissedGamesStorage.kt index 53e63f7e8e..db5078390e 100644 --- a/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessDismissedGamesStorage.kt +++ b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessDismissedGamesStorage.kt @@ -24,6 +24,21 @@ import android.content.Context import android.content.SharedPreferences import androidx.core.content.edit +/** + * Still on SharedPreferences, deliberately, while the rest of the app moved to + * DataStore. + * + * ChessLobbyLogic reads this in a property initializer and writes it from two + * non-suspending functions. DataStore is suspend-only, so migrating would mean + * seeding the dismissed set asynchronously — and until that load lands, a game + * the user already dismissed reappears in the list. That is a visible + * regression in exchange for consistency alone: this store is already an + * expect/actual with a working iOS implementation, so unlike the rest of the + * preference layer it is not blocking any target. + * + * Worth revisiting if ChessLobbyLogic ever gains a suspending initialisation + * path of its own. + */ actual class ChessDismissedGamesStorage private actual constructor() { private var prefs: SharedPreferences? = null diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/CalendarReminderLogStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/CalendarReminderLogStore.kt new file mode 100644 index 0000000000..0cf1590083 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/CalendarReminderLogStore.kt @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.preferences + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.longPreferencesKey +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.first +import okio.IOException + +/** + * The "already reminded about this" log for calendar appointments. + * + * Without it, every worker run after a restart would re-notify for the same + * upcoming event until it started, since LocalCache has no memory of past + * reminders. + * + * Each key stores the event-start time the reminder fired for, not a bare flag. + * That is what makes a moved meeting work: if the author changes the start, the + * stored value no longer matches and a fresh reminder fires, rather than the + * new time being silently skipped. + */ +class CalendarReminderLogStore( + private val store: DataStore, +) { + companion object { + private const val KEY_PREFIX = "notified:" + + fun keyFor(eventId: String) = longPreferencesKey(KEY_PREFIX + eventId) + + internal fun isLogKey(key: Preferences.Key<*>) = key.name.startsWith(KEY_PREFIX) + } + + private suspend fun read(): Preferences = + store.data + .catch { e -> if (e is IOException) emit(emptyPreferences()) else throw e } + .first() + + suspend fun wasNotified( + eventId: String, + eventStartSeconds: Long, + ): Boolean = read()[keyFor(eventId)] == eventStartSeconds + + suspend fun markNotified( + eventId: String, + eventStartSeconds: Long, + ) { + store.edit { it[keyFor(eventId)] = eventStartSeconds } + } + + /** + * Drops entries whose recorded event-start is older than [cutoffSeconds], + * keeping the log bounded — an event that has long since ended cannot fire + * a second reminder, so its entry is dead weight. + * + * Only keys carrying the log's own prefix are considered, so a future + * setting sharing this store cannot be pruned away by a stale cutoff. + */ + suspend fun forgetBefore(cutoffSeconds: Long) { + store.edit { prefs -> + prefs + .asMap() + .filter { (key, value) -> isLogKey(key) && value is Long && value < cutoffSeconds } + .forEach { (key, _) -> prefs.remove(key) } + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/CalendarReminderSettingsStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/CalendarReminderSettingsStore.kt new file mode 100644 index 0000000000..adcb7b17f6 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/CalendarReminderSettingsStore.kt @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.preferences + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.intPreferencesKey +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import okio.IOException + +/** + * Device-wide settings for the calendar reminder worker. + * + * Device scope rather than per-account, as before: the worker that consults + * them runs globally, and multiplexing per-account settings would need + * account-context plumbing into WorkManager that the rest of the app does not + * have. A user who flips between accounts on one device shares one lead time. + */ +data class CalendarReminderSettings( + val enabled: Boolean = DEFAULT_ENABLED, + val leadMinutes: Int = DEFAULT_LEAD_MINUTES, +) { + companion object { + const val DEFAULT_LEAD_MINUTES = 15 + const val DEFAULT_ENABLED = true + + /** + * Choices presented in the settings UI. Anchored to the worker cadence — + * lead times smaller than the cadence (15 min) cannot be honoured + * reliably; 60 is the largest the UX shape supports without an extra + * "hours" picker. + */ + val LEAD_TIME_CHOICES = listOf(5, 15, 30, 60) + } +} + +class CalendarReminderSettingsStore( + private val store: DataStore, +) { + companion object { + val enabled = booleanPreferencesKey("enabled") + val leadMinutes = intPreferencesKey("lead_minutes") + } + + private fun Flow.guarded() = catch { e -> if (e is IOException) emit(emptyPreferences()) else throw e } + + /** Observes the settings, so a screen can react to a change made elsewhere. */ + val flow: Flow = + store.data.guarded().map { prefs -> + CalendarReminderSettings( + enabled = prefs[enabled] ?: CalendarReminderSettings.DEFAULT_ENABLED, + leadMinutes = prefs[leadMinutes] ?: CalendarReminderSettings.DEFAULT_LEAD_MINUTES, + ) + } + + suspend fun load(): CalendarReminderSettings = flow.first() + + suspend fun setEnabled(value: Boolean) { + store.edit { it[enabled] = value } + } + + suspend fun setLeadMinutes(value: Int) { + store.edit { it[leadMinutes] = value } + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/CalendarReminderStoresTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/CalendarReminderStoresTest.kt new file mode 100644 index 0000000000..06a9219e4d --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/CalendarReminderStoresTest.kt @@ -0,0 +1,144 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.preferences + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.longPreferencesKey +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import okio.Path.Companion.toOkioPath +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +/** Ports the coverage the Android CalendarReminderPrefsTest had, onto the DataStore stores. */ +class CalendarReminderStoresTest { + @get:Rule + val folder = TemporaryFolder() + + private var seq = 0 + + private fun raw(): DataStore { + val file = File(folder.root, "cal_${seq++}.preferences_pb") + return PreferenceDataStoreFactory.createWithPath( + scope = CoroutineScope(Dispatchers.IO + SupervisorJob()), + produceFile = { file.toOkioPath() }, + ) + } + + // ── settings ────────────────────────────────────────────────────── + + /** Reminders are ON out of the box; a wrong default here silently stops everyone's reminders. */ + @Test + fun defaultsAreEnabledAtFifteenMinutes() = + runTest { + val loaded = CalendarReminderSettingsStore(raw()).load() + + assertEquals(true, loaded.enabled) + assertEquals(15, loaded.leadMinutes) + } + + @Test + fun settingsRoundTrip() = + runTest { + val store = CalendarReminderSettingsStore(raw()) + + store.setEnabled(false) + store.setLeadMinutes(60) + + assertEquals(CalendarReminderSettings(enabled = false, leadMinutes = 60), store.load()) + } + + /** The choices are anchored to the worker cadence — below 15 min cannot be honoured. */ + @Test + fun leadTimeChoicesAreUnchanged() { + assertEquals(listOf(5, 15, 30, 60), CalendarReminderSettings.LEAD_TIME_CHOICES) + assertTrue(CalendarReminderSettings.DEFAULT_LEAD_MINUTES in CalendarReminderSettings.LEAD_TIME_CHOICES) + } + + // ── log ─────────────────────────────────────────────────────────── + + @Test + fun anUnknownEventWasNotNotified() = + runTest { + assertFalse(CalendarReminderLogStore(raw()).wasNotified("abc", 1_000L)) + } + + @Test + fun markingMakesItNotified() = + runTest { + val store = CalendarReminderLogStore(raw()) + + store.markNotified("abc", 1_000L) + + assertTrue(store.wasNotified("abc", 1_000L)) + } + + /** + * The stored value is the event-start the reminder fired for, not a flag: + * a moved meeting must fire again rather than be silently skipped. + */ + @Test + fun aMovedEventIsNotifiedAgain() = + runTest { + val store = CalendarReminderLogStore(raw()) + store.markNotified("abc", 1_000L) + + assertFalse("the new start time has not been notified", store.wasNotified("abc", 2_000L)) + } + + @Test + fun forgetBeforeDropsOnlyOlderEntries() = + runTest { + val store = CalendarReminderLogStore(raw()) + store.markNotified("old", 100L) + store.markNotified("new", 900L) + + store.forgetBefore(500L) + + assertFalse(store.wasNotified("old", 100L)) + assertTrue(store.wasNotified("new", 900L)) + } + + /** forgetBefore must only prune its own keys, not anything else sharing the store. */ + @Test + fun forgetBeforeLeavesForeignKeysAlone() = + runTest { + val foreignKey = longPreferencesKey("someone_elses_counter") + val raw = raw() + val log = CalendarReminderLogStore(raw) + log.markNotified("old", 100L) + raw.updateData { prefs -> prefs.toMutablePreferences().apply { this[foreignKey] = 1L } } + + log.forgetBefore(500L) + + assertEquals(1L, raw.data.first()[foreignKey]) + } +} From e4ebb0c728117b0a7bbf8bb6b7440191b4a7c83a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 18:47:31 +0000 Subject: [PATCH 06/50] refactor: move chess dismissed games to DataStore, dropping the old data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverses the decision made one commit earlier to leave this on SharedPreferences. That call was made to avoid a visible regression — seeding asynchronously means a dismissed game can reappear for a frame or two — but chess has few enough users that the regression does not matter, which also removes the reason to carry the old data over. Replaces the whole expect/actual trio (commonMain expect, androidMain SharedPreferences, jvmMain java.util.prefs, iosMain in-memory) with one commonMain ChessDismissedGamesStore over DataStore. That is the payoff from making the preference layer multiplatform in the first commit of this series: the platform now supplies a file location rather than an implementation. iOS gains real persistence as a side effect. Its actual had been an in-memory map, commented as standing in until an iosApp module existed, so dismissals there were lost on every launch. No migration, deliberately. The old stores keep their data and nothing reads it; a user who had dismissed a game sees it once more and dismisses it again. Android writes to a new file (chess_dismissed_games_v2) rather than reusing the old name, so the two cannot be confused. ChessLobbyLogic now seeds the dismissed set in an init launch rather than a property initializer. The seed unions rather than replaces, so a dismissal the user makes before the read lands is not overwritten by it. The two writes are fire-and-forget on the logic's own scope. Desktop gets its store from commons rather than building one itself, so a front end does not need DataStore on its own classpath. The per-OS data directory that SecretEncryption had inlined is now a single commons/jvmMain definition both use, so the key file and the stores land in the same place. 5 tests: unknown user, round trip, per-user isolation, that an empty set removes the key rather than storing an empty one, and that a later save replaces rather than merges. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AXvKXakvup4inNFfAhhr4L --- .../loggedIn/chess/ChessDismissedGamesData.kt | 26 ++--- .../loggedIn/chess/ChessViewModelNew.kt | 4 +- .../nip64Chess/ChessDismissedGamesStorage.kt | 69 ----------- .../nip64Chess/ChessDismissedGamesStore.kt | 66 +++++++++++ .../commons/nip64Chess/ChessLobbyLogic.kt | 30 ++++- .../model/preferences/SecretEncryption.jvm.kt | 17 +-- .../ChessDismissedGamesStoreJvm.kt} | 36 +++--- .../AppDataDir.kt} | 46 ++++---- .../ChessDismissedGamesStoreTest.kt | 107 ++++++++++++++++++ .../desktop/chess/DesktopChessViewModelNew.kt | 4 +- 10 files changed, 250 insertions(+), 155 deletions(-) rename commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessDismissedGamesStorage.kt => amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessDismissedGamesData.kt (65%) delete mode 100644 commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessDismissedGamesStorage.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessDismissedGamesStore.kt rename commons/src/{iosMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessDismissedGamesStorage.ios.kt => jvmMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessDismissedGamesStoreJvm.kt} (58%) rename commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/{nip64Chess/ChessDismissedGamesStorage.kt => util/AppDataDir.kt} (53%) create mode 100644 commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessDismissedGamesStoreTest.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessDismissedGamesStorage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessDismissedGamesData.kt similarity index 65% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessDismissedGamesStorage.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessDismissedGamesData.kt index 3c950e3509..dc63800518 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessDismissedGamesStorage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessDismissedGamesData.kt @@ -18,21 +18,17 @@ * 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.nip64Chess +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chess + +import android.content.Context +import androidx.datastore.preferences.preferencesDataStore /** - * Persists dismissed chess game IDs locally per user. - * Uses expect/actual for platform-specific storage. + * Where Android keeps the dismissed-chess-games store. + * + * A new file rather than a migration of `chess_dismissed_games`: the dismissed + * list is a convenience, and chess has few enough users that carrying the old + * data over is not worth the code. Anyone who had dismissed a game sees it once + * more and dismisses it again. */ -expect class ChessDismissedGamesStorage private constructor() { - companion object { - fun create(context: Any? = null): ChessDismissedGamesStorage - } - - fun load(userPubkey: String): Set - - fun save( - userPubkey: String, - ids: Set, - ) -} +internal val Context.chessDismissedGamesData by preferencesDataStore(name = "chess_dismissed_games_v2") diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelNew.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelNew.kt index 893c23fb17..ae46d80463 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelNew.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelNew.kt @@ -25,7 +25,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.commons.nip64Chess.ChessBroadcastStatus import com.vitorpamplona.amethyst.commons.nip64Chess.ChessChallenge -import com.vitorpamplona.amethyst.commons.nip64Chess.ChessDismissedGamesStorage +import com.vitorpamplona.amethyst.commons.nip64Chess.ChessDismissedGamesStore import com.vitorpamplona.amethyst.commons.nip64Chess.ChessLobbyLogic import com.vitorpamplona.amethyst.commons.nip64Chess.ChessPollingDefaults import com.vitorpamplona.amethyst.commons.nip64Chess.ChessSyncStatus @@ -61,7 +61,7 @@ class ChessViewModelNew( private val publisher = AndroidChessPublisher(account) private val fetcher = AndroidRelayFetcher(account) private val metadataProvider = AndroidMetadataProvider() - private val dismissedStorage = ChessDismissedGamesStorage.create(application) + private val dismissedStorage = ChessDismissedGamesStore(application.chessDismissedGamesData) // Shared business logic (creates its own ChessLobbyState internally) private val logic = diff --git a/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessDismissedGamesStorage.kt b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessDismissedGamesStorage.kt deleted file mode 100644 index db5078390e..0000000000 --- a/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessDismissedGamesStorage.kt +++ /dev/null @@ -1,69 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.commons.nip64Chess - -import android.content.Context -import android.content.SharedPreferences -import androidx.core.content.edit - -/** - * Still on SharedPreferences, deliberately, while the rest of the app moved to - * DataStore. - * - * ChessLobbyLogic reads this in a property initializer and writes it from two - * non-suspending functions. DataStore is suspend-only, so migrating would mean - * seeding the dismissed set asynchronously — and until that load lands, a game - * the user already dismissed reappears in the list. That is a visible - * regression in exchange for consistency alone: this store is already an - * expect/actual with a working iOS implementation, so unlike the rest of the - * preference layer it is not blocking any target. - * - * Worth revisiting if ChessLobbyLogic ever gains a suspending initialisation - * path of its own. - */ -actual class ChessDismissedGamesStorage private actual constructor() { - private var prefs: SharedPreferences? = null - - actual companion object { - private const val PREFS_NAME = "chess_dismissed_games" - - private fun prefsKey(userPubkey: String) = "dismissed_$userPubkey" - - actual fun create(context: Any?): ChessDismissedGamesStorage { - val storage = ChessDismissedGamesStorage() - val ctx = - context as? Context - ?: throw IllegalArgumentException("Android context required") - storage.prefs = ctx.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - return storage - } - } - - // getStringSet returns a live reference to the internal set — must copy defensively - actual fun load(userPubkey: String): Set = prefs?.getStringSet(prefsKey(userPubkey), null)?.toHashSet() ?: emptySet() - - actual fun save( - userPubkey: String, - ids: Set, - ) { - prefs?.edit { putStringSet(prefsKey(userPubkey), ids) } - } -} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessDismissedGamesStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessDismissedGamesStore.kt new file mode 100644 index 0000000000..1adbf9ab06 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessDismissedGamesStore.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.nip64Chess + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.stringSetPreferencesKey +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.first +import okio.IOException + +/** + * The chess games a user has dismissed from their completed list, per pubkey. + * + * Replaces the previous expect/actual trio. DataStore is multiplatform, so one + * implementation now serves every target — and iOS gains real persistence, + * where its actual had been an in-memory map standing in until an iosApp + * module existed. + * + * Nothing is carried over from the old per-platform stores: the dismissed list + * is a convenience, losing it costs a user one re-dismissal, and chess has few + * enough users that a migration is not worth the code that would carry it. + */ +class ChessDismissedGamesStore( + private val store: DataStore, +) { + companion object { + internal fun keyFor(userPubkey: String) = stringSetPreferencesKey("dismissed_$userPubkey") + } + + suspend fun load(userPubkey: String): Set = + store.data + .catch { e -> if (e is IOException) emit(emptyPreferences()) else throw e } + .first()[keyFor(userPubkey)] + ?: emptySet() + + /** An empty set removes the key rather than storing an empty one. */ + suspend fun save( + userPubkey: String, + ids: Set, + ) { + store.edit { prefs -> + if (ids.isEmpty()) prefs.remove(keyFor(userPubkey)) else prefs[keyFor(userPubkey)] = ids + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessLobbyLogic.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessLobbyLogic.kt index ee6e5604bd..d799e48997 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessLobbyLogic.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessLobbyLogic.kt @@ -128,13 +128,33 @@ class ChessLobbyLogic( private val metadataProvider: IUserMetadataProvider, private val scope: CoroutineScope, pollingConfig: ChessPollingConfig = ChessPollingDefaults.android, - private val dismissedStorage: ChessDismissedGamesStorage? = null, + private val dismissedStorage: ChessDismissedGamesStore? = null, ) { val state = ChessLobbyState(userPubkey, scope) private val dismissedGameIdsLock = KmpLock() - private val dismissedGameIds: MutableSet = - (dismissedStorage?.load(userPubkey)?.toMutableSet() ?: mutableSetOf()) + + /** + * Seeded asynchronously: the store is DataStore-backed and reads suspend, + * so this starts empty and fills shortly after construction. Until it does, + * a previously dismissed game can appear in the completed list for a frame + * or two. + * + * The seed unions rather than replaces, so a dismissal the user makes + * before the read lands is not overwritten by it. + */ + private val dismissedGameIds: MutableSet = mutableSetOf() + + init { + dismissedStorage?.let { storage -> + scope.launch { + val stored = storage.load(userPubkey) + if (stored.isNotEmpty()) { + dismissedGameIdsLock.withLock { dismissedGameIds.addAll(stored) } + } + } + } + } // Track when games were last loaded to prevent duplicate fetches // (e.g., discoverUserGames loads a game, then polling immediately re-fetches it). @@ -969,7 +989,7 @@ class ChessLobbyLogic( dismissedGameIds.add(gameId) dismissedGameIds.toSet() } - dismissedStorage?.save(userPubkey, snapshot) + dismissedStorage?.let { storage -> scope.launch { storage.save(userPubkey, snapshot) } } } fun dismissAllCompletedGames() { @@ -980,7 +1000,7 @@ class ChessLobbyLogic( dismissedGameIds.addAll(allIds) dismissedGameIds.toSet() } - dismissedStorage?.save(userPubkey, snapshot) + dismissedStorage?.let { storage -> scope.launch { storage.save(userPubkey, snapshot) } } } /** diff --git a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/SecretEncryption.jvm.kt b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/SecretEncryption.jvm.kt index a1c9246919..c11b1505da 100644 --- a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/SecretEncryption.jvm.kt +++ b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/SecretEncryption.jvm.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.commons.model.preferences +import com.vitorpamplona.amethyst.commons.util.appDataDir import com.vitorpamplona.amethyst.commons.util.restrictToOwner import com.vitorpamplona.quartz.utils.Log import java.io.File @@ -56,21 +57,7 @@ actual class SecretEncryption internal constructor( private const val GCM_TAG_LENGTH_BITS = 128 private const val KEY_FILE_NAME = "secret.key" - /** Where this OS keeps per-user application data. */ - internal fun defaultKeyFile(): File { - val home = System.getProperty("user.home") ?: "." - val os = System.getProperty("os.name").orEmpty().lowercase() - val dir = - when { - os.contains("mac") || os.contains("darwin") -> - File(home, "Library/Application Support/Amethyst") - os.contains("win") -> - File(System.getenv("APPDATA") ?: "$home\\AppData\\Roaming", "Amethyst") - else -> - File(System.getenv("XDG_DATA_HOME")?.takeIf { it.isNotBlank() } ?: "$home/.local/share", "amethyst") - } - return File(dir, KEY_FILE_NAME) - } + internal fun defaultKeyFile(): File = File(appDataDir, KEY_FILE_NAME) } // A Cipher holds the state of the operation in progress, so two coroutines diff --git a/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessDismissedGamesStorage.ios.kt b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessDismissedGamesStoreJvm.kt similarity index 58% rename from commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessDismissedGamesStorage.ios.kt rename to commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessDismissedGamesStoreJvm.kt index 9148daed9e..5d0153c51d 100644 --- a/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessDismissedGamesStorage.ios.kt +++ b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessDismissedGamesStoreJvm.kt @@ -20,25 +20,21 @@ */ package com.vitorpamplona.amethyst.commons.nip64Chess -// Phase 2 compile-only iOS actual. In-memory only; persistence via -// NSUserDefaults arrives with the iosApp module in Phase 3. -actual class ChessDismissedGamesStorage private actual constructor() { - private val dismissed = mutableMapOf>() +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import com.vitorpamplona.amethyst.commons.util.appDataDir +import okio.Path.Companion.toOkioPath +import java.io.File - actual companion object { - actual fun create(context: Any?): ChessDismissedGamesStorage = ChessDismissedGamesStorage() - } - - actual fun load(userPubkey: String): Set = dismissed[userPubkey] ?: emptySet() - - actual fun save( - userPubkey: String, - ids: Set, - ) { - if (ids.isEmpty()) { - dismissed.remove(userPubkey) - } else { - dismissed[userPubkey] = ids - } - } +/** + * The desktop dismissed-games store, in the shared app data directory. + * + * Built here rather than in desktopApp so a front end does not need DataStore + * on its own classpath to get one. Replaces a `java.util.prefs` node without + * carrying it over — the dismissed list is a convenience, and chess has few + * enough users that a migration is not worth the code. + */ +fun desktopChessDismissedGamesStore(): ChessDismissedGamesStore { + val file = File(appDataDir, "chess_dismissed_games.preferences_pb") + file.parentFile?.mkdirs() + return ChessDismissedGamesStore(PreferenceDataStoreFactory.createWithPath(produceFile = { file.toOkioPath() })) } diff --git a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessDismissedGamesStorage.kt b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/util/AppDataDir.kt similarity index 53% rename from commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessDismissedGamesStorage.kt rename to commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/util/AppDataDir.kt index 7ea69cdd61..c589ceb54c 100644 --- a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessDismissedGamesStorage.kt +++ b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/util/AppDataDir.kt @@ -18,34 +18,26 @@ * 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.nip64Chess +package com.vitorpamplona.amethyst.commons.util -import java.util.prefs.Preferences +import java.io.File -actual class ChessDismissedGamesStorage private actual constructor() { - private val prefs: Preferences = Preferences.userNodeForPackage(ChessDismissedGamesStorage::class.java) - - actual companion object { - private const val NODE_PREFIX = "chess_dismissed_" - private const val DELIMITER = "," - - actual fun create(context: Any?): ChessDismissedGamesStorage = ChessDismissedGamesStorage() - } - - actual fun load(userPubkey: String): Set { - val raw = prefs.get("$NODE_PREFIX$userPubkey", "") - if (raw.isEmpty()) return emptySet() - return raw.split(DELIMITER).toSet() - } - - actual fun save( - userPubkey: String, - ids: Set, - ) { - if (ids.isEmpty()) { - prefs.remove("$NODE_PREFIX$userPubkey") - } else { - prefs.put("$NODE_PREFIX$userPubkey", ids.joinToString(DELIMITER)) +/** + * Where this desktop OS keeps per-user application data. + * + * One definition, so the key file, the preference stores and anything else + * persistent land together rather than each picking their own convention. + */ +val appDataDir: File + get() { + val home = System.getProperty("user.home") ?: "." + val os = System.getProperty("os.name").orEmpty().lowercase() + return when { + os.contains("mac") || os.contains("darwin") -> + File(home, "Library/Application Support/Amethyst") + os.contains("win") -> + File(System.getenv("APPDATA") ?: "$home\\AppData\\Roaming", "Amethyst") + else -> + File(System.getenv("XDG_DATA_HOME")?.takeIf { it.isNotBlank() } ?: "$home/.local/share", "amethyst") } } -} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessDismissedGamesStoreTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessDismissedGamesStoreTest.kt new file mode 100644 index 0000000000..0d4efd334f --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessDismissedGamesStoreTest.kt @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.nip64Chess + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import okio.Path.Companion.toOkioPath +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +class ChessDismissedGamesStoreTest { + @get:Rule + val folder = TemporaryFolder() + + private var seq = 0 + + private fun raw(): DataStore { + val file = File(folder.root, "chess_${seq++}.preferences_pb") + return PreferenceDataStoreFactory.createWithPath( + scope = CoroutineScope(Dispatchers.IO + SupervisorJob()), + produceFile = { file.toOkioPath() }, + ) + } + + @Test + fun anUnknownUserHasDismissedNothing() = + runTest { + assertTrue(ChessDismissedGamesStore(raw()).load("npub1").isEmpty()) + } + + @Test + fun savedIdsReadBack() = + runTest { + val store = ChessDismissedGamesStore(raw()) + + store.save("npub1", setOf("game1", "game2")) + + assertEquals(setOf("game1", "game2"), store.load("npub1")) + } + + /** Two accounts on one device must not see each other's dismissals. */ + @Test + fun usersAreIsolated() = + runTest { + val store = ChessDismissedGamesStore(raw()) + + store.save("npub1", setOf("game1")) + store.save("npub2", setOf("game2")) + + assertEquals(setOf("game1"), store.load("npub1")) + assertEquals(setOf("game2"), store.load("npub2")) + } + + /** An empty set removes the key rather than storing an empty one. */ + @Test + fun savingAnEmptySetClearsTheEntry() = + runTest { + val raw = raw() + val store = ChessDismissedGamesStore(raw) + store.save("npub1", setOf("game1")) + + store.save("npub1", emptySet()) + + assertTrue(store.load("npub1").isEmpty()) + assertFalse(raw.data.first().contains(ChessDismissedGamesStore.keyFor("npub1"))) + } + + @Test + fun aLaterSaveReplacesTheSet() = + runTest { + val store = ChessDismissedGamesStore(raw()) + + store.save("npub1", setOf("a", "b")) + store.save("npub1", setOf("c")) + + assertEquals(setOf("c"), store.load("npub1")) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessViewModelNew.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessViewModelNew.kt index d120a24714..3a5123f8b7 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessViewModelNew.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessViewModelNew.kt @@ -23,12 +23,12 @@ package com.vitorpamplona.amethyst.desktop.chess import com.vitorpamplona.amethyst.commons.model.cache.UserMetadataCache import com.vitorpamplona.amethyst.commons.nip64Chess.ChessBroadcastStatus import com.vitorpamplona.amethyst.commons.nip64Chess.ChessChallenge -import com.vitorpamplona.amethyst.commons.nip64Chess.ChessDismissedGamesStorage import com.vitorpamplona.amethyst.commons.nip64Chess.ChessLobbyLogic import com.vitorpamplona.amethyst.commons.nip64Chess.ChessPollingDefaults import com.vitorpamplona.amethyst.commons.nip64Chess.ChessSyncStatus import com.vitorpamplona.amethyst.commons.nip64Chess.CompletedGame import com.vitorpamplona.amethyst.commons.nip64Chess.PublicGame +import com.vitorpamplona.amethyst.commons.nip64Chess.desktopChessDismissedGamesStore import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.quartz.nip01Core.core.Event @@ -60,7 +60,7 @@ class DesktopChessViewModelNew( private val publisher = DesktopChessPublisher(account, relayManager) private val fetcher = DesktopRelayFetcher(relayManager, account.pubKeyHex) private val metadataProvider = DesktopMetadataProvider(userMetadataCache) - private val dismissedStorage = ChessDismissedGamesStorage.create() + private val dismissedStorage = desktopChessDismissedGamesStore() // Shared business logic (creates its own ChessLobbyState internally) private val logic = From 1397bc6f819f165cafe184093372f353d19b89ae Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 19:50:58 +0000 Subject: [PATCH 07/50] refactor: move NUT-13 counters to DataStore, hoisting the reservation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 6, built as option 2: the counter reservation moves up to the suspend layer so secret derivation stays pure, rather than making SecretFactory.nextSecrets suspend and dragging RandomSecretFactory — a function that does nothing but generate randomness — along with it. SecretFactory splits in two: suspend fun reserve(keysetId, count): SecretReservation fun derive(reservation, count): List reserve() is the durability boundary and the only part that touches storage. derive() is pure: same reservation, same secrets, no suspension. nextSecrets() remains as the convenience that does both. CashuMintOperations.secretOutputsFor now calls them separately, so the write that stands between a crash and a reused counter is visible at the layer that can await it, instead of hidden inside derivation. RandomSecretFactory reserves nothing. DeterministicSecretFactory reserves nothing either when it has no seed yet, since it will fall back to random and burning counters for secrets never derived from them is waste. If the seed disappears between reserving and deriving, that batch falls back to random and the reserved range goes unused — harmless, because counters only ever move forward and an unused one is never replayed. Storage: CashuKeysetCounterStore becomes suspend, and the Android implementation moves to DataStore as DataStoreCashuCounterStore in commons, so desktop gets one too rather than having none. Read and write sit inside a single `edit`, which is what the previous @Synchronized was for: two concurrent mints cannot observe the same starting index. DataStore's edit suspends until its write lands and swaps the file atomically — the same guarantee SharedPreferences.edit(commit = true) gave, paid at a suspension rather than a blocked thread. Both older layers still feed in and neither can move a counter backwards: the per-account SharedPreferences file is copied in full on first read, inside the same atomic write that records the copy happened, so a crash cannot leave the marker set with the counters missing; and the older AccountSettings.cashuKeysetCounters map is still applied per keyset through seedIfMissing. 21 tests where there were none. This path decides whether ecash is spendable and had no coverage at all while it was on SharedPreferences. The ones that matter most: 25 concurrent reservations carve strictly disjoint ranges, a reservation survives a reopen, seedIfMissing never moves a counter backwards, the legacy migration carries every counter and does not rewind reservations made after it ran, and a host with no store wired fails loudly instead of answering 0. Not verified here: no mint was contacted. The durability and concurrency properties are covered by tests, but an end-to-end mint/melt against a real mint is still worth doing before release. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AXvKXakvup4inNFfAhhr4L --- .../amethyst/model/AccountSettings.kt | 9 +- .../model/nip60Cashu/CashuPreferences.kt | 136 ++++------ .../cli/stores/FileCashuKeysetCounterStore.kt | 4 +- .../commons/cashu/CashuKeysetCounterStore.kt | 13 +- .../cashu/DataStoreCashuCounterStore.kt | 101 +++++++ .../commons/cashu/ops/CashuWalletOps.kt | 4 +- .../cashu/DataStoreCashuCounterStoreTest.kt | 254 ++++++++++++++++++ .../nip60Cashu/mintApi/SecretFactory.kt | 101 +++++-- .../nip60Cashu/mintApi/SecretFactoryTest.kt | 150 +++++++++++ .../nip60Cashu/mintApi/CashuMintOperations.kt | 11 +- 10 files changed, 658 insertions(+), 125 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cashu/DataStoreCashuCounterStore.kt create mode 100644 commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cashu/DataStoreCashuCounterStoreTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/SecretFactoryTest.kt 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 e2613fbe36..0ae9158a53 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -1221,7 +1221,8 @@ class AccountSettings( * Reserve [count] consecutive NUT-13 counters for [keysetId], * returning the first one. Caller derives `(secret, r)` from * `(seed, keysetId, i)` for `i in [returned .. returned+count-1]`. - * Persisted synchronously before returning — see [CashuKeysetCounterStore]. + * Persisted before returning — see [CashuKeysetCounterStore]. Suspends + * because that write is what stands between a crash and a reused counter. * * One-time migration: when this keyset has a non-zero value in the * legacy [cashuKeysetCounters] map (from a build that persisted @@ -1229,7 +1230,7 @@ class AccountSettings( * still at zero, the legacy value is copied over before we reserve * so an upgrade doesn't reset the counter. */ - fun reserveCashuCounters( + suspend fun reserveCashuCounters( keysetId: String, count: Int, ): Long { @@ -1238,12 +1239,12 @@ class AccountSettings( } /** Inspect the next counter for [keysetId] without consuming any. */ - fun peekCashuCounter(keysetId: String): Long { + suspend fun peekCashuCounter(keysetId: String): Long { migrateLegacyCashuCounter(keysetId) return cashuCounters.peek(keysetId) } - private fun migrateLegacyCashuCounter(keysetId: String) { + private suspend fun migrateLegacyCashuCounter(keysetId: String) { val legacy = cashuKeysetCounters[keysetId] ?: return cashuCounters.seedIfMissing(keysetId, legacy) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuPreferences.kt index 02ce5266ac..08f3b96ada 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuPreferences.kt @@ -20,110 +20,64 @@ */ package com.vitorpamplona.amethyst.model.nip60Cashu -import android.annotation.SuppressLint import android.content.Context -import android.content.SharedPreferences -import androidx.core.content.edit +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.longPreferencesKey import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.cashu.CashuKeysetCounterStore +import com.vitorpamplona.amethyst.commons.cashu.DataStoreCashuCounterStore +import com.vitorpamplona.amethyst.commons.model.preferences.CopyOnceMigration +import com.vitorpamplona.quartz.utils.cache.LargeCache +import okio.Path.Companion.toOkioPath +import java.io.File /** - * Per-account Cashu state that needs durable, synchronous persistence — - * separate from [com.vitorpamplona.amethyst.model.AccountSettings] which - * batches writes through a 1-second debounced StateFlow. + * Android's per-account NUT-13 counter store: the shared + * [DataStoreCashuCounterStore] over a file in the app's data directory. * - * # Why a separate store + * Two older layers feed into it, and neither may move a counter backwards: * - * The NUT-13 keyset counter is the critical bit. Every mint / swap / - * melt reserves counter slots, derives deterministic blinded outputs at - * those slots, sends them to the mint, and the mint signs them. The - * mint persists which (keyset, blind_message) pairs it has ever signed; - * a second request to sign the same blind_message returns HTTP 400 - * "outputs already signed". So once the wallet hands a counter to the - * mint, the local counter advance MUST survive a crash — otherwise the - * next reservation pulls the same slot and the mint rejects it. + * - `cashu_prefs_` SharedPreferences, copied in full on first read by + * [CopyOnceMigration]. The copy happens inside the same atomic DataStore + * write that records it happened, so a crash cannot leave the marker set + * with the counters missing. It is a copy, not a move: the old file stays + * intact, so a rolled-back build still finds its counters. + * - `AccountSettings.cashuKeysetCounters`, an older in-settings map, still + * applied per keyset through `seedIfMissing` on every read. * - * The default settings save path debounces writes by 1000 ms, which is - * exactly the race window between "we asked the mint to sign" and "the - * mint replied". A crash inside that window (OOM, signer dialog dismiss, - * unexpected process death) loses the counter advance and makes the - * wallet unusable. This store writes via `commit = true` so each - * reservation is durable before the function returns. - * - * # Layout - * - * One SharedPreferences file per account, named - * `cashu_prefs_.xml`. Keys are flat: - * - `counter_` → Long, the next free NUT-13 counter - * - * Plain (non-encrypted) prefs because keyset counters aren't secret — - * they're not the seed, they don't carry value, and a leak would only - * tell an attacker how many proofs the wallet has minted at each - * keyset (a privacy signal at most). - * - * # Migration - * - * Older builds stored counters inside `AccountSettings.cashuKeysetCounters`. - * On first read of a given keyset, callers should pre-seed the store - * from the legacy map (one-time copy) so an upgrade doesn't reset the - * counter to zero. See `AccountSettings.migrateCashuCountersTo` for - * the helper. + * Losing a counter here means restarting a keyset at zero and reusing + * indices, which costs real ecash — so nothing on this path is best-effort. */ -class CashuPreferences( - private val prefs: SharedPreferences, -) : CashuKeysetCounterStore { - /** Inspect the next free counter for [keysetId] without advancing it. */ - @Synchronized - override fun peek(keysetId: String): Long = prefs.getLong(counterKey(keysetId), 0L) +object CashuPreferences { + private const val LEGACY_FILE_PREFIX = "cashu_prefs_" + + private val stores = LargeCache() /** - * Atomically reserve [count] consecutive NUT-13 counters for - * [keysetId] and return the first reserved index. The write is - * forced to disk with `commit = true` BEFORE returning — see the - * class header for why this isn't optional. + * Per-account instance, cached: DataStore refuses two live instances over + * one file, and a second instance would defeat the single-writer + * serialisation that `reserve` depends on. */ - @Synchronized - @SuppressLint("ApplySharedPref") - override fun reserve( - keysetId: String, - count: Int, - ): Long { - require(count > 0) { "Counter reservation must be positive" } - val current = peek(keysetId) - val next = current + count.toLong() - prefs.edit(commit = true) { putLong(counterKey(keysetId), next) } - return current - } - - /** - * Seed [keysetId]'s counter from a legacy value found in - * [AccountSettings.cashuKeysetCounters]. No-op when the store - * already has a value at or above [legacyValue] — never moves the - * counter backwards. Called once at wallet load to carry forward - * pre-migration state. - */ - @Synchronized - @SuppressLint("ApplySharedPref") - override fun seedIfMissing( - keysetId: String, - legacyValue: Long, - ) { - if (legacyValue <= 0L) return - val current = peek(keysetId) - if (current >= legacyValue) return - prefs.edit(commit = true) { putLong(counterKey(keysetId), legacyValue) } - } - - companion object { - private const val FILE_PREFIX = "cashu_prefs_" - - private fun counterKey(keysetId: String) = "counter_$keysetId" - - /** Per-account instance. [npub] keys the on-disk file so each account is isolated. */ - fun forAccount(npub: String): CashuPreferences { + fun forAccount(npub: String): CashuKeysetCounterStore = + stores.getOrCreate(npub) { val context = Amethyst.instance.appContext - val prefs = context.getSharedPreferences("$FILE_PREFIX$npub", Context.MODE_PRIVATE) - return CashuPreferences(prefs) + DataStoreCashuCounterStore( + PreferenceDataStoreFactory.createWithPath( + migrations = listOf(legacyMigration(context, npub)), + produceFile = { File(context.filesDir, "datastore/cashu_$npub.preferences_pb").toOkioPath() }, + ), + ) + } + + private fun legacyMigration( + context: Context, + npub: String, + ) = CopyOnceMigration("migrated.cashuCounters") { out -> + val legacy = context.getSharedPreferences("$LEGACY_FILE_PREFIX$npub", Context.MODE_PRIVATE) + legacy.all.forEach { (key, value) -> + if (key.startsWith(DataStoreCashuCounterStore.COUNTER_PREFIX) && value is Long) { + out[longPreferencesKey(key)] = value + } } } } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/FileCashuKeysetCounterStore.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/FileCashuKeysetCounterStore.kt index c561cf2b10..02b3317824 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/FileCashuKeysetCounterStore.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/FileCashuKeysetCounterStore.kt @@ -54,9 +54,9 @@ class FileCashuKeysetCounterStore( Persisted() } - override fun peek(keysetId: String): Long = synchronized(lock) { load().keyset_counters[keysetId] ?: 0L } + override suspend fun peek(keysetId: String): Long = synchronized(lock) { load().keyset_counters[keysetId] ?: 0L } - override fun reserve( + override suspend fun reserve( keysetId: String, count: Int, ): Long = diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cashu/CashuKeysetCounterStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cashu/CashuKeysetCounterStore.kt index 3974287c4d..64349e0b3f 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cashu/CashuKeysetCounterStore.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cashu/CashuKeysetCounterStore.kt @@ -27,7 +27,8 @@ package com.vitorpamplona.amethyst.commons.cashu * monotonically increasing counter. Reusing a counter makes the mint reply * `outputs already signed`, so every reservation MUST be persisted **before** * the blinded outputs hit the mint. Implementations therefore make - * [reserve] atomic and durable. + * [reserve] atomic and durable. They suspend because durable storage on + * every target this runs on is a suspending API. * * - Android backs this with `AccountSettings` / `CashuPreferences`. * - `amy` backs this with `~/.amy//cashu.json`. @@ -37,13 +38,13 @@ package com.vitorpamplona.amethyst.commons.cashu */ interface CashuKeysetCounterStore { /** The next counter for [keysetId] without advancing it (0 if unseen). */ - fun peek(keysetId: String): Long + suspend fun peek(keysetId: String): Long /** * Atomically reserve [count] consecutive counters for [keysetId] and * return the first reserved index. Persists before returning. */ - fun reserve( + suspend fun reserve( keysetId: String, count: Int, ): Long @@ -55,7 +56,7 @@ interface CashuKeysetCounterStore { * kept whatever the backing store; a host whose store can write the value in * one atomic op should override it. */ - fun seedIfMissing( + suspend fun seedIfMissing( keysetId: String, legacyValue: Long, ) { @@ -76,9 +77,9 @@ interface CashuKeysetCounterStore { object UnavailableCashuKeysetCounterStore : CashuKeysetCounterStore { private fun fail(): Nothing = error("No durable NUT-13 counter store is wired for this account; refusing to reuse counters.") - override fun peek(keysetId: String): Long = fail() + override suspend fun peek(keysetId: String): Long = fail() - override fun reserve( + override suspend fun reserve( keysetId: String, count: Int, ): Long = fail() diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cashu/DataStoreCashuCounterStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cashu/DataStoreCashuCounterStore.kt new file mode 100644 index 0000000000..3795cd3a7a --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cashu/DataStoreCashuCounterStore.kt @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cashu + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.longPreferencesKey +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.first +import okio.IOException + +/** + * DataStore-backed NUT-13 counter store, shared by every front end that has one. + * + * # Why the counters are not secret + * + * They are indices, not key material: they derive nothing without the wallet + * seed, carry no value, and a leak would at most reveal how many proofs the + * wallet has minted at each keyset. + * + * # Why every write is awaited + * + * Reusing a counter makes the mint reply `outputs already signed` and strands + * the proofs, so [reserve] must reach disk before the secrets derived from it + * reach the mint. DataStore's `edit` suspends until its write completes and + * swaps the file atomically — the same guarantee + * `SharedPreferences.edit(commit = true)` gave, paid at a suspension rather + * than a blocked thread. + * + * Read and write live inside one `edit`, so two concurrent mints cannot + * observe the same starting index; that is what the previous implementation's + * `@Synchronized` was for. + */ +class DataStoreCashuCounterStore( + private val store: DataStore, +) : CashuKeysetCounterStore { + companion object { + const val COUNTER_PREFIX = "counter_" + + fun counterKey(keysetId: String) = longPreferencesKey(COUNTER_PREFIX + keysetId) + } + + private suspend fun read(): Preferences = + store.data + .catch { e -> if (e is IOException) emit(emptyPreferences()) else throw e } + .first() + + override suspend fun peek(keysetId: String): Long = read()[counterKey(keysetId)] ?: 0L + + override suspend fun reserve( + keysetId: String, + count: Int, + ): Long { + require(count > 0) { "Counter reservation must be positive" } + var first = 0L + store.edit { prefs -> + val key = counterKey(keysetId) + first = prefs[key] ?: 0L + prefs[key] = first + count.toLong() + } + return first + } + + /** + * Carry a counter forward from an older store, never backwards. + * + * Writes the value directly in one atomic edit rather than advancing + * through [reserve], and compares inside that edit so a concurrent + * reservation cannot be undone by a stale read. + */ + override suspend fun seedIfMissing( + keysetId: String, + legacyValue: Long, + ) { + if (legacyValue <= 0L) return + store.edit { prefs -> + val key = counterKey(keysetId) + if ((prefs[key] ?: 0L) < legacyValue) prefs[key] = legacyValue + } + } +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cashu/ops/CashuWalletOps.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cashu/ops/CashuWalletOps.kt index 99def91987..d7e7dd65bc 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cashu/ops/CashuWalletOps.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cashu/ops/CashuWalletOps.kt @@ -119,14 +119,14 @@ class CashuWalletOps( * it. Used to rewind the restore window in [completeMintFromLightning] * recovery. Default is 0 (no persistent counter store). */ - private val peekCashuCounter: (keysetId: String) -> Long = { 0L }, + private val peekCashuCounter: suspend (keysetId: String) -> Long = { 0L }, /** * Atomically reserve [count] consecutive NUT-13 counters and * return the first reserved index. Used by the recovery path to * advance past slots the mint confirmed in use. Default is a * no-op for tests / random-only callers. */ - private val reserveCashuCounters: (keysetId: String, count: Int) -> Long = { _, _ -> 0L }, + private val reserveCashuCounters: suspend (keysetId: String, count: Int) -> Long = { _, _ -> 0L }, ) { private val opsCache = ConcurrentHashMap() diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cashu/DataStoreCashuCounterStoreTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cashu/DataStoreCashuCounterStoreTest.kt new file mode 100644 index 0000000000..e1485b0698 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cashu/DataStoreCashuCounterStoreTest.kt @@ -0,0 +1,254 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cashu + +import androidx.datastore.core.DataMigration +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.longPreferencesKey +import com.vitorpamplona.amethyst.commons.model.preferences.CopyOnceMigration +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.job +import kotlinx.coroutines.test.runTest +import okio.Path.Companion.toOkioPath +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +/** + * NUT-13 counters decide whether ecash is spendable. + * + * A counter handed out twice under the same (seed, keyset) derives the same + * blinded secret twice; the mint answers `outputs already signed` and the + * proofs are stranded. These tests exist because that path had no coverage at + * all while it was backed by SharedPreferences. + */ +class DataStoreCashuCounterStoreTest { + @get:Rule + val folder = TemporaryFolder() + + private var seq = 0 + + /** + * Closes a store's scope and waits for it. + * + * DataStore refuses two live instances over one file and only releases it + * once the owning job has actually finished, so a bare cancel() races the + * next open. + */ + private suspend fun CoroutineScope.release() { + coroutineContext.job.cancelAndJoin() + } + + private fun raw( + file: File = File(folder.root, "cashu_${seq++}.preferences_pb"), + scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()), + migrations: List> = emptyList(), + ): DataStore = + PreferenceDataStoreFactory.createWithPath( + scope = scope, + migrations = migrations, + produceFile = { file.toOkioPath() }, + ) + + @Test + fun anUnseenKeysetStartsAtZero() = + runTest { + assertEquals(0L, DataStoreCashuCounterStore(raw()).peek("keyset1")) + } + + @Test + fun peekDoesNotAdvance() = + runTest { + val store = DataStoreCashuCounterStore(raw()) + + store.peek("keyset1") + store.peek("keyset1") + + assertEquals(0L, store.reserve("keyset1", 1)) + } + + @Test + fun reserveReturnsTheFirstIndexAndAdvancesByCount() = + runTest { + val store = DataStoreCashuCounterStore(raw()) + + assertEquals(0L, store.reserve("keyset1", 3)) + assertEquals(3L, store.peek("keyset1")) + assertEquals(3L, store.reserve("keyset1", 2)) + assertEquals(5L, store.peek("keyset1")) + } + + @Test + fun keysetsAdvanceIndependently() = + runTest { + val store = DataStoreCashuCounterStore(raw()) + + store.reserve("keyset1", 5) + + assertEquals(0L, store.reserve("keyset2", 1)) + } + + @Test + fun aNonPositiveReservationIsRejected() = + runTest { + val store = DataStoreCashuCounterStore(raw()) + + // Not assertThrows: a nested runTest would wrap the failure. + val thrown = runCatching { store.reserve("keyset1", 0) }.exceptionOrNull() + + assertTrue("expected IllegalArgumentException, got $thrown", thrown is IllegalArgumentException) + } + + /** + * The property everything else rests on: no index is ever handed out twice. + * Concurrent reservations must carve up disjoint ranges. + */ + @Test + fun concurrentReservationsNeverOverlap() = + runTest { + val store = DataStoreCashuCounterStore(raw()) + val batch = 4 + val workers = 25 + + val firsts = + (1..workers) + .map { async(Dispatchers.IO) { store.reserve("keyset1", batch) } } + .awaitAll() + + val handedOut = firsts.flatMap { first -> (0 until batch).map { first + it } } + assertEquals("every index handed out exactly once", handedOut.size, handedOut.toSet().size) + assertEquals("the counter accounts for all of them", (workers * batch).toLong(), store.peek("keyset1")) + } + + /** A reserved counter must survive the process that reserved it. */ + @Test + fun reservationsSurviveAReopen() = + runTest { + val file = File(folder.root, "persist.preferences_pb") + + val firstScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + DataStoreCashuCounterStore(raw(file, firstScope)).reserve("keyset1", 7) + firstScope.release() + + val secondScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + assertEquals(7L, DataStoreCashuCounterStore(raw(file, secondScope)).peek("keyset1")) + secondScope.release() + } + + // ── seeding from older stores ───────────────────────────────────── + + @Test + fun seedIfMissingCarriesALegacyValueForward() = + runTest { + val store = DataStoreCashuCounterStore(raw()) + + store.seedIfMissing("keyset1", 42L) + + assertEquals(42L, store.peek("keyset1")) + } + + /** Never backwards: a legacy value below the current one must be ignored. */ + @Test + fun seedIfMissingNeverMovesACounterBackwards() = + runTest { + val store = DataStoreCashuCounterStore(raw()) + store.reserve("keyset1", 100) + + store.seedIfMissing("keyset1", 5L) + + assertEquals(100L, store.peek("keyset1")) + } + + @Test + fun seedIfMissingIgnoresNonPositiveValues() = + runTest { + val store = DataStoreCashuCounterStore(raw()) + store.reserve("keyset1", 3) + + store.seedIfMissing("keyset1", 0L) + store.seedIfMissing("keyset1", -1L) + + assertEquals(3L, store.peek("keyset1")) + } + + /** + * The SharedPreferences -> DataStore migration. A counter lost here + * restarts a keyset at zero and reuses every index it already spent. + */ + @Test + fun theLegacyMigrationCarriesEveryCounter() = + runTest { + val legacy = + mapOf( + "counter_keysetA" to 17L, + "counter_keysetB" to 4L, + ) + val migration = + CopyOnceMigration("migrated.cashuCounters") { out -> + legacy.forEach { (key, value) -> out[longPreferencesKey(key)] = value } + } + + val store = DataStoreCashuCounterStore(raw(migrations = listOf(migration))) + + assertEquals(17L, store.peek("keysetA")) + assertEquals(4L, store.peek("keysetB")) + assertEquals("the next reservation continues, never replays", 17L, store.reserve("keysetA", 1)) + } + + /** The migration must not re-run and rewind counters spent since it ran. */ + @Test + fun theLegacyMigrationDoesNotRewindLaterReservations() = + runTest { + val file = File(folder.root, "once.preferences_pb") + val migration = { CopyOnceMigration("migrated.cashuCounters") { out -> out[longPreferencesKey("counter_keysetA")] = 10L } } + + val firstScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + DataStoreCashuCounterStore(raw(file, firstScope, listOf(migration()))).reserve("keysetA", 5) + firstScope.release() + + val secondScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + val reopened = DataStoreCashuCounterStore(raw(file, secondScope, listOf(migration()))) + assertEquals(15L, reopened.peek("keysetA")) + assertTrue("a later reservation is past everything spent", reopened.reserve("keysetA", 1) >= 15L) + secondScope.release() + } + + /** A host that wired no store must fail loudly rather than answer 0. */ + @Test + fun theUnavailableStoreRefusesToAnswer() = + runTest { + val onPeek = runCatching { UnavailableCashuKeysetCounterStore.peek("keyset1") }.exceptionOrNull() + val onReserve = runCatching { UnavailableCashuKeysetCounterStore.reserve("keyset1", 1) }.exceptionOrNull() + + assertTrue("peek must refuse, got $onPeek", onPeek is IllegalStateException) + assertTrue("reserve must refuse, got $onReserve", onReserve is IllegalStateException) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/SecretFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/SecretFactory.kt index 51d662f82f..2da95e82fc 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/SecretFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/SecretFactory.kt @@ -44,6 +44,18 @@ data class DerivedSecret( override fun hashCode(): Int = 31 * secretHex.hashCode() + blindingFactor.contentHashCode() } +/** + * Where a batch of deterministic secrets starts. + * + * [firstCounter] is null when the secrets will be random — either because the + * factory is [RandomSecretFactory], or because a [DeterministicSecretFactory] + * had no seed available when it reserved. + */ +data class SecretReservation( + val keysetId: String, + val firstCounter: Long?, +) + /** * Strategy for producing the (secret, r) pairs that go into BDHKE blind * messages. Two impls today: @@ -61,21 +73,48 @@ data class DerivedSecret( */ interface SecretFactory { /** - * Mint [count] (secret, r) pairs for use on the specified keyset. + * Reserve whatever durable state the next [count] secrets need, and return + * where they start. * - * Batched on purpose: the deterministic implementation reserves a - * contiguous counter range via a single atomic critical section on - * `AccountSettings.reserveCashuCounters`. Calling one-at-a-time - * inside `splitAmounts(amount).map { ... }` would take the lock N - * times per mint — wasteful for both contention and disk writes. + * Suspends because that is the durability boundary: a NUT-13 counter MUST + * reach disk before any secret derived from it reaches a mint, or a crash + * mid-mint replays the counter on the next launch and the mint answers + * `outputs already signed`. + * + * Separate from [derive] so that derivation stays pure and synchronous. + * Reserving is the only part that touches storage, and only a + * deterministic factory does so at all. */ - fun nextSecrets( + suspend fun reserve( keysetId: String, count: Int, + ): SecretReservation + + /** + * Derive [count] (secret, r) pairs from an already-reserved position. + * + * Pure: no storage, no suspension, same output for the same reservation. + */ + fun derive( + reservation: SecretReservation, + count: Int, ): List + /** + * Reserve and derive in one step. + * + * Batched on purpose: the deterministic implementation reserves a + * contiguous counter range in a single atomic write. Calling one-at-a-time + * inside `splitAmounts(amount).map { ... }` would take the lock N times per + * mint — wasteful for both contention and disk writes. + */ + suspend fun nextSecrets( + keysetId: String, + count: Int, + ): List = derive(reserve(keysetId, count), count) + /** Convenience for ops that need a single output. */ - fun nextSecret(keysetId: String): DerivedSecret = nextSecrets(keysetId, 1).first() + suspend fun nextSecret(keysetId: String): DerivedSecret = nextSecrets(keysetId, 1).first() } /** @@ -85,9 +124,15 @@ interface SecretFactory { * pre-dates the NUT-13 wiring). */ object RandomSecretFactory : SecretFactory { - override fun nextSecrets( + /** Nothing to reserve: random secrets keep no durable state. */ + override suspend fun reserve( keysetId: String, count: Int, + ): SecretReservation = SecretReservation(keysetId, null) + + override fun derive( + reservation: SecretReservation, + count: Int, ): List { require(count > 0) { "Must request at least one secret" } return List(count) { @@ -126,29 +171,51 @@ class DeterministicSecretFactory( private val seedProvider: () -> ByteArray?, /** * Atomically reserves [count] consecutive counters for a keyset and - * returns the FIRST one — the factory then derives at indices + * returns the FIRST one — derivation then runs at indices * `[returned .. returned+count)`. Persisting in one shot avoids the - * lock-N-times-per-mint waste of the old per-secret API. + * lock-N-times-per-mint waste of a per-secret API. * + * Suspends: it must reach disk before the secrets are used. * `AccountSettings.reserveCashuCounters(keysetId, count)` is the * canonical implementation. */ - private val reserveCounters: (keysetId: String, count: Int) -> Long, + private val reserveCounters: suspend (keysetId: String, count: Int) -> Long, private val fallback: SecretFactory = RandomSecretFactory, ) : SecretFactory { - override fun nextSecrets( + /** + * With no seed yet — the wallet has not decrypted its kind:17375 — this + * reserves nothing and reports a random batch, so counters are not burned + * for secrets that will not be derived from them. + */ + override suspend fun reserve( keysetId: String, count: Int, + ): SecretReservation { + require(count > 0) { "Must request at least one secret" } + seedProvider() ?: return SecretReservation(keysetId, null) + return SecretReservation(keysetId, reserveCounters(keysetId, count)) + } + + override fun derive( + reservation: SecretReservation, + count: Int, ): List { require(count > 0) { "Must request at least one secret" } - val seed = seedProvider() ?: return fallback.nextSecrets(keysetId, count) - val first = reserveCounters(keysetId, count) + val first = reservation.firstCounter ?: return fallback.derive(reservation, count) + + // Re-read rather than capturing the seed in the reservation, which + // would carry it through a public data class. If the seed vanished + // between reserving and deriving, this batch falls back to random and + // the reserved counters go unused — harmless, because counters only + // ever move forward and an unused one is never replayed. + val seed = seedProvider() ?: return fallback.derive(SecretReservation(reservation.keysetId, null), count) + return List(count) { offset -> val counter = first + offset // CashuDeterministic.secretBytes returns the raw 32 bytes; // the hex form is what BDHKE/proof storage actually use. - val secretHex = CashuDeterministic.secretBytes(seed, keysetId, counter).toHexKey() - val r = CashuDeterministic.blindingFactor(seed, keysetId, counter) + val secretHex = CashuDeterministic.secretBytes(seed, reservation.keysetId, counter).toHexKey() + val r = CashuDeterministic.blindingFactor(seed, reservation.keysetId, counter) DerivedSecret(secretHex, r) } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/SecretFactoryTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/SecretFactoryTest.kt new file mode 100644 index 0000000000..1d6d88c318 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/SecretFactoryTest.kt @@ -0,0 +1,150 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip60Cashu.mintApi + +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The reserve/derive split. + * + * Reserving is the durability boundary — it suspends and must persist a NUT-13 + * counter before any secret derived from it reaches a mint. Deriving is pure. + * Keeping them apart is what lets [RandomSecretFactory] stay free of storage + * and lets the mint layer see where the write happens. + */ +class SecretFactoryTest { + private val seed = ByteArray(64) { it.toByte() } + + // Keyset ids are hex by NUT-02, and NUT-13 derivation decodes them, so a + // placeholder like "keyset1" is rejected before any secret is produced. + private val keysetA = "009a1f293253e41e" + private val keysetB = "00ad268c4d1f5826" + + @Test + fun randomFactoryReservesNothing() = + runTest { + val reservation = RandomSecretFactory.reserve(keysetA, 3) + + assertNull(reservation.firstCounter, "random secrets keep no durable state") + assertEquals(keysetA, reservation.keysetId) + } + + @Test + fun randomFactoryDerivesDistinctSecrets() = + runTest { + val secrets = RandomSecretFactory.nextSecrets(keysetA, 4) + + assertEquals(4, secrets.size) + assertEquals(4, secrets.map { it.secretHex }.toSet().size, "random secrets must not repeat") + } + + @Test + fun deterministicFactoryReservesTheWholeBatchOnce() = + runTest { + val calls = mutableListOf>() + val factory = + DeterministicSecretFactory( + seedProvider = { seed }, + reserveCounters = { keysetId, count -> + calls.add(keysetId to count) + 10L + }, + ) + + val reservation = factory.reserve(keysetA, 5) + + assertEquals(listOf(keysetA to 5), calls, "one reservation for the batch, not one per secret") + assertEquals(10L, reservation.firstCounter) + } + + /** + * With no seed the factory falls back to random, so reserving would burn + * counters for secrets that are never derived from them. + */ + @Test + fun noSeedMeansNoCountersBurned() = + runTest { + var reserved = false + val factory = + DeterministicSecretFactory( + seedProvider = { null }, + reserveCounters = { _, _ -> + reserved = true + 0L + }, + ) + + val reservation = factory.reserve(keysetA, 3) + + assertTrue(!reserved, "the counter store must not be touched") + assertNull(reservation.firstCounter) + } + + /** Derivation is pure: the same reservation yields the same secrets. */ + @Test + fun deriveIsDeterministicForAReservation() = + runTest { + val factory = DeterministicSecretFactory(seedProvider = { seed }, reserveCounters = { _, _ -> 7L }) + val reservation = SecretReservation(keysetA, 7L) + + assertEquals(factory.derive(reservation, 3), factory.derive(reservation, 3)) + } + + /** Consecutive counters must give different secrets, or a reused index would be harmless — it is not. */ + @Test + fun eachCounterInABatchDerivesADifferentSecret() = + runTest { + val factory = DeterministicSecretFactory(seedProvider = { seed }, reserveCounters = { _, _ -> 0L }) + + val secrets = factory.derive(SecretReservation(keysetA, 0L), 4) + + assertEquals(4, secrets.map { it.secretHex }.toSet().size) + } + + /** NUT-13 derivation is keyset-aware: the same counter on another keyset is a different secret. */ + @Test + fun theSameCounterOnAnotherKeysetDerivesADifferentSecret() = + runTest { + val factory = DeterministicSecretFactory(seedProvider = { seed }, reserveCounters = { _, _ -> 0L }) + + val onA = factory.derive(SecretReservation(keysetA, 0L), 1).first() + val onB = factory.derive(SecretReservation(keysetB, 0L), 1).first() + + assertNotEquals(onA.secretHex, onB.secretHex) + } + + /** A reservation carrying no counter derives random secrets, whatever the factory. */ + @Test + fun aCounterlessReservationFallsBackToRandom() = + runTest { + val factory = DeterministicSecretFactory(seedProvider = { seed }, reserveCounters = { _, _ -> 0L }) + + val first = factory.derive(SecretReservation(keysetA, null), 2) + val second = factory.derive(SecretReservation(keysetA, null), 2) + + assertNotEquals(first.map { it.secretHex }, second.map { it.secretHex }, "random, so not reproducible") + } +} diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/CashuMintOperations.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/CashuMintOperations.kt index bedae4d049..024413a720 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/CashuMintOperations.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/CashuMintOperations.kt @@ -723,7 +723,7 @@ class CashuMintOperations( */ private suspend fun fetchInputFeePpkByKeyset(): Map = client.keysets().keysets.associate { it.id to (it.inputFeePpk ?: 0L) } - private fun createBlindedOutputs( + private suspend fun createBlindedOutputs( amount: Long, keyset: KeysetDto, ): List = secretOutputsFor(splitAmounts(amount), keyset) @@ -735,7 +735,7 @@ class CashuMintOperations( * [SecretFactory.nextSecret] per amount instead would take the * @Synchronized lock + dirty `AccountSettings.saveable` N times. */ - private fun secretOutputsFor( + private suspend fun secretOutputsFor( amounts: List, keyset: KeysetDto, ): List { @@ -744,7 +744,12 @@ class CashuMintOperations( // [secretFactory] decides whether those bytes are pure-random or // NUT-13-derived from a wallet seed; either way the on-wire shape // is identical so the mint can't tell which scheme we're using. - val derived = secretFactory.nextSecrets(keyset.id, amounts.size) + // Two steps on purpose. reserve() suspends and persists the NUT-13 + // counter; derive() is pure. Keeping them apart means the durability + // boundary is visible here, at the only layer that can await it, rather + // than hidden inside secret derivation. + val reservation = secretFactory.reserve(keyset.id, amounts.size) + val derived = secretFactory.derive(reservation, amounts.size) return amounts.mapIndexed { i, amount -> val pair = derived[i] val bTick = Bdhke.blind(pair.secretHex.encodeToByteArray(), pair.blindingFactor) From d2d1d813d1c5ab1e97ceb1b623565b5dafc5c09a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 20:14:04 +0000 Subject: [PATCH 08/50] refactor: drop androidx.security from commons' key storage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 5a. SecureKeyStorage's Android actual was EncryptedSharedPreferences from androidx.security.crypto — the same deprecated library, and the same MasterKey.DEFAULT_MASTER_KEY_ALIAS, that EncryptedStorage uses. It now runs on EncryptedDataStore sealed with SecretEncryption, which talks to the AndroidKeyStore directly, so the key still never enters app memory and the library leaves this module. Verified: androidx.security no longer appears on commons' androidCompileClasspath. Worth recording, because it corrects the plan this series was working to: migrating the Android app's private keys *into* SecureKeyStorage would have gained nothing. Both sides were the same deprecated implementation under different filenames. The OS-keychain backing its KDoc describes is the JVM actual, which desktop uses; Android never had it. No migration, and none needed: SecureKeyStorage has 42 references in desktopApp and none in amethyst, so `amethyst_secure_keys` has never been written on an Android install. Were that to change, a migration would have to land first — the class says so. Also fixes a hazard this move would otherwise have introduced. EncryptedDataStore.get() flattens a read failure into null, which is fine for settings but wrong for getPrivateKeyOrThrow — the probe whose whole purpose is telling "no key" apart from "backend failed", used before creating a replacement key. Reading a merely unreadable store as absent there overwrites a live key. getOrThrow() now propagates instead, and a test truncates a store to prove the two reads diverge on it. Not verified here: the Android actual itself. It needs an instrumented test for the real AndroidKeyStore, and this environment has no device or emulator (commons has no Robolectric either). The contract underneath it — EncryptedDataStore over SecretEncryption — is covered by 14 jvmTest cases against the JVM actual. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AXvKXakvup4inNFfAhhr4L --- commons/build.gradle.kts | 4 +- .../commons/keystorage/SecureKeyStorage.kt | 146 ++++++++---------- .../model/preferences/EncryptedDataStore.kt | 18 +++ .../preferences/EncryptedDataStoreTest.kt | 45 ++++++ 4 files changed, 129 insertions(+), 84 deletions(-) diff --git a/commons/build.gradle.kts b/commons/build.gradle.kts index 4d6a78e368..86c5c10c33 100644 --- a/commons/build.gradle.kts +++ b/commons/build.gradle.kts @@ -167,8 +167,8 @@ kotlin { // Compose UI artifacts before the :commonsUI split. implementation(libs.androidx.core.ktx) - // Secure key storage via Android Keystore - implementation(libs.androidx.security.crypto.ktx) + // Secure key storage talks to the AndroidKeyStore directly through + // SecretEncryption; androidx.security.crypto is gone from this module. } } diff --git a/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorage.kt b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorage.kt index dbb4efb401..f3b08ae257 100644 --- a/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorage.kt +++ b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorage.kt @@ -21,42 +21,48 @@ package com.vitorpamplona.amethyst.commons.keystorage import android.content.Context -import androidx.core.content.edit -import androidx.security.crypto.EncryptedSharedPreferences -import androidx.security.crypto.MasterKey +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.stringPreferencesKey +import com.vitorpamplona.amethyst.commons.model.preferences.EncryptedDataStore +import com.vitorpamplona.amethyst.commons.model.preferences.SecretEncryption +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext +import kotlinx.coroutines.SupervisorJob +import okio.Path.Companion.toOkioPath +import java.io.File /** - * Android implementation of SecureKeyStorage using EncryptedSharedPreferences - * backed by Android Keystore (AES-256-GCM, hardware-backed when available). + * Android implementation of [SecureKeyStorage]: an encrypted DataStore whose + * values are sealed with a key held in the AndroidKeyStore. * - * ## Security Features + * ## Why not EncryptedSharedPreferences * - * - **Hardware Security:** Uses Android Keystore (hardware-backed on supported devices with StrongBox) - * - **Encryption:** AES-256-GCM for both keys and values - * - **Key Derivation:** AES-256-SIV for preference keys, AES-256-GCM for values - * - **Application Context:** Uses applicationContext to prevent memory leaks - * - **Auto-backup Disabled:** EncryptedSharedPreferences automatically excluded from cloud backups + * This used to be `androidx.security.crypto`, which Google deprecated with no + * drop-in successor. [SecretEncryption] talks to the AndroidKeyStore directly — + * AES-256-GCM, StrongBox-backed where the device offers it — so the key still + * never enters app memory, and the library goes away. * - * **Note:** While the encryption keys are protected by hardware security modules (when available), - * the decrypted private keys returned by [getPrivateKey] are still subject to the String memory - * limitation described in [SecureKeyStorage]. + * Nothing is migrated from the old `amethyst_secure_keys` file because nothing + * ever wrote to it: this class is used by the desktop app, and the Android app + * has its own key storage in LocalPreferences. Were that to change, a migration + * would have to come first. + * + * ## Security note + * + * Only values are encrypted; the key names are not. That reveals which npubs + * this installation holds keys for, but not the keys themselves — the same + * trade-off the rest of the encrypted stores make. + * + * The String memory limitation described on [SecureKeyStorage] still applies: + * a decrypted private key cannot be zeroed from a JVM String. */ actual class SecureKeyStorage private actual constructor() { actual companion object { - private const val PREFS_NAME = "amethyst_secure_keys" + private const val STORE_FILE = "datastore/secure_keys.preferences_pb" private const val KEY_PREFIX = "privkey_" private lateinit var appContext: Context - /** - * Creates a SecureKeyStorage instance for Android. - * - * @param context Android Context (will use applicationContext to avoid leaks) - * @return SecureKeyStorage instance - * @throws IllegalArgumentException if context is null or not a valid Context - */ actual fun create(context: Any?): SecureKeyStorage { require(context is Context) { "Android requires a valid Context" } appContext = context.applicationContext @@ -64,85 +70,61 @@ actual class SecureKeyStorage private actual constructor() { } } - // androidx.security.crypto is deprecated with no drop-in successor; migrating the - // on-disk key store is a separate, security-sensitive effort. - @Suppress("DEPRECATION") - private val masterKey: MasterKey by lazy { - MasterKey - .Builder(appContext, MasterKey.DEFAULT_MASTER_KEY_ALIAS) - .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) - .build() - } + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - @Suppress("DEPRECATION") - private val encryptedPrefs by lazy { - EncryptedSharedPreferences.create( - appContext, - PREFS_NAME, - masterKey, - EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, - EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + private val store by lazy { + EncryptedDataStore( + PreferenceDataStoreFactory.createWithPath( + scope = scope, + produceFile = { File(appContext.filesDir, STORE_FILE).toOkioPath() }, + ), + SecretEncryption(), + scope = scope, ) } + private fun keyFor(npub: String) = stringPreferencesKey(KEY_PREFIX + npub) + actual suspend fun savePrivateKey( npub: String, privKeyHex: String, ) { - withContext(Dispatchers.IO) { - try { - encryptedPrefs.edit { putString(KEY_PREFIX + npub, privKeyHex) } - } catch (e: Exception) { - throw SecureStorageException("Failed to save private key", e) - } + try { + store.save(keyFor(npub), privKeyHex) + } catch (e: Exception) { + throw SecureStorageException("Failed to save private key", e) } } actual suspend fun getPrivateKey(npub: String): String? = - withContext(Dispatchers.IO) { - try { - encryptedPrefs.getString(KEY_PREFIX + npub, null) - } catch (e: Exception) { - throw SecureStorageException("Failed to retrieve private key", e) - } + try { + store.get(keyFor(npub)) + } catch (e: Exception) { + throw SecureStorageException("Failed to retrieve private key", e) } /** - * Android backend: EncryptedSharedPreferences.contains + getString has no - * ambiguous-error state comparable to macOS Keychain user-cancel/deny, so - * "key not present" and "key present" are the only two null outcomes. - * Any thrown exception is a genuine failure and propagates. + * Unlike [getPrivateKey], this reads through [EncryptedDataStore.getOrThrow] + * so a store that cannot be read raises instead of reporting the key as + * absent. That distinction is the whole point of this method: callers use + * it to decide whether a key needs creating, and treating a transient read + * failure as "no key here" would overwrite a live one. */ actual suspend fun getPrivateKeyOrThrow(npub: String): String? = - withContext(Dispatchers.IO) { - try { - val key = KEY_PREFIX + npub - if (!encryptedPrefs.contains(key)) { - null - } else { - encryptedPrefs.getString(key, null) - } - } catch (e: Exception) { - throw SecureStorageException("Failed to retrieve private key", e) - } + try { + store.getOrThrow(keyFor(npub)) + } catch (e: Exception) { + throw SecureStorageException("Failed to retrieve private key", e) } actual suspend fun deletePrivateKey(npub: String): Boolean = - withContext(Dispatchers.IO) { - try { - val key = KEY_PREFIX + npub - val existed = encryptedPrefs.contains(key) - if (existed) { - encryptedPrefs.edit { remove(key) } - } - existed - } catch (e: Exception) { - throw SecureStorageException("Failed to delete private key", e) - } + try { + val existed = store.get(keyFor(npub)) != null + if (existed) store.remove(keyFor(npub)) + existed + } catch (e: Exception) { + throw SecureStorageException("Failed to delete private key", e) } - actual suspend fun hasPrivateKey(npub: String): Boolean = - withContext(Dispatchers.IO) { - encryptedPrefs.contains(KEY_PREFIX + npub) - } + actual suspend fun hasPrivateKey(npub: String): Boolean = getPrivateKey(npub) != null } diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/EncryptedDataStore.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/EncryptedDataStore.kt index 710102f222..01b26082bb 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/EncryptedDataStore.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/EncryptedDataStore.kt @@ -26,6 +26,7 @@ import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.emptyPreferences import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.map import okio.IOException @@ -58,6 +59,13 @@ class EncryptedDataStore( store.edit { prefs -> prefs[key] = encrypt(value) } } + /** + * The value, or null when the key is absent **or unreadable**. + * + * A read error is reported as absence, which is what most callers want. + * Anything that must not mistake a failure for an empty store — a probe + * deciding whether to create a replacement key, say — needs [getOrThrow]. + */ suspend fun get(key: Preferences.Key): String? = store.data .catch { e -> @@ -66,6 +74,16 @@ class EncryptedDataStore( ?.get(key) ?.let { decrypt(it) } + /** + * The value, or null only when the key is genuinely absent. + * + * Unlike [get], a failure to read propagates rather than being flattened + * into null. The difference matters wherever null means "nothing was ever + * stored" and the caller acts on that — overwriting a key that is present + * but temporarily unreadable is not recoverable. + */ + suspend fun getOrThrow(key: Preferences.Key): String? = store.data.first()[key]?.let { decrypt(it) } + fun getProperty( key: Preferences.Key, parser: (String) -> T, diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/EncryptedDataStoreTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/EncryptedDataStoreTest.kt index 68eddfbf94..05f9e231e1 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/EncryptedDataStoreTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/EncryptedDataStoreTest.kt @@ -25,10 +25,12 @@ import androidx.datastore.preferences.core.stringPreferencesKey import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel import kotlinx.coroutines.test.runTest import okio.Path.Companion.toOkioPath import org.junit.Assert.assertEquals import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test import org.junit.rules.TemporaryFolder @@ -131,4 +133,47 @@ class EncryptedDataStoreTest { assertNull(subject.get(key)) } + + /** + * [EncryptedDataStore.get] flattens a read failure into null; + * [EncryptedDataStore.getOrThrow] does not. + * + * The difference guards a live key: a probe that decides whether to create + * one must not read "absent" from a store it merely failed to open, or it + * overwrites what is already there. + */ + @Test + fun getSwallowsAReadFailureButGetOrThrowDoesNot() = + runTest { + val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + val n = seq++ + val dataFile = File(folder.root, "corrupt_$n.preferences_pb") + val keyFile = File(folder.root, "corrupt_$n.key") + val subject = + EncryptedDataStore( + PreferenceDataStoreFactory.createWithPath(scope = scope, produceFile = { dataFile.toOkioPath() }), + SecretEncryption(keyFile), + scope = scope, + ) + subject.save(key, "a real value") + scope.cancel() + + // Truncate the store so opening it fails rather than reading empty. + dataFile.writeBytes(byteArrayOf(0x01, 0x02, 0x03)) + + val readScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + val reopened = + EncryptedDataStore( + PreferenceDataStoreFactory.createWithPath(scope = readScope, produceFile = { dataFile.toOkioPath() }), + SecretEncryption(keyFile), + scope = readScope, + ) + + assertNull("get() reports the unreadable store as absent", reopened.get(key)) + assertTrue( + "getOrThrow() must not call it absent", + runCatching { reopened.getOrThrow(key) }.isFailure, + ) + readScope.cancel() + } } From 9514548cfeefb9f558dc2b281e01f2fbdd0f59a9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 20:33:28 +0000 Subject: [PATCH 09/50] feat: dual-store account private keys, migrating off androidx.security MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 5b. Account private keys now live in SecureKeyStorage — an encrypted DataStore sealed by the AndroidKeyStore since 5a — while the legacy `secret_keeper_` EncryptedSharedPreferences file keeps its copy. Both stores are written on every save and reads prefer the new one with the old as fallback, so: - an install that has never run this build finds its key and is migrated the first time the account loads; - a build rolled back to reading only the legacy store finds every key, including ones added after the upgrade; - a new store that cannot be read — a wiped AndroidKeyStore after a device credential reset — falls back instead of presenting the account as having no key, which would silently demote a signing account to read-only. Nothing is deleted. Dropping the legacy file is a later release's job, once this has shipped and held; doing both at once leaves no way back. That means androidx.security.crypto stays declared in :amethyst for now — 5b alone cannot remove it. Three details that decide whether this is safe: - The mirror matches the legacy write exactly, including the case that is easy to miss: with no external signer and no private key in hand, the legacy store leaves the stored key alone rather than clearing it. Deleting there would drop the key on every save from a session that never decrypted it. - Reads go through getPrivateKeyOrThrow, so an unreadable store raises instead of reporting absence, and a failed read never triggers a migration into the store that just failed. - An npub is derived from its private key, so the two stores cannot legitimately disagree. A mismatch means corruption and resolves in favour of the older, proven store. The logic is behind a PrivateKeyVault interface rather than calling SecureKeyStorage directly, because no unit test can reach an AndroidKeyStore. 13 tests cover each branch: legacy-only migrates, already-migrated is not rewritten, an unreadable store falls back and is not written to, a mismatch prefers legacy, a failed migration still returns the key, and a save with no key in hand leaves the stored one alone. Not verified here: the AndroidKeyStore path itself, which needs a device. The decision logic is tested against a fake vault; the store underneath it is not. A real sign-in, restart and account-switch on a device should confirm the migration before the legacy file is dropped. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AXvKXakvup4inNFfAhhr4L --- .../vitorpamplona/amethyst/AccountKeyStore.kt | 181 +++++++++++++++ .../amethyst/LocalPreferences.kt | 18 +- .../amethyst/AccountKeyStoreTest.kt | 216 ++++++++++++++++++ 3 files changed, 414 insertions(+), 1 deletion(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/AccountKeyStore.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/AccountKeyStoreTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AccountKeyStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AccountKeyStore.kt new file mode 100644 index 0000000000..67dabc7e34 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AccountKeyStore.kt @@ -0,0 +1,181 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst + +import com.vitorpamplona.amethyst.commons.keystorage.SecureKeyStorage +import com.vitorpamplona.quartz.utils.Log + +/** + * The narrow slice of a key store this needs. + * + * An interface rather than [SecureKeyStorage] directly so the decision logic + * below — which store wins, what happens when one fails — is testable without + * an AndroidKeyStore, which no unit test can reach. + */ +interface PrivateKeyVault { + /** The stored key, or null only when genuinely absent. Throws when the store cannot be read. */ + suspend fun get(npub: String): String? + + suspend fun save( + npub: String, + privKeyHex: String, + ) + + suspend fun delete(npub: String) +} + +/** [PrivateKeyVault] over the real [SecureKeyStorage]. */ +class SecureKeyStorageVault( + private val storage: SecureKeyStorage, +) : PrivateKeyVault { + override suspend fun get(npub: String): String? = storage.getPrivateKeyOrThrow(npub) + + override suspend fun save( + npub: String, + privKeyHex: String, + ) = storage.savePrivateKey(npub, privKeyHex) + + override suspend fun delete(npub: String) { + storage.deletePrivateKey(npub) + } +} + +/** + * Moves account private keys off `androidx.security.crypto` without ever + * leaving one only in a place the running build cannot read. + * + * The old home is `secret_keeper_`, an EncryptedSharedPreferences file. + * The new one is [SecureKeyStorage], which on Android is now an encrypted + * DataStore sealed by the AndroidKeyStore directly. Both are written on every + * save, and reads prefer the new store but fall back to the old one, so: + * + * - an install that has never run this build still finds its key, and is + * migrated the first time the account loads; + * - a build rolled back to reading only the old store still finds every key, + * including ones added after the upgrade; + * - a new store that cannot be read — a wiped AndroidKeyStore after a device + * credential reset, say — falls back rather than presenting the account as + * having no key, which would silently demote it to read-only. + * + * Nothing is deleted here. Dropping the legacy file is a later release's job, + * once this path has shipped and held; doing both at once leaves no way back. + * + * The two stores cannot legitimately disagree: an npub is derived from its + * private key, so the key for a given npub never changes. A mismatch means + * corruption, and is resolved in favour of the older, proven store. + */ +class AccountKeyStore( + private val vault: PrivateKeyVault, +) { + companion object { + private const val TAG = "AccountKeyStore" + } + + /** + * The account's private key, or null when it genuinely has none — an + * external-signer account, or a watch-only npub. + * + * @param legacyValue what the legacy store holds, read by the caller that + * already has the file open. + */ + suspend fun read( + npub: String, + legacyValue: String?, + ): String? { + val fromSecure = + try { + vault.get(npub) + } catch (e: Exception) { + // Unreadable, not absent. Fall back, and do not migrate into a + // store that just failed. + Log.w(TAG, "Could not read the key store for $npub; using the legacy store", e) + return legacyValue + } + + if (fromSecure != null) { + if (legacyValue != null && legacyValue != fromSecure) { + Log.e(TAG, "Key mismatch for $npub between the legacy and current stores; keeping the legacy value", null) + return legacyValue + } + return fromSecure + } + + // Absent from the new store: first load since the upgrade. + if (legacyValue != null) migrate(npub, legacyValue) + return legacyValue + } + + private suspend fun migrate( + npub: String, + privKeyHex: String, + ) { + try { + vault.save(npub, privKeyHex) + Log.i(TAG) { "Migrated the private key for $npub into the current store" } + } catch (e: Exception) { + // The legacy store still has it and is still read, so this is + // recoverable — the next load tries again. + Log.w(TAG, "Could not migrate the private key for $npub; it stays in the legacy store", e) + } + } + + /** + * Mirrors a save into the new store. The legacy write stays where it is, + * inside the caller's existing edit block, so a rollback keeps working. + * + * The three cases match the legacy write exactly, including the one that is + * easy to get wrong: with no external signer and no private key in hand, + * the legacy store *leaves the stored key alone* rather than clearing it, + * so this must not clear it either. Deleting here would drop the key on + * every save from a session that never decrypted it. + */ + suspend fun mirrorSave( + npub: String, + usesExternalSigner: Boolean, + privKeyHex: String?, + ) { + try { + when { + usesExternalSigner -> vault.delete(npub) + privKeyHex != null -> vault.save(npub, privKeyHex) + else -> Unit + } + } catch (e: Exception) { + // Never fatal: the legacy store still loads the account, and the + // next save or load repairs this one. + Log.w(TAG, "Could not write the private key for $npub to the current store", e) + } + } + + /** Drops the key from the new store; the caller clears the legacy file itself. */ + suspend fun delete(npub: String) { + try { + vault.delete(npub) + } catch (e: Exception) { + Log.w(TAG, "Could not delete the private key for $npub from the current store", e) + } + } +} + +/** The production instance, over the app's [SecureKeyStorage]. */ +val accountKeyStore: AccountKeyStore by lazy { + AccountKeyStore(SecureKeyStorageVault(SecureKeyStorage.create(Amethyst.instance.appContext))) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index dabf6b10e0..f61513aa47 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -622,6 +622,7 @@ object LocalPreferences { // would resurrect the deleted settings from this cache. mutex.withLock { cachedAccounts.remove(accountInfo.npub) } encryptedPreferences(accountInfo.npub).edit(commit = true) { clear() } + accountKeyStore.delete(accountInfo.npub) removeAccount(accountInfo) deleteUserPreferenceFile(accountInfo.npub) @@ -756,6 +757,15 @@ object LocalPreferences { JsonMapper.toJson(settings.pendingAttestations.value), ) } + + // Mirrored into the key store after the legacy write, not + // instead of it: both stores carry the key during the + // transition so a rollback still loads the account. + accountKeyStore.mirrorSave( + npub = settings.keyPair.pubKey.toNpub(), + usesExternalSigner = settings.externalSignerPackageName != null, + privKeyHex = settings.keyPair.privKey?.toHexKey(), + ) } uploadSettingsStore(settings.keyPair.pubKey.toNpub()).save( UploadSettings( @@ -966,8 +976,14 @@ object LocalPreferences { withContext(Dispatchers.IO) { return@withContext with(encryptedPreferences(npub)) { Log.d("LocalPreferences") { "Load account from file $npub - opened file" } - val privKey = getString(PrefKeys.NOSTR_PRIVKEY, null) + // pubKey first: the key store is keyed by npub, which is derived + // from it, and this is the same npub the save side writes under. val pubKey = getString(PrefKeys.NOSTR_PUBKEY, null) ?: return@with null + val privKey = + accountKeyStore.read( + npub = pubKey.hexToByteArray().toNpub(), + legacyValue = getString(PrefKeys.NOSTR_PRIVKEY, null), + ) val externalSignerPackageName = getString(PrefKeys.SIGNER_PACKAGE_NAME, null) ?: if (getBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, false)) "com.greenart7c3.nostrsigner" else null val keyPair = KeyPair(privKey = privKey?.hexToByteArray(), pubKey = pubKey.hexToByteArray()) diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/AccountKeyStoreTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/AccountKeyStoreTest.kt new file mode 100644 index 0000000000..00ccff8987 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/AccountKeyStoreTest.kt @@ -0,0 +1,216 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst + +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The read/write decisions that move account keys off the deprecated store. + * + * Every branch here can cost someone their account: reading a working store as + * empty demotes a signing account to read-only, and clearing a key that was + * only temporarily unreadable destroys it. The AndroidKeyStore itself cannot be + * reached from a unit test, so [PrivateKeyVault] is faked and the logic above + * it is what gets exercised. + */ +class AccountKeyStoreTest { + private val npub = "npub1xxxx" + private val key = "e5e2b1d3f6a94c8d7b0e1f2a3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d" + + private class FakeVault( + var stored: MutableMap = mutableMapOf(), + var failReads: Boolean = false, + var failWrites: Boolean = false, + ) : PrivateKeyVault { + var saves = 0 + var deletes = 0 + + override suspend fun get(npub: String): String? { + if (failReads) throw IllegalStateException("store unreadable") + return stored[npub] + } + + override suspend fun save( + npub: String, + privKeyHex: String, + ) { + saves++ + if (failWrites) throw IllegalStateException("store unwritable") + stored[npub] = privKeyHex + } + + override suspend fun delete(npub: String) { + deletes++ + if (failWrites) throw IllegalStateException("store unwritable") + stored.remove(npub) + } + } + + // ── reads ───────────────────────────────────────────────────────── + + /** First load after the upgrade: the key is only in the legacy store, and moves across. */ + @Test + fun aLegacyOnlyKeyIsReturnedAndMigrated() = + runTest { + val vault = FakeVault() + + val read = AccountKeyStore(vault).read(npub, legacyValue = key) + + assertEquals(key, read) + assertEquals("migrated into the new store", key, vault.stored[npub]) + } + + @Test + fun anAlreadyMigratedKeyIsReadFromTheNewStore() = + runTest { + val vault = FakeVault(mutableMapOf(npub to key)) + + assertEquals(key, AccountKeyStore(vault).read(npub, legacyValue = key)) + assertEquals("already there, so not rewritten", 0, vault.saves) + } + + /** + * The dangerous one. A store that cannot be read must not look like an + * account with no key — that would silently turn a signing account into a + * read-only one. + */ + @Test + fun anUnreadableStoreFallsBackToLegacyRatherThanReportingNoKey() = + runTest { + val vault = FakeVault(failReads = true) + + assertEquals(key, AccountKeyStore(vault).read(npub, legacyValue = key)) + } + + /** And it must not try to migrate into a store that just failed. */ + @Test + fun anUnreadableStoreIsNotWrittenTo() = + runTest { + val vault = FakeVault(failReads = true) + + AccountKeyStore(vault).read(npub, legacyValue = key) + + assertEquals(0, vault.saves) + } + + /** An account genuinely without a key — external signer, or watch-only. */ + @Test + fun noKeyAnywhereReadsAsNull() = + runTest { + val vault = FakeVault() + + assertNull(AccountKeyStore(vault).read(npub, legacyValue = null)) + assertEquals("nothing to migrate", 0, vault.saves) + } + + /** A key added after the upgrade exists only in the new store. */ + @Test + fun aNewStoreOnlyKeyIsReturned() = + runTest { + val vault = FakeVault(mutableMapOf(npub to key)) + + assertEquals(key, AccountKeyStore(vault).read(npub, legacyValue = null)) + } + + /** + * An npub is derived from its key, so the two stores disagreeing means + * corruption. The older, proven store wins. + */ + @Test + fun aMismatchPrefersTheLegacyValue() = + runTest { + val vault = FakeVault(mutableMapOf(npub to "deadbeef")) + + assertEquals(key, AccountKeyStore(vault).read(npub, legacyValue = key)) + } + + /** A failed migration must not fail the account load; the legacy store still has it. */ + @Test + fun aFailedMigrationStillReturnsTheKey() = + runTest { + val vault = FakeVault(failWrites = true) + + assertEquals(key, AccountKeyStore(vault).read(npub, legacyValue = key)) + } + + // ── writes ──────────────────────────────────────────────────────── + + @Test + fun aSaveMirrorsTheKey() = + runTest { + val vault = FakeVault() + + AccountKeyStore(vault).mirrorSave(npub, usesExternalSigner = false, privKeyHex = key) + + assertEquals(key, vault.stored[npub]) + } + + @Test + fun anExternalSignerAccountClearsTheKey() = + runTest { + val vault = FakeVault(mutableMapOf(npub to key)) + + AccountKeyStore(vault).mirrorSave(npub, usesExternalSigner = true, privKeyHex = null) + + assertTrue(vault.stored.isEmpty()) + } + + /** + * The case that is easy to get wrong. With no external signer and no key in + * hand, the legacy store leaves the stored key alone — so this must too. + * Deleting here would drop the key on every save from a session that never + * decrypted it. + */ + @Test + fun aSaveWithoutAKeyInHandLeavesTheStoredKeyAlone() = + runTest { + val vault = FakeVault(mutableMapOf(npub to key)) + + AccountKeyStore(vault).mirrorSave(npub, usesExternalSigner = false, privKeyHex = null) + + assertEquals(key, vault.stored[npub]) + assertEquals(0, vault.deletes) + assertEquals(0, vault.saves) + } + + /** A write failure must never fail the save: the legacy store is still written. */ + @Test + fun aWriteFailureIsSwallowed() = + runTest { + val vault = FakeVault(failWrites = true) + + AccountKeyStore(vault).mirrorSave(npub, usesExternalSigner = false, privKeyHex = key) + } + + @Test + fun deleteRemovesFromTheNewStore() = + runTest { + val vault = FakeVault(mutableMapOf(npub to key)) + + AccountKeyStore(vault).delete(npub) + + assertTrue(vault.stored.isEmpty()) + } +} From 61a16fc74c36d04e184ffbf9305750fe42f74ca3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 21:04:58 +0000 Subject: [PATCH 10/50] feat: dual-store per-account secrets (NIP-46, wallets) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 5c. The NIP-46 bunker material and wallet connection strings move out of `secret_keeper_` into an encrypted DataStore, on the same terms as the private key in 5b: both stores written, new store preferred on read, nothing deleted. Everything stays encrypted, including the parts that are not obviously secret. defaultPaymentSourceId is only an identifier and nip46SeenRequestIds only request ids, but both were encrypted before and both say something about what the account does; moving them to a plain store would be a quiet downgrade. The copy is lazy, through the store's own save(), rather than a DataMigration — and that is not a style choice. A DataMigration writes values as-is while this store decrypts on read, so plaintext placed there cannot be read back: the attempt raises "Invalid symbol". A test now pins that, because the failure mode is an account whose wallet strings are unreadable rather than obviously missing. Three bugs found by wiring this up, all in code written earlier in this series while it had no callers: - AccountSecretsEncryptedStores named its file `.secrets_pb`, but DataStore's Preferences factory rejects any extension other than `.preferences_pb`. The class could never open a store at all. Now `.secrets.preferences_pb`. - Neither per-account store could be re-created after removeAccount. DataStore keeps a process-wide registry keyed by file path and releases an entry only when its scope ends, so dropping the cache entry left the path claimed and deleting an account then adding it again in the same session threw. Both stores now own a cancellable scope per account. Fixed in AccountPreferenceStores too, which had it since step 1. - The mirror serialized clinkDebitWallets raw where the legacy write stores `.map { it.denormalize() }`. That would have written JSON the loader cannot parse. Caught by reading the legacy block rather than trusting the field name. 22 tests: 9 for the secrets group (round trip, the boolean and set surviving a string encoding, an empty set not becoming a set holding an empty string, clearing a wallet removing it rather than leaving stale JSON, per-account isolation, and that the bunker secret and wallet string are not readable in the store file), 4 for the plain store including delete-then-re-add, plus the encrypted-migration pin. Not verified here: the AndroidKeyStore path, same as 5b. A device should confirm a NIP-46 signer still pairs and a wallet still pays after upgrade before the legacy file is dropped. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AXvKXakvup4inNFfAhhr4L --- .../amethyst/AccountSecretsStore.kt | 109 ++++++++++ .../amethyst/LocalPreferences.kt | 63 +++++- .../preferences/AccountPreferenceStores.kt | 47 ++++- .../model/preferences/AccountSecrets.kt | 80 ++++++++ .../AccountSecretsEncryptedStores.kt | 120 ++++++++++- .../AccountPreferenceStoresTest.kt | 104 ++++++++++ .../preferences/AccountSecretsStoreTest.kt | 187 ++++++++++++++++++ .../preferences/EncryptedDataStoreTest.kt | 35 ++++ 8 files changed, 716 insertions(+), 29 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/AccountSecretsStore.kt create mode 100644 commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountSecrets.kt create mode 100644 commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountPreferenceStoresTest.kt create mode 100644 commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountSecretsStoreTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AccountSecretsStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AccountSecretsStore.kt new file mode 100644 index 0000000000..f7c071219e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AccountSecretsStore.kt @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst + +import com.vitorpamplona.amethyst.commons.model.preferences.AccountSecrets +import com.vitorpamplona.amethyst.commons.model.preferences.AccountSecretsEncryptedStores +import com.vitorpamplona.quartz.utils.Log +import okio.Path.Companion.toOkioPath + +/** + * Moves the per-account secrets — NIP-46 bunker material, wallet connection + * strings — out of the `secret_keeper_` EncryptedSharedPreferences file + * and into an encrypted DataStore, on the same terms as the private key: both + * stores written, new store preferred on read, nothing deleted. + * + * The copy is lazy rather than a DataMigration, and that is not a style + * choice. A DataMigration writes values as-is, while this store decrypts on + * read, so plaintext placed there by one cannot be read back — the attempt + * raises. `EncryptedDataStoreTest` pins that behaviour. Copying through the + * store's own `save` is what keeps the values readable. + * + * Losing these is recoverable — the user re-pairs a signer or re-adds a wallet + * — but it is not something to spend, so a read that fails falls back to the + * legacy values rather than reporting the account as having none. + */ +class AccountSecretsStore( + private val stores: AccountSecretsEncryptedStores, +) { + companion object { + private const val TAG = "AccountSecretsStore" + } + + /** + * The account's secrets, migrating out of the legacy file on first use. + * + * @param legacy what the legacy encrypted file holds, read by the caller + * that already has it open. + */ + suspend fun read( + npub: String, + legacy: AccountSecrets, + ): AccountSecrets { + val stored = + try { + stores.loadSecrets(npub) + } catch (e: Exception) { + Log.w(TAG, "Could not read the secrets store for $npub; using the legacy file", e) + return legacy + } + + if (stored != null) return stored + + // Not migrated yet: copy the legacy values across and use them. + mirror(npub, legacy) + return legacy + } + + /** Mirrors a save into the new store. The legacy write stays where it is. */ + suspend fun mirror( + npub: String, + value: AccountSecrets, + ) { + try { + stores.saveSecrets(npub, value) + } catch (e: Exception) { + // Never fatal: the legacy file still has them, and the next save or + // load tries again. + Log.w(TAG, "Could not write the secrets for $npub to the current store", e) + } + } + + suspend fun delete(npub: String) { + try { + stores.removeAccount(npub) + } catch (e: Exception) { + Log.w(TAG, "Could not drop the secrets store for $npub", e) + } + } +} + +val accountSecretsStore: AccountSecretsStore by lazy { + AccountSecretsStore( + AccountSecretsEncryptedStores( + rootFilesDir = { + Amethyst.instance.appContext.filesDir + .toOkioPath() + }, + scope = Amethyst.instance.applicationIOScope, + ), + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index f61513aa47..726297aa1a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -35,6 +35,7 @@ import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupViewM import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntry import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntryNorm import com.vitorpamplona.amethyst.commons.model.preferences.AccountPreferenceStores +import com.vitorpamplona.amethyst.commons.model.preferences.AccountSecrets import com.vitorpamplona.amethyst.commons.model.preferences.CopyOnceMigration import com.vitorpamplona.amethyst.commons.model.preferences.DialogDismissal import com.vitorpamplona.amethyst.commons.model.preferences.DialogDismissalStore @@ -623,6 +624,7 @@ object LocalPreferences { mutex.withLock { cachedAccounts.remove(accountInfo.npub) } encryptedPreferences(accountInfo.npub).edit(commit = true) { clear() } accountKeyStore.delete(accountInfo.npub) + accountSecretsStore.delete(accountInfo.npub) removeAccount(accountInfo) deleteUserPreferenceFile(accountInfo.npub) @@ -761,6 +763,31 @@ object LocalPreferences { // Mirrored into the key store after the legacy write, not // instead of it: both stores carry the key during the // transition so a rollback still loads the account. + accountSecretsStore.mirror( + npub = settings.keyPair.pubKey.toNpub(), + value = + AccountSecrets( + nip46SignerEnabled = settings.nip46SignerEnabled.value, + nip46BunkerSecret = settings.nip46BunkerSecret.value, + nip46TransportKey = settings.nip46TransportKey.value, + nip46SeenRequestIds = settings.nip46SeenRequestIds.value, + nwcWalletsJson = + settings.nwcWallets.value + .mapNotNull { it.denormalize() } + .takeIf { it.isNotEmpty() } + ?.let { JsonMapper.toJson(it) }, + // .map { denormalize() } and not the raw wallets: the legacy + // write stores the denormalized shape, and the read path parses + // that shape. Serializing the raw value here would write JSON + // the loader cannot understand. + clinkDebitWalletsJson = + settings.clinkDebitWallets.value + .map { it.denormalize() } + .takeIf { it.isNotEmpty() } + ?.let { JsonMapper.toJson(it) }, + defaultPaymentSourceId = settings.defaultPaymentSourceId.value, + ), + ) accountKeyStore.mirrorSave( npub = settings.keyPair.pubKey.toNpub(), usesExternalSigner = settings.externalSignerPackageName != null, @@ -998,10 +1025,6 @@ object LocalPreferences { val mirrorUploadsToAllServers = stores.uploadSettings.mirrorUploadsToAllServers val optimizeMediaOnUpload = stores.uploadSettings.optimizeMediaOnUpload val hideCommunityRulesViolations = stores.dialogDismissal.hideCommunityRulesViolations - val nip46SignerEnabled = getBoolean(PrefKeys.NIP46_SIGNER_ENABLED, false) - val nip46BunkerSecret = getString(PrefKeys.NIP46_BUNKER_SECRET, "") ?: "" - val nip46TransportKey = getString(PrefKeys.NIP46_TRANSPORT_KEY, "") ?: "" - val nip46SeenRequestIds = getStringSet(PrefKeys.NIP46_SEEN_IDS, null) ?: setOf() val hideDeleteRequestDialog = stores.dialogDismissal.hideDeleteRequestDialog val hideBlockAlertDialog = stores.dialogDismissal.hideBlockAlertDialog val hideNIP17WarningDialog = stores.dialogDismissal.hideNip17WarningDialog @@ -1022,11 +1045,33 @@ object LocalPreferences { val followListPrefs = toFollowListPrefs(stores.followLists) - val zapPaymentRequestServerStr = getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null) - val nwcWalletsStr = getString(PrefKeys.NWC_WALLETS, null) - val defaultNwcWalletIdStr = getString(PrefKeys.DEFAULT_NWC_WALLET_ID, null) - val clinkDebitWalletsStr = getString(PrefKeys.CLINK_DEBIT_WALLETS, null) - val defaultPaymentSourceIdStr = getString(PrefKeys.DEFAULT_PAYMENT_SOURCE_ID, null) + // The secrets that used to live in this file now come from the + // encrypted DataStore, falling back to what is still here. + val secrets = + accountSecretsStore.read( + npub = keyPair.pubKey.toNpub(), + legacy = + AccountSecrets( + nip46SignerEnabled = getBoolean(PrefKeys.NIP46_SIGNER_ENABLED, false), + nip46BunkerSecret = getString(PrefKeys.NIP46_BUNKER_SECRET, "") ?: "", + nip46TransportKey = getString(PrefKeys.NIP46_TRANSPORT_KEY, "") ?: "", + nip46SeenRequestIds = getStringSet(PrefKeys.NIP46_SEEN_IDS, null) ?: setOf(), + nwcWalletsJson = getString(PrefKeys.NWC_WALLETS, null), + clinkDebitWalletsJson = getString(PrefKeys.CLINK_DEBIT_WALLETS, null), + defaultPaymentSourceId = getString(PrefKeys.DEFAULT_PAYMENT_SOURCE_ID, null), + legacyDefaultNwcWalletId = getString(PrefKeys.DEFAULT_NWC_WALLET_ID, null), + legacyZapPaymentRequestServer = getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null), + ), + ) + val nip46SignerEnabled = secrets.nip46SignerEnabled + val nip46BunkerSecret = secrets.nip46BunkerSecret + val nip46TransportKey = secrets.nip46TransportKey + val nip46SeenRequestIds = secrets.nip46SeenRequestIds + val zapPaymentRequestServerStr = secrets.legacyZapPaymentRequestServer + val nwcWalletsStr = secrets.nwcWalletsJson + val defaultNwcWalletIdStr = secrets.legacyDefaultNwcWalletId + val clinkDebitWalletsStr = secrets.clinkDebitWalletsJson + val defaultPaymentSourceIdStr = secrets.defaultPaymentSourceId val defaultFileServerStr = stores.uploadSettings.defaultFileServerJson val pendingAttestationsStr = getString(PrefKeys.PENDING_ATTESTATIONS, null) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountPreferenceStores.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountPreferenceStores.kt index af03d7de4d..9695fe17a5 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountPreferenceStores.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountPreferenceStores.kt @@ -26,6 +26,11 @@ import androidx.datastore.preferences.core.PreferenceDataStoreFactory import androidx.datastore.preferences.core.Preferences import com.vitorpamplona.amethyst.commons.util.platformFileSystem import com.vitorpamplona.quartz.utils.cache.LargeCache +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel import okio.Path /** @@ -48,26 +53,48 @@ class AccountPreferenceStores( val rootFilesDir: () -> Path, private val migrations: (npub: String) -> List> = { emptyList() }, ) { - private val storeCache = LargeCache>() + /** + * One store per account, each on a scope this class can cancel. + * + * DataStore keeps a process-wide registry keyed by file path and only + * releases an entry when the owning scope ends. Left to create its own + * internal scope, a store is never released, and deleting an account then + * adding it again in the same session throws "multiple DataStores active + * for the same file". + */ + private class Entry( + val scope: CoroutineScope, + val store: DataStore, + ) + + private val storeCache = LargeCache() fun file(npub: String): Path = rootFilesDir() / "datastore" / "$npub.preferences_pb" fun getDataStore(npub: String): DataStore = - storeCache.getOrCreate(npub) { - PreferenceDataStoreFactory.createWithPath( - migrations = migrations(npub), - produceFile = { file(npub) }, - ) - } + storeCache + .getOrCreate(npub) { + val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + Entry( + scope, + PreferenceDataStoreFactory.createWithPath( + scope = scope, + migrations = migrations(npub), + produceFile = { file(npub) }, + ), + ) + }.store /** * Drops the account's stored preferences. * - * The cached handle goes first: deleting the file under a live DataStore - * would leave that instance writing the account's settings back out on the - * next edit, re-creating what this call is meant to erase. + * The live store is shut down first: deleting the file underneath one + * would leave it writing the account's settings back out on the next edit, + * re-creating what this call is meant to erase — and would keep the path + * registered, so the same account could not be added again. */ fun removeAccount(npub: String): Boolean { + storeCache.get(npub)?.scope?.cancel() storeCache.remove(npub) val path = file(npub) if (!platformFileSystem.exists(path)) return false diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountSecrets.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountSecrets.kt new file mode 100644 index 0000000000..220d73546c --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountSecrets.kt @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.preferences + +import androidx.datastore.preferences.core.stringPreferencesKey + +/** + * The per-account values that used to live in the encrypted + * `secret_keeper_` file, other than the private key itself. + * + * Everything here stays encrypted, including the parts that are not obviously + * secret. `defaultPaymentSourceId` is only an identifier and + * `nip46SeenRequestIds` only request ids, but both were encrypted before and + * both say something about what the account does — moving them to a plain + * store would be a quiet downgrade, so they keep the protection they had. + * + * The two `legacy` fields are read-only leftovers the app still migrates from; + * they are carried across so an upgrade does not strand a wallet that only + * exists in the old shape. + */ +data class AccountSecrets( + val nip46SignerEnabled: Boolean = false, + val nip46BunkerSecret: String = "", + val nip46TransportKey: String = "", + val nip46SeenRequestIds: Set = emptySet(), + val nwcWalletsJson: String? = null, + val clinkDebitWalletsJson: String? = null, + val defaultPaymentSourceId: String? = null, + val legacyDefaultNwcWalletId: String? = null, + val legacyZapPaymentRequestServer: String? = null, +) + +/** + * Keys for [AccountSecrets] inside an [EncryptedDataStore]. + * + * All of them are string keys: the store encrypts strings, so a boolean and a + * set are encoded here rather than stored in typed keys that would sit in + * cleartext beside the encrypted values. + */ +internal object AccountSecretKeys { + val nip46SignerEnabled = stringPreferencesKey("nip46SignerEnabled") + val nip46BunkerSecret = stringPreferencesKey("nip46BunkerSecret") + val nip46TransportKey = stringPreferencesKey("nip46TransportKey") + val nip46SeenRequestIds = stringPreferencesKey("nip46SeenRequestIds") + val nwcWallets = stringPreferencesKey("nwcWallets") + val clinkDebitWallets = stringPreferencesKey("clinkDebitWallets") + val defaultPaymentSourceId = stringPreferencesKey("defaultPaymentSourceId") + val legacyDefaultNwcWalletId = stringPreferencesKey("defaultNwcWalletId") + val legacyZapPaymentRequestServer = stringPreferencesKey("zapPaymentServer") + + /** Records that the one-off copy out of the legacy file has run for this account. */ + val migrated = stringPreferencesKey("migrated.accountSecrets") + + /** + * Sets are stored newline-joined rather than as JSON. + * + * The members are nostr event ids — hex, so they cannot contain a newline — + * which makes the round trip exact without pulling a serializer into the + * encryption path. + */ + const val SET_SEPARATOR = "\n" +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountSecretsEncryptedStores.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountSecretsEncryptedStores.kt index 60300c1498..c56bf6b9bd 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountSecretsEncryptedStores.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountSecretsEncryptedStores.kt @@ -26,6 +26,9 @@ import com.vitorpamplona.amethyst.commons.util.platformFileSystem import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import com.vitorpamplona.quartz.utils.cache.LargeCache import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel import okio.Path /** @@ -53,18 +56,42 @@ class AccountSecretsEncryptedStores( val nwc = stringPreferencesKey("nwc") } - private val storeCache = LargeCache() + /** + * One store per account, each on its own child scope. + * + * The scope matters: DataStore keeps a process-wide registry keyed by file + * path and only releases an entry when the owning scope is cancelled. On a + * shared scope, deleting an account and re-adding it in the same session + * would throw "multiple DataStores active for the same file". + */ + private class Entry( + val scope: CoroutineScope, + val store: EncryptedDataStore, + ) - fun file(npub: String): Path = rootFilesDir() / "datastore" / "$npub.secrets_pb" + private val storeCache = LargeCache() + + /** + * The `.preferences_pb` suffix is required, not decorative: DataStore's + * Preferences factory rejects any other extension at open time. The + * `.secrets` part is what keeps this file distinct from the account's + * plain preference store. + */ + fun file(npub: String): Path = rootFilesDir() / "datastore" / "$npub.secrets.preferences_pb" fun getDataStore(npub: String): EncryptedDataStore = - storeCache.getOrCreate(npub) { - EncryptedDataStore( - PreferenceDataStoreFactory.createWithPath(produceFile = { file(npub) }), - encryption, - scope = scope, - ) - } + storeCache + .getOrCreate(npub) { + val child = CoroutineScope(scope.coroutineContext + SupervisorJob(scope.coroutineContext[Job])) + Entry( + child, + EncryptedDataStore( + PreferenceDataStoreFactory.createWithPath(scope = child, produceFile = { file(npub) }), + encryption, + scope = child, + ), + ) + }.store fun nwc(npub: String): UpdatablePropertyFlow = getDataStore(npub).getProperty( @@ -73,12 +100,85 @@ class AccountSecretsEncryptedStores( serializer = Nip47WalletConnect.Nip47URI::serializer, ) - /** See [AccountPreferenceStores.removeAccount] — the cached handle goes first. */ + /** + * Drops the account's secrets. + * + * Cancels the store's scope before deleting, so DataStore releases the + * path and the same account can be added again in this session. + */ fun removeAccount(npub: String): Boolean { + storeCache.get(npub)?.scope?.cancel() storeCache.remove(npub) val path = file(npub) if (!platformFileSystem.exists(path)) return false platformFileSystem.delete(path) return true } + + // ── the per-account secret group ────────────────────────────────── + + /** + * Reads [AccountSecrets], or null when this account has not been migrated + * out of the legacy encrypted file yet. + * + * Null and "all defaults" are deliberately different answers: the caller + * uses null to decide whether to run the one-off copy, and an account that + * genuinely holds no secrets must not trigger it forever. + */ + suspend fun loadSecrets(npub: String): AccountSecrets? { + val store = getDataStore(npub) + if (store.get(AccountSecretKeys.migrated) == null) return null + + return AccountSecrets( + nip46SignerEnabled = store.get(AccountSecretKeys.nip46SignerEnabled).toBoolean(), + nip46BunkerSecret = store.get(AccountSecretKeys.nip46BunkerSecret) ?: "", + nip46TransportKey = store.get(AccountSecretKeys.nip46TransportKey) ?: "", + nip46SeenRequestIds = decodeSet(store.get(AccountSecretKeys.nip46SeenRequestIds)), + nwcWalletsJson = store.get(AccountSecretKeys.nwcWallets), + clinkDebitWalletsJson = store.get(AccountSecretKeys.clinkDebitWallets), + defaultPaymentSourceId = store.get(AccountSecretKeys.defaultPaymentSourceId), + legacyDefaultNwcWalletId = store.get(AccountSecretKeys.legacyDefaultNwcWalletId), + legacyZapPaymentRequestServer = store.get(AccountSecretKeys.legacyZapPaymentRequestServer), + ) + } + + /** + * Writes the group, then the marker. + * + * Marker last on purpose: a crash midway leaves the account looking + * unmigrated, so the next load copies from the legacy file again rather + * than reading a half-written set of secrets as complete. + */ + suspend fun saveSecrets( + npub: String, + value: AccountSecrets, + ) { + val store = getDataStore(npub) + + store.save(AccountSecretKeys.nip46SignerEnabled, value.nip46SignerEnabled.toString()) + store.save(AccountSecretKeys.nip46BunkerSecret, value.nip46BunkerSecret) + store.save(AccountSecretKeys.nip46TransportKey, value.nip46TransportKey) + store.save(AccountSecretKeys.nip46SeenRequestIds, value.nip46SeenRequestIds.joinToString(AccountSecretKeys.SET_SEPARATOR)) + store.putOrRemove(AccountSecretKeys.nwcWallets, value.nwcWalletsJson) + store.putOrRemove(AccountSecretKeys.clinkDebitWallets, value.clinkDebitWalletsJson) + store.putOrRemove(AccountSecretKeys.defaultPaymentSourceId, value.defaultPaymentSourceId) + store.putOrRemove(AccountSecretKeys.legacyDefaultNwcWalletId, value.legacyDefaultNwcWalletId) + store.putOrRemove(AccountSecretKeys.legacyZapPaymentRequestServer, value.legacyZapPaymentRequestServer) + + store.save(AccountSecretKeys.migrated, "true") + } + + private suspend fun EncryptedDataStore.putOrRemove( + key: androidx.datastore.preferences.core.Preferences.Key, + value: String?, + ) { + if (value != null) save(key, value) else remove(key) + } + + private fun decodeSet(raw: String?): Set = + raw + ?.split(AccountSecretKeys.SET_SEPARATOR) + ?.filter { it.isNotEmpty() } + ?.toSet() + ?: emptySet() } diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountPreferenceStoresTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountPreferenceStoresTest.kt new file mode 100644 index 0000000000..34ba75fabd --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountPreferenceStoresTest.kt @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.preferences + +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import okio.Path.Companion.toOkioPath +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +class AccountPreferenceStoresTest { + @get:Rule + val folder = TemporaryFolder() + + private var seq = 0 + + /** + * The root is resolved once, outside the lambda: [AccountPreferenceStores] + * calls `rootFilesDir()` on every file() and a lambda that allocated a new + * directory each time would hand out a different path per call. + */ + private fun stores(): AccountPreferenceStores { + val root = File(folder.root, "r${seq++}").apply { mkdirs() } + return AccountPreferenceStores(rootFilesDir = { root.toOkioPath() }) + } + + private val key = stringPreferencesKey("k") + + @Test + fun valuesRoundTripPerAccount() = + runTest { + val subject = stores() + + subject.getDataStore("npub1a").edit { it[key] = "a" } + subject.getDataStore("npub1b").edit { it[key] = "b" } + + assertEquals("a", subject.getDataStore("npub1a").data.first()[key]) + assertEquals("b", subject.getDataStore("npub1b").data.first()[key]) + } + + @Test + fun removingAnAccountDeletesItsFile() = + runTest { + val subject = stores() + subject.getDataStore("npub1a").edit { it[key] = "a" } + + assertTrue(subject.removeAccount("npub1a")) + assertFalse(subject.file("npub1a").toFile().exists()) + } + + /** + * Deleting an account and adding it again in the same session. + * + * DataStore keeps a process-wide registry keyed by file path and only + * releases an entry when its scope ends, so a store that is merely dropped + * from the cache keeps the path claimed — and this throws "multiple + * DataStores active for the same file". + */ + @Test + fun anAccountCanBeAddedAgainAfterRemoval() = + runTest { + val subject = stores() + subject.getDataStore("npub1a").edit { it[key] = "before" } + subject.removeAccount("npub1a") + + val reopened = subject.getDataStore("npub1a") + + assertNull("the old value is gone", reopened.data.first()[key]) + reopened.edit { it[key] = "after" } + assertEquals("after", subject.getDataStore("npub1a").data.first()[key]) + } + + @Test + fun removingAnUnknownAccountReportsNothingDeleted() = + runTest { + assertFalse(stores().removeAccount("npub1missing")) + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountSecretsStoreTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountSecretsStoreTest.kt new file mode 100644 index 0000000000..d560d48709 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountSecretsStoreTest.kt @@ -0,0 +1,187 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.preferences + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.test.runTest +import okio.Path.Companion.toOkioPath +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +/** + * The per-account secrets group: NIP-46 bunker material and wallet strings. + * + * These are encrypted at rest and reached through string keys, so a booleans + * and a set have to survive an encode/decode they did not before — that is + * what most of this covers. + */ +class AccountSecretsStoreTest { + @get:Rule + val folder = TemporaryFolder() + + private var seq = 0 + + private fun stores(): AccountSecretsEncryptedStores { + val root = File(folder.root, "acct_${seq++}").apply { mkdirs() } + return AccountSecretsEncryptedStores( + rootFilesDir = { root.toOkioPath() }, + scope = CoroutineScope(Dispatchers.IO + SupervisorJob()), + encryption = SecretEncryption(File(root, "secret.key")), + ) + } + + private val npub = "npub1abc" + + private val filled = + AccountSecrets( + nip46SignerEnabled = true, + nip46BunkerSecret = "bunker-secret", + nip46TransportKey = "transport-key", + nip46SeenRequestIds = setOf("aa11", "bb22", "cc33"), + nwcWalletsJson = """[{"uri":"nostr+walletconnect://x"}]""", + clinkDebitWalletsJson = """[{"id":"debit1"}]""", + defaultPaymentSourceId = "source-1", + ) + + /** + * Absent must read as null, not as a default-filled group: the caller uses + * null to decide whether to run the one-off copy out of the legacy file. + */ + @Test + fun anUnmigratedAccountReadsAsNull() = + runTest { + assertNull(stores().loadSecrets(npub)) + } + + @Test + fun theGroupRoundTrips() = + runTest { + val subject = stores() + + subject.saveSecrets(npub, filled) + + assertEquals(filled, subject.loadSecrets(npub)) + } + + /** An account that genuinely holds nothing must still read as migrated, not as null forever. */ + @Test + fun anEmptyGroupStillCountsAsMigrated() = + runTest { + val subject = stores() + + subject.saveSecrets(npub, AccountSecrets()) + + assertEquals(AccountSecrets(), subject.loadSecrets(npub)) + } + + /** The boolean and the set go through a string encoding that did not exist before. */ + @Test + fun theBooleanAndSetSurviveEncoding() = + runTest { + val subject = stores() + + subject.saveSecrets(npub, AccountSecrets(nip46SignerEnabled = true, nip46SeenRequestIds = setOf("a", "b"))) + val loaded = subject.loadSecrets(npub)!! + + assertTrue(loaded.nip46SignerEnabled) + assertEquals(setOf("a", "b"), loaded.nip46SeenRequestIds) + } + + @Test + fun anEmptySetDoesNotBecomeASetHoldingAnEmptyString() = + runTest { + val subject = stores() + + subject.saveSecrets(npub, AccountSecrets(nip46SeenRequestIds = emptySet())) + + assertTrue(subject.loadSecrets(npub)!!.nip46SeenRequestIds.isEmpty()) + } + + /** Clearing a wallet must remove it, not leave the previous JSON behind. */ + @Test + fun aClearedWalletIsRemoved() = + runTest { + val subject = stores() + subject.saveSecrets(npub, filled) + + subject.saveSecrets(npub, filled.copy(nwcWalletsJson = null, defaultPaymentSourceId = null)) + val loaded = subject.loadSecrets(npub)!! + + assertNull(loaded.nwcWalletsJson) + assertNull(loaded.defaultPaymentSourceId) + assertEquals("siblings untouched", filled.clinkDebitWalletsJson, loaded.clinkDebitWalletsJson) + } + + @Test + fun accountsAreIsolated() = + runTest { + val subject = stores() + + subject.saveSecrets("npub1aaa", filled) + + assertNull(subject.loadSecrets("npub1bbb")) + } + + /** The values must not be sitting in the store file in the clear. */ + @Test + fun secretsAreNotStoredInPlaintext() = + runTest { + val root = File(folder.root, "plain").apply { mkdirs() } + val subject = + AccountSecretsEncryptedStores( + rootFilesDir = { root.toOkioPath() }, + scope = CoroutineScope(Dispatchers.IO + SupervisorJob()), + encryption = SecretEncryption(File(root, "secret.key")), + ) + + subject.saveSecrets(npub, filled) + + val onDisk = + subject + .file(npub) + .toFile() + .readBytes() + .decodeToString() + assertFalse("the bunker secret must not be readable", onDisk.contains("bunker-secret")) + assertFalse("the wallet string must not be readable", onDisk.contains("nostr+walletconnect")) + } + + @Test + fun removingAnAccountDropsItsSecrets() = + runTest { + val subject = stores() + subject.saveSecrets(npub, filled) + assertNotNull(subject.loadSecrets(npub)) + + subject.removeAccount(npub) + + assertNull(subject.loadSecrets(npub)) + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/EncryptedDataStoreTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/EncryptedDataStoreTest.kt index 05f9e231e1..6354f5e924 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/EncryptedDataStoreTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/EncryptedDataStoreTest.kt @@ -176,4 +176,39 @@ class EncryptedDataStoreTest { ) readScope.cancel() } + + /** + * A stock DataMigration writes values as-is, but this store decrypts on + * read — so plaintext put there by one cannot survive the trip, and the + * read raises rather than returning something wrong. + * + * This is why secrets migrate lazily, through [save], instead of through a + * DataMigration the way the plain preference stores do. Getting it wrong + * would leave an account's wallet strings unreadable rather than obviously + * missing. + */ + @Test + fun aRawMigrationIntoAnEncryptedStoreIsNotReadable() = + runTest { + val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + val n = seq++ + val dataFile = File(folder.root, "rawmig_$n.preferences_pb") + val keyFile = File(folder.root, "rawmig_$n.key") + val subject = + EncryptedDataStore( + PreferenceDataStoreFactory.createWithPath( + scope = scope, + migrations = listOf(CopyOnceMigration("probe") { out -> out[key] = "plaintext-secret" }), + produceFile = { dataFile.toOkioPath() }, + ), + SecretEncryption(keyFile), + scope = scope, + ) + + assertTrue( + "a raw-migrated value must not read back as if it were valid", + runCatching { subject.get(key) }.isFailure, + ) + scope.cancel() + } } From 7d9720b3ce75518aa7675739d99dcf4efdfa7e6f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 21:14:45 +0000 Subject: [PATCH 11/50] feat: dual-store the account roster MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finishes 5c. The account index — which accounts exist, which one is in front — moves out of the global `secret_keeper` EncryptedSharedPreferences file into an encrypted DataStore, on the same terms as the keys and secrets before it: both stores written, new store preferred on read, nothing deleted. This is the most consequential piece of the preference layer to get wrong. Every private key can be perfectly intact and, if the roster reads empty, the app still opens as a fresh install with no route back to the accounts sitting on disk. So the legacy file wins every tie: - an unreadable store falls back rather than reporting no accounts; - a migration that could not be written falls back; - a migrated-but-empty list falls back, because "[]" and "never populated" are indistinguishable here and only one is safe to act on. Only a genuinely empty legacy file reads as no accounts. Accessors are per-field rather than one group: the app writes the current account and the account list on separate paths, and a group save would make each clobber the other's value. Encrypted because it was encrypted before. The contents are npubs and two booleans rather than key material, but they are the list of identities on this device. clear() still matches the legacy behaviour when the last account is removed, including dropping the migrated marker — with the legacy file cleared too there is nothing left to copy, and a marker left set would claim a migration whose source no longer exists. 10 tests against a fake store, one per branch above, plus the fresh install that must not invent an account. androidx.security.crypto is still declared in :amethyst. Every reader of the legacy files now prefers a DataStore, but the legacy writes remain so a rollback works, and dropping them is the next release's job. Not verified here, and this is the one to hold: no device. Before the legacy files are deleted, a real device should confirm that an upgraded install still lists its accounts, opens the right one, survives a force-stop, and that adding and removing an account still behaves. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AXvKXakvup4inNFfAhhr4L --- .../vitorpamplona/amethyst/AccountRoster.kt | 182 +++++++++++++++ .../amethyst/LocalPreferences.kt | 20 +- .../amethyst/AccountRosterTest.kt | 209 ++++++++++++++++++ .../model/preferences/AccountRosterStore.kt | 89 ++++++++ 4 files changed, 491 insertions(+), 9 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/AccountRoster.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/AccountRosterTest.kt create mode 100644 commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountRosterStore.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AccountRoster.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AccountRoster.kt new file mode 100644 index 0000000000..666e508b11 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AccountRoster.kt @@ -0,0 +1,182 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst + +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import com.vitorpamplona.amethyst.commons.model.preferences.AccountRosterStore +import com.vitorpamplona.amethyst.commons.model.preferences.SecretEncryption +import com.vitorpamplona.quartz.utils.Log +import okio.Path.Companion.toOkioPath +import java.io.File + +/** + * The slice of the roster store this needs. + * + * An interface so the fallback decisions below are testable without an + * AndroidKeyStore, which no unit test can reach. + */ +interface RosterStorage { + suspend fun hasMigrated(): Boolean + + suspend fun markMigrated() + + suspend fun currentAccount(): String? + + suspend fun setCurrentAccount(npub: String?) + + suspend fun allAccountInfoJson(): String? + + suspend fun setAllAccountInfoJson(json: String?) + + suspend fun clear() +} + +/** [RosterStorage] over the real encrypted store. */ +class EncryptedRosterStorage( + private val store: AccountRosterStore, +) : RosterStorage { + override suspend fun hasMigrated() = store.hasMigrated() + + override suspend fun markMigrated() = store.markMigrated() + + override suspend fun currentAccount() = store.currentAccount() + + override suspend fun setCurrentAccount(npub: String?) = store.setCurrentAccount(npub) + + override suspend fun allAccountInfoJson() = store.allAccountInfoJson() + + override suspend fun setAllAccountInfoJson(json: String?) = store.setAllAccountInfoJson(json) + + override suspend fun clear() = store.clear() +} + +/** + * Moves the account index — which accounts exist, which one is in front — out + * of the global `secret_keeper` EncryptedSharedPreferences file, on the same + * terms as the keys and secrets before it: both stores written, new store + * preferred on read, nothing deleted. + * + * This one is the most consequential to get wrong. Every private key can be + * perfectly intact and, if the roster reads empty, the app still opens as a + * fresh install with no way back to the accounts that are sitting on disk. + * So a read that fails or comes back empty falls through to the legacy file + * rather than being taken at face value. + */ +class AccountRoster( + private val store: RosterStorage, +) { + companion object { + private const val TAG = "AccountRoster" + } + + /** + * Runs the one-off copy if it has not run, and reports whether the new + * store can be trusted for this read. + * + * Returns false when anything goes wrong, which sends the caller to the + * legacy file. + */ + private suspend fun ready( + legacyCurrent: () -> String?, + legacyAll: () -> String?, + ): Boolean = + try { + if (!store.hasMigrated()) { + store.setCurrentAccount(legacyCurrent()) + store.setAllAccountInfoJson(legacyAll()) + // Marker last: a crash midway leaves this unmigrated, so the + // next read copies again rather than trusting a half-written + // roster. + store.markMigrated() + } + true + } catch (e: Exception) { + Log.w(TAG, "Could not prepare the roster store; using the legacy file", e) + false + } + + suspend fun currentAccount( + legacyCurrent: () -> String?, + legacyAll: () -> String?, + ): String? { + if (!ready(legacyCurrent, legacyAll)) return legacyCurrent() + + return try { + store.currentAccount() ?: legacyCurrent() + } catch (e: Exception) { + Log.w(TAG, "Could not read the current account; using the legacy file", e) + legacyCurrent() + } + } + + /** + * The saved-account list as JSON. + * + * An empty or absent value falls through to the legacy file rather than + * being reported as "no accounts": the two are indistinguishable here, and + * only one of them is safe to act on. + */ + suspend fun allAccountInfoJson( + legacyCurrent: () -> String?, + legacyAll: () -> String?, + ): String? { + if (!ready(legacyCurrent, legacyAll)) return legacyAll() + + return try { + store.allAccountInfoJson()?.takeIf { it.isNotBlank() && it != "[]" } ?: legacyAll() + } catch (e: Exception) { + Log.w(TAG, "Could not read the saved accounts; using the legacy file", e) + legacyAll() + } + } + + suspend fun mirrorCurrentAccount(npub: String?) = guard { store.setCurrentAccount(npub) } + + suspend fun mirrorAllAccountInfoJson(json: String?) = guard { store.setAllAccountInfoJson(json) } + + /** Matches the legacy `clear()` on the global file when the last account goes. */ + suspend fun clear() = guard { store.clear() } + + private suspend fun guard(block: suspend () -> Unit) { + try { + block() + } catch (e: Exception) { + // Never fatal: the legacy file is still written and still read. + Log.w(TAG, "Could not write the roster store", e) + } + } +} + +val accountRoster: AccountRoster by lazy { + val context = Amethyst.instance.appContext + AccountRoster( + EncryptedRosterStorage( + AccountRosterStore( + PreferenceDataStoreFactory.createWithPath( + scope = Amethyst.instance.applicationIOScope, + produceFile = { File(context.filesDir, "datastore/roster.preferences_pb").toOkioPath() }, + ), + SecretEncryption(), + Amethyst.instance.applicationIOScope, + ), + ), + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 726297aa1a..37e72033e3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -467,11 +467,15 @@ object LocalPreferences { globalSettingsPrefs().edit { putBoolean(PrefKeys.NOTIFICATION_SERVICE_ENABLED, enabled) } } + private fun legacyCurrentAccount(): String? = encryptedPreferences().getString(PrefKeys.CURRENT_ACCOUNT, null) + + private fun legacyAllAccountInfo(): String? = encryptedPreferences().getString(PrefKeys.ALL_ACCOUNT_INFO, null) + suspend fun currentAccount(): String? { if (currentAccount == null) { currentAccount = withContext(Dispatchers.IO) { - encryptedPreferences().getString(PrefKeys.CURRENT_ACCOUNT, null) + accountRoster.currentAccount(::legacyCurrentAccount, ::legacyAllAccountInfo) } } return currentAccount @@ -482,12 +486,14 @@ object LocalPreferences { currentAccount = null withContext(Dispatchers.IO) { encryptedPreferences().edit { clear() } + accountRoster.clear() } } else if (currentAccount != info.npub) { currentAccount = info.npub if (!info.isTransient) { withContext(Dispatchers.IO) { encryptedPreferences().edit { putString(PrefKeys.CURRENT_ACCOUNT, info.npub) } + accountRoster.mirrorCurrentAccount(info.npub) } } } @@ -507,7 +513,7 @@ object LocalPreferences { withContext(Dispatchers.IO) { with(encryptedPreferences()) { val newSystemOfAccounts = - getString(PrefKeys.ALL_ACCOUNT_INFO, "[]")?.let { + (accountRoster.allAccountInfoJson(::legacyCurrentAccount, ::legacyAllAccountInfo) ?: "[]").let { JsonMapper.fromJson>(it) } @@ -545,13 +551,9 @@ object LocalPreferences { if (savedAccounts != accounts) { savedAccounts.emit(accounts) - encryptedPreferences() - .edit { - putString( - PrefKeys.ALL_ACCOUNT_INFO, - JsonMapper.toJson(accounts.filter { !it.isTransient }), - ) - } + val json = JsonMapper.toJson(accounts.filter { !it.isTransient }) + encryptedPreferences().edit { putString(PrefKeys.ALL_ACCOUNT_INFO, json) } + accountRoster.mirrorAllAccountInfoJson(json) } } diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/AccountRosterTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/AccountRosterTest.kt new file mode 100644 index 0000000000..94f5cb65b5 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/AccountRosterTest.kt @@ -0,0 +1,209 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst + +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The account index. + * + * Every branch here decides whether the app opens to the user's accounts or to + * an empty screen. A read that comes back empty is indistinguishable from a + * store that has not been populated, and only one of those is safe to act on — + * so the legacy file wins every tie. + */ +class AccountRosterTest { + private val accountsJson = """[{"npub":"npub1a","hasPrivKey":true}]""" + + private class FakeStorage( + var migrated: Boolean = false, + var current: String? = null, + var allJson: String? = null, + var failReads: Boolean = false, + var failWrites: Boolean = false, + ) : RosterStorage { + var cleared = 0 + + override suspend fun hasMigrated(): Boolean { + if (failReads) throw IllegalStateException("unreadable") + return migrated + } + + override suspend fun markMigrated() { + if (failWrites) throw IllegalStateException("unwritable") + migrated = true + } + + override suspend fun currentAccount(): String? { + if (failReads) throw IllegalStateException("unreadable") + return current + } + + override suspend fun setCurrentAccount(npub: String?) { + if (failWrites) throw IllegalStateException("unwritable") + current = npub + } + + override suspend fun allAccountInfoJson(): String? { + if (failReads) throw IllegalStateException("unreadable") + return allJson + } + + override suspend fun setAllAccountInfoJson(json: String?) { + if (failWrites) throw IllegalStateException("unwritable") + allJson = json + } + + override suspend fun clear() { + cleared++ + migrated = false + current = null + allJson = null + } + } + + private fun legacy( + current: String? = "npub1a", + all: String? = accountsJson, + ) = Pair<() -> String?, () -> String?>({ current }, { all }) + + // ── first run after the upgrade ─────────────────────────────────── + + @Test + fun theLegacyRosterIsCopiedOnFirstRead() = + runTest { + val store = FakeStorage() + val (c, a) = legacy() + + assertEquals("npub1a", AccountRoster(store).currentAccount(c, a)) + + assertTrue(store.migrated) + assertEquals("npub1a", store.current) + assertEquals(accountsJson, store.allJson) + } + + @Test + fun anAlreadyMigratedRosterIsReadFromTheNewStore() = + runTest { + val store = FakeStorage(migrated = true, current = "npub1z", allJson = """[{"npub":"npub1z"}]""") + val (c, a) = legacy() + + assertEquals("npub1z", AccountRoster(store).currentAccount(c, a)) + assertEquals("""[{"npub":"npub1z"}]""", AccountRoster(store).allAccountInfoJson(c, a)) + } + + // ── the failure modes that empty the account list ───────────────── + + /** An unreadable store must never present as "no accounts". */ + @Test + fun anUnreadableStoreFallsBackToLegacy() = + runTest { + val store = FakeStorage(failReads = true) + val (c, a) = legacy() + + assertEquals("npub1a", AccountRoster(store).currentAccount(c, a)) + assertEquals(accountsJson, AccountRoster(store).allAccountInfoJson(c, a)) + } + + /** Nor must a migration that could not be written. */ + @Test + fun aFailedMigrationFallsBackToLegacy() = + runTest { + val store = FakeStorage(failWrites = true) + val (c, a) = legacy() + + assertEquals("npub1a", AccountRoster(store).currentAccount(c, a)) + assertEquals(accountsJson, AccountRoster(store).allAccountInfoJson(c, a)) + } + + /** + * A migrated-but-empty list is indistinguishable from one that was never + * populated, so it falls through rather than being taken as truth. + */ + @Test + fun anEmptyAccountListFallsBackToLegacy() = + runTest { + val (c, a) = legacy() + + assertEquals(accountsJson, AccountRoster(FakeStorage(migrated = true, allJson = "[]")).allAccountInfoJson(c, a)) + assertEquals(accountsJson, AccountRoster(FakeStorage(migrated = true, allJson = "")).allAccountInfoJson(c, a)) + assertEquals(accountsJson, AccountRoster(FakeStorage(migrated = true, allJson = null)).allAccountInfoJson(c, a)) + } + + @Test + fun anAbsentCurrentAccountFallsBackToLegacy() = + runTest { + val (c, a) = legacy() + + assertEquals("npub1a", AccountRoster(FakeStorage(migrated = true, current = null)).currentAccount(c, a)) + } + + /** A genuinely fresh install has nothing anywhere, and must not invent an account. */ + @Test + fun aFreshInstallReadsAsNothing() = + runTest { + val (c, a) = legacy(current = null, all = null) + + assertNull(AccountRoster(FakeStorage()).currentAccount(c, a)) + assertNull(AccountRoster(FakeStorage()).allAccountInfoJson(c, a)) + } + + // ── writes ──────────────────────────────────────────────────────── + + @Test + fun mirroredWritesReachTheStore() = + runTest { + val store = FakeStorage() + val subject = AccountRoster(store) + + subject.mirrorCurrentAccount("npub1b") + subject.mirrorAllAccountInfoJson(accountsJson) + + assertEquals("npub1b", store.current) + assertEquals(accountsJson, store.allJson) + } + + @Test + fun clearingWipesTheRoster() = + runTest { + val store = FakeStorage(migrated = true, current = "npub1a", allJson = accountsJson) + + AccountRoster(store).clear() + + assertEquals(1, store.cleared) + assertNull(store.current) + assertNull(store.allJson) + } + + /** A write failure must never fail the save: the legacy file is still written. */ + @Test + fun aWriteFailureIsSwallowed() = + runTest { + val subject = AccountRoster(FakeStorage(failWrites = true)) + + subject.mirrorCurrentAccount("npub1b") + subject.mirrorAllAccountInfoJson(accountsJson) + } +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountRosterStore.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountRosterStore.kt new file mode 100644 index 0000000000..2090fa600f --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountRosterStore.kt @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.preferences + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.stringPreferencesKey +import kotlinx.coroutines.CoroutineScope + +/** + * Which accounts this installation holds, and which one is in front. + * + * Not per-account — this is the index that has to be read before any account + * can be. Losing it is the worst failure in the preference layer: every key + * survives untouched, and the app still opens as if freshly installed. + * + * Encrypted because it was encrypted before. The contents are npubs and two + * booleans rather than key material, but they are the list of identities on + * this device, and moving them to a plain store would be a quiet downgrade. + * + * Accessors are per-field rather than one group: the app writes the current + * account and the account list on separate paths, and a group save would make + * each one clobber the other's value. + */ +class AccountRosterStore( + store: DataStore, + encryption: SecretEncryption, + scope: CoroutineScope, +) { + companion object { + private val currentAccountKey = stringPreferencesKey("currently_logged_in_account") + private val allAccountInfoKey = stringPreferencesKey("all_saved_accounts_info") + private val migratedKey = stringPreferencesKey("migrated.roster") + } + + private val encrypted = EncryptedDataStore(store, encryption, scope) + + /** True once the one-off copy out of the legacy encrypted file has run. */ + suspend fun hasMigrated(): Boolean = encrypted.get(migratedKey) != null + + suspend fun markMigrated() { + encrypted.save(migratedKey, "true") + } + + suspend fun currentAccount(): String? = encrypted.get(currentAccountKey) + + suspend fun setCurrentAccount(npub: String?) { + if (npub != null) encrypted.save(currentAccountKey, npub) else encrypted.remove(currentAccountKey) + } + + /** The account list as the JSON the app already stores; parsing stays at the call site. */ + suspend fun allAccountInfoJson(): String? = encrypted.get(allAccountInfoKey) + + suspend fun setAllAccountInfoJson(json: String?) { + if (json != null) encrypted.save(allAccountInfoKey, json) else encrypted.remove(allAccountInfoKey) + } + + /** + * Wipes the roster, matching the legacy `clear()` on the global file when + * the last account is removed. + * + * The migrated marker goes too: with the legacy file cleared as well, there + * is nothing left to copy, and leaving the marker set would be a claim + * about a migration whose source no longer exists. + */ + suspend fun clear() { + encrypted.remove(currentAccountKey) + encrypted.remove(allAccountInfoKey) + encrypted.remove(migratedKey) + } +} From 37664390021b375fa1b4bcb9a36814df41e9807b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 21:28:22 +0000 Subject: [PATCH 12/50] docs: record why EncryptedStorage cannot be deleted, and what is left MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Corrects a claim this series repeated and never checked. Several commit messages and comments said the legacy files could be dropped "once this path has shipped and held". That is wrong, and the mistake matters. Every migration in the preference layer is lazy: it reads the legacy store when it runs, not when the app is installed. Nine come through EncryptedStorage — the seven CopyOnceMigrations plus the key, secret and roster stores. Only the Cashu counters and calendar reminders read a plain source and would survive its removal. So deleting EncryptedStorage does not only affect installs that have yet to upgrade. It strands anyone who SKIPS the release introducing the new stores: a pre-migration build upgrading straight to a post-deletion build runs its migration against a reader that no longer exists, and every key, account, wallet and setting stays encrypted on disk with nothing able to read it. The app opens as a fresh install. Auto-update off, the F-Droid cadence and restoring from a backup all skip releases. The reader is therefore permanent, and androidx.security.crypto stays with it — an unmaintained-library risk rather than an active vulnerability, and far cheaper than stranding users. What a later release can retire is the legacy *write*. An audit of what is actually migrated also found the files are not ready to be deleted even for users who do upgrade in order. Seven keys are still read only from the legacy files, and one is fatal: without NOSTR_PUBKEY, loadAccountConfigFromEncryptedStorage returns null and the account disappears even though its private key migrated fine. The rest — the two external-signer keys, HAS_BACKED_UP_KEYS, LOCAL_RELAY_SERVERS, OPEN_BACKUP_CONFLICTS and the global SHARED_SETTINGS — lose settings rather than accounts. PENDING_ATTESTATIONS, NOTIF_GLOBAL_TO_CURATED_MIGRATED and LAST_READ_PER_ROUTE are accepted losses and stay unmigrated. That makes four accessors added to NotificationPrefsStore in the settings-group commit permanently dead — they were written, documented as deliberately special-cased, and never called. Removed, with their tests, rather than left looking like a feature. amethyst/plans/2026-09-23-encrypted-storage-retirement.md carries the constraint, the outstanding keys, the accepted losses, and the device checks none of this has had. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AXvKXakvup4inNFfAhhr4L --- ...2026-09-23-encrypted-storage-retirement.md | 78 +++++++++++++++++++ .../vitorpamplona/amethyst/AccountKeyStore.kt | 7 +- .../vitorpamplona/amethyst/AccountRoster.kt | 3 + .../amethyst/AccountSecretsStore.kt | 3 + .../amethyst/EncryptedStorage.kt | 33 ++++++++ .../model/preferences/CopyOnceMigration.kt | 10 ++- .../preferences/NotificationPrefsStore.kt | 34 ++------ .../preferences/AccountSettingStoresTest.kt | 28 ------- 8 files changed, 135 insertions(+), 61 deletions(-) create mode 100644 amethyst/plans/2026-09-23-encrypted-storage-retirement.md diff --git a/amethyst/plans/2026-09-23-encrypted-storage-retirement.md b/amethyst/plans/2026-09-23-encrypted-storage-retirement.md new file mode 100644 index 0000000000..4a6ff1ab2f --- /dev/null +++ b/amethyst/plans/2026-09-23-encrypted-storage-retirement.md @@ -0,0 +1,78 @@ +# Retiring EncryptedStorage + +Status: **blocked** — the legacy files cannot be deleted yet, and the reader +can never be. + +## The constraint + +Every migration in the preference layer is *lazy*: it reads the legacy store +when it runs, not when the app is installed. Nine come through +`EncryptedStorage` — the seven `CopyOnceMigration`s in `LocalPreferences` plus +the key, secret and roster stores. Only the Cashu counters and calendar +reminders read a plain (non-encrypted) source and would survive its removal. + +So deleting `EncryptedStorage` does not merely affect installs that have not +upgraded yet. It strands anyone who **skips** the release introducing the new +stores: a pre-migration build upgrading straight to a post-deletion build runs +its migration against a reader that no longer exists. Keys, accounts, wallets +and settings stay encrypted on disk with nothing able to read them, and the app +opens as a fresh install. Auto-update off, the F-Droid cadence and restoring +from a backup all skip releases. + +**The reader is permanent.** What a later release can retire is the legacy +*write*, which stops new data landing there while old data stays readable. +`androidx.security.crypto` has to stay for as long as the reader does. That is +an unmaintained-library risk, not an active vulnerability, and a much smaller +cost than stranding users. + +## Outstanding before any legacy file is deleted + +Deletion is only safe for an account whose every key has a new home. These do +not yet, and are still read from the legacy files: + +| key | scope | if deleted today | +|---|---|---| +| `NOSTR_PUBKEY` | per-account | **fatal** — `loadAccountConfigFromEncryptedStorage` returns null without it, so the account disappears even though its private key migrated | +| `LOGIN_WITH_EXTERNAL_SIGNER` | per-account | external-signer accounts stop resolving their signer | +| `SIGNER_PACKAGE_NAME` | per-account | as above | +| `HAS_BACKED_UP_KEYS` | per-account | the key-backup nag returns for everyone | +| `LOCAL_RELAY_SERVERS` | per-account | silently lost | +| `OPEN_BACKUP_CONFLICTS` | per-account | silently lost | +| `SHARED_SETTINGS` | global | UI settings reset | + +## Deliberately not migrated + +Three keys are accepted losses rather than outstanding work — the cost of +losing them is one-off and small, and carrying them is not worth the code: + +| key | what is lost | +|---|---| +| `PENDING_ATTESTATIONS` | queued OTS attestations are not published | +| `NOTIF_GLOBAL_TO_CURATED_MIGRATED` | the one-shot notification filter migration runs once more | +| `LAST_READ_PER_ROUTE` | every feed reads as unread once | + +`NotificationPrefsStore` was given `hasRunGlobalToCuratedMigration`, +`markGlobalToCuratedMigrated`, `lastReadPerRoute` and `saveLastReadPerRoute` +for the last two of these. Nothing ever called them, and now nothing will; +they have been removed rather than left looking like a feature. + +`USE_PROXY` and `PROXY_PORT` need nothing either: they are only ever `remove`d, +being cleaned up rather than read. + +## Order of work + +1. Migrate the seven keys above, on the same dual-store terms as the rest. +2. Add a per-account completeness check — every key present in the new stores — + and only then delete that account's legacy file, after reading back what was + written. +3. Retire the legacy writes once (2) holds for every account on a device. +4. Keep the reader, and the dependency, indefinitely. + +## Verification this needs and has not had + +None of the AndroidKeyStore paths have executed: this environment has no device +or emulator, and `commons` has no Robolectric. What is tested is the decision +logic against fakes. On a real device, before any deletion ships: upgrade an +install holding accounts and confirm they all list; open one and sign; +force-stop and relaunch; add and remove an account; pair a NIP-46 signer; pay +from a wallet. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AccountKeyStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AccountKeyStore.kt index 67dabc7e34..71ac28878d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AccountKeyStore.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AccountKeyStore.kt @@ -75,8 +75,11 @@ class SecureKeyStorageVault( * credential reset, say — falls back rather than presenting the account as * having no key, which would silently demote it to read-only. * - * Nothing is deleted here. Dropping the legacy file is a later release's job, - * once this path has shipped and held; doing both at once leaves no way back. + * Nothing is deleted here, and the legacy *reader* is permanent — see + * [EncryptedStorage]. The migration is lazy, so an install that skips the + * release introducing this store still needs the old file readable when it + * finally arrives. What a later release can drop is the legacy **write**, once + * every key in that file has a new home; several still do not. * * The two stores cannot legitimately disagree: an npub is derived from its * private key, so the key for a given npub never changes. A mismatch means diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AccountRoster.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AccountRoster.kt index 666e508b11..e0a79dcd66 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AccountRoster.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AccountRoster.kt @@ -79,6 +79,9 @@ class EncryptedRosterStorage( * fresh install with no way back to the accounts that are sitting on disk. * So a read that fails or comes back empty falls through to the legacy file * rather than being taken at face value. + * + * The legacy reader stays for good; see [EncryptedStorage] for why a lazy + * migration cannot have its source deleted. */ class AccountRoster( private val store: RosterStorage, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AccountSecretsStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AccountSecretsStore.kt index f7c071219e..6e3c3bcf12 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AccountSecretsStore.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AccountSecretsStore.kt @@ -40,6 +40,9 @@ import okio.Path.Companion.toOkioPath * Losing these is recoverable — the user re-pairs a signer or re-adds a wallet * — but it is not something to spend, so a read that fails falls back to the * legacy values rather than reporting the account as having none. + * + * The legacy reader stays for good; see [EncryptedStorage] for why a lazy + * migration cannot have its source deleted. */ class AccountSecretsStore( private val stores: AccountSecretsEncryptedStores, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/EncryptedStorage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/EncryptedStorage.kt index 324abea1e1..2802812ca9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/EncryptedStorage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/EncryptedStorage.kt @@ -24,6 +24,39 @@ import android.content.Context import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.MasterKey +/** + * The legacy encrypted preference files, and a permanent read-only migration + * source. + * + * # This class cannot be deleted + * + * Every migration in the preference layer is *lazy*: it reads the legacy store + * at the moment it runs, not when the app is installed. Nine of them come + * through here — the seven CopyOnceMigrations under LocalPreferences plus the + * key, secret and roster stores. + * + * So deleting this class does not only affect installs that have not upgraded + * yet. It strands anyone who **skips** the release that introduced the new + * stores: they move from a pre-migration build straight to a post-deletion one, + * the migration runs against a reader that no longer exists, and their keys, + * accounts, wallets and settings sit encrypted on disk with nothing able to + * read them. The app opens as a fresh install. That is not rare — auto-update + * off, the F-Droid cadence, or a restore from backup all skip releases. + * + * What *can* go, once the new path has shipped and held, is the legacy + * **writes**. Dropping those stops new data landing here while this stays able + * to read what is already here. The `androidx.security.crypto` dependency has + * to stay for as long as this does; it is an unmaintained-library risk rather + * than an active vulnerability, and a far smaller cost than stranding users. + * + * # Before any legacy file is deleted + * + * Deletion is only safe for an account whose every key has been migrated, and + * that is not yet true — see `amethyst/plans/2026-09-23-encrypted-storage-retirement.md` + * for what is still outstanding. NOSTR_PUBKEY is the one to watch: without it + * `loadAccountConfigFromEncryptedStorage` returns null and the account + * disappears whether or not its private key survived. + */ class EncryptedStorage { companion object { private const val PREFERENCES_NAME = "secret_keeper" diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/CopyOnceMigration.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/CopyOnceMigration.kt index d1a3be3e89..671684e847 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/CopyOnceMigration.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/CopyOnceMigration.kt @@ -34,8 +34,14 @@ import androidx.datastore.preferences.core.booleanPreferencesKey * run twice, and which makes the migration a one-way door: a build that rolls * back to reading the old store finds the user's settings gone. Here a marker * key in the *destination* records that the copy happened, so the source is - * left untouched and a rollback still works. Deleting the legacy data is a - * separate decision, taken once the migration has shipped and held. + * left untouched and a rollback still works. + * + * Deleting the legacy data is a separate and later decision, and a narrower one + * than it looks: because this runs lazily — on the first read of the + * destination, not at install time — the source has to stay *readable* + * indefinitely for installs that skip the release which introduced the + * destination. What can be retired is writing to the source, once nothing + * still reads a key only it holds. * * [copy] receives the destination and writes whatever it has, so a migration * can carry strings, booleans, int and string sets alike. It is only called diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/NotificationPrefsStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/NotificationPrefsStore.kt index e6de5d5c83..6925a61289 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/NotificationPrefsStore.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/NotificationPrefsStore.kt @@ -25,13 +25,17 @@ import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.emptyPreferences -import androidx.datastore.preferences.core.stringPreferencesKey import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.first import okio.IOException /** * This account's share of the notification settings. + * + * Two keys from the legacy file are deliberately absent: + * `notif_global_to_curated_migrated` and `last_read_per_route` are accepted + * losses rather than migrations — re-running a one-shot filter migration, or + * marking feeds unread once, costs less than the code to carry them. * The global on/off switch is not here — it lives in plain, non-encrypted * storage because the restart layer must read it synchronously from a fresh @@ -55,8 +59,6 @@ class NotificationPrefsStore( val alwaysOnService = booleanPreferencesKey("always_on_notification_service") val showMessagesInNotifications = booleanPreferencesKey("show_messages_in_notifications") val splitNotificationsEnabled = booleanPreferencesKey("split_notifications_enabled") - val globalToCuratedMigrated = booleanPreferencesKey("notif_global_to_curated_migrated") - val lastReadPerRouteJson = stringPreferencesKey("last_read_per_route") } private suspend fun read(): Preferences = @@ -74,32 +76,6 @@ class NotificationPrefsStore( ) } - /** - * Whether the one-shot global-to-curated notification filter migration has - * already run for this account. - * - * Kept out of [NotificationPrefs] and its bulk save because it is not a - * user setting: it is written once, by that migration, and a bulk save that - * carried a stale copy could re-run the migration or wrongly suppress it. - */ - suspend fun hasRunGlobalToCuratedMigration(): Boolean = read()[globalToCuratedMigrated] ?: false - - suspend fun markGlobalToCuratedMigrated() { - store.edit { prefs -> prefs[globalToCuratedMigrated] = true } - } - - /** - * The per-route read markers, as JSON. - * - * Written on its own path as the user reads things, at a different cadence - * from the settings above, so it is not part of [save]. - */ - suspend fun lastReadPerRoute(): String? = read()[lastReadPerRouteJson] - - suspend fun saveLastReadPerRoute(json: String) { - store.edit { prefs -> prefs[lastReadPerRouteJson] = json } - } - /** Writes the whole group in one edit, so a crash cannot half-apply it. */ suspend fun save(value: NotificationPrefs) { store.edit { prefs -> diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountSettingStoresTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountSettingStoresTest.kt index e15c451da8..e6530895a7 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountSettingStoresTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountSettingStoresTest.kt @@ -168,34 +168,6 @@ class AccountSettingStoresTest { assertEquals(false, loaded.splitNotificationsEnabled) } - /** - * The one-shot migration marker is not a user setting, so it must not ride - * along in the bulk save where a stale copy could re-run or suppress it. - */ - @Test - fun theGlobalToCuratedMarkerIsIndependentOfTheBulkSave() = - runTest { - val store = NotificationPrefsStore(raw()) - - assertEquals(false, store.hasRunGlobalToCuratedMigration()) - store.markGlobalToCuratedMigrated() - store.save(NotificationPrefs(alwaysOnService = true)) - - assertEquals("a later bulk save must not clear it", true, store.hasRunGlobalToCuratedMigration()) - assertEquals(true, store.load().alwaysOnService) - } - - @Test - fun lastReadPerRouteIsIndependentOfTheBulkSave() = - runTest { - val store = NotificationPrefsStore(raw()) - - store.saveLastReadPerRoute("""{"home":1}""") - store.save(NotificationPrefs(splitNotificationsEnabled = true)) - - assertEquals("""{"home":1}""", store.lastReadPerRoute()) - } - /** A null string field must clear its key rather than leave the old value behind. */ @Test fun aNullStringFieldClearsTheKey() = From 5e9b6b6f6aa2005a365c0707a6dc7211a18ea504 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 22:13:23 +0000 Subject: [PATCH 13/50] feat: migrate the last legacy preference keys, table-driven MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The seven keys still read only from `secret_keeper` now have a home in the new stores. `nostr_pubkey` is the one that mattered: without it `loadAccountConfigFromEncryptedStorage` returns null and the account disappears from the app with its private key sitting safe and unreachable in the key store. Six are per-account and go into a new AccountIdentityStore in the account's plain DataStore — pubkey, external-signer flag and package, local relay servers, open backup conflicts, and the key-backup flag. All of it is public, and the store file is already named after the npub, so nothing is revealed that the file name does not reveal. Unlike the earlier plain-store groups, the legacy writes stay: a rollback that loses a setting is an annoyance, one that loses the pubkey is an empty app. `has_backed_up_keys` is carried but deliberately kept out of the group's save. The nudge writes it on its own, so a group save would carry a value its caller never knew about and put the nudge back in front of everyone. The seventh, `shared_settings`, is global and had already been superseded by UiSharedPreferences' own DataStore — except nothing ever moved the old blob across, and the fallback only fires when the new store *throws*, not when it is merely empty. It now carries a guarded copy: the destination has to be unsaved (no `ui.theme`) before the old blob is written into it, so an install that has been using the new store keeps everything it has. `saveSharedSettings` went with it, having had no callers since that store landed. Migrations are now tables of (legacy name -> Preferences.Key) owned by each store, rather than blocks of hand-written copy lines. The point is not brevity: a block cannot be asked what it covers, so the check that gates deleting the legacy file would have to restate the list, and a key added to one and not the other is exactly the silent hole that makes deletion unsafe. The rename pairs are worth having in one place too — five of DialogDismissalStore's nine keys changed name on the way in, and `relay_auth_trust_my_relays_and_venues` lost its suffix. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AXvKXakvup4inNFfAhhr4L --- .../amethyst/LegacySharedPreferences.kt | 45 ++++ .../model/preferences/UISharedPreferences.kt | 107 +++++--- .../model/preferences/AccountIdentityStore.kt | 153 ++++++++++++ .../model/preferences/DialogDismissalStore.kt | 22 ++ .../model/preferences/FeedVisibilityStore.kt | 13 + .../preferences/LatestEventCacheStore.kt | 14 ++ .../model/preferences/LegacyKeyTable.kt | 150 +++++++++++ .../preferences/NotificationPrefsStore.kt | 11 + .../model/preferences/RelayAuthStore.kt | 18 ++ .../preferences/TopNavFollowListStore.kt | 14 ++ .../model/preferences/UploadSettingsStore.kt | 14 ++ .../model/preferences/AccountSecrets.kt | 57 +++++ .../preferences/AccountIdentityStoreTest.kt | 200 +++++++++++++++ .../model/preferences/LegacyKeyTableTest.kt | 232 ++++++++++++++++++ 14 files changed, 1017 insertions(+), 33 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/LegacySharedPreferences.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountIdentityStore.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/LegacyKeyTable.kt create mode 100644 commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountIdentityStoreTest.kt create mode 100644 commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/LegacyKeyTableTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LegacySharedPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LegacySharedPreferences.kt new file mode 100644 index 0000000000..a49c151e9e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LegacySharedPreferences.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst + +import android.content.SharedPreferences +import com.vitorpamplona.amethyst.commons.model.preferences.LegacyPreferenceSource + +/** + * [LegacyPreferenceSource] over the `secret_keeper` files. + * + * Every getter reports absence as null rather than as a default, which + * `SharedPreferences` itself cannot do — that distinction is what keeps a + * migration from writing "false" over a key the user never set. + */ +class LegacySharedPreferences( + private val prefs: SharedPreferences, +) : LegacyPreferenceSource { + override fun keys(): Set = prefs.all.keys + + override fun getBoolean(name: String): Boolean? = if (prefs.contains(name)) prefs.getBoolean(name, false) else null + + override fun getString(name: String): String? = prefs.getString(name, null) + + // SharedPreferences hands back the live set and documents that mutating it + // corrupts the file, so this copies before anything downstream can hold it. + override fun getStringSet(name: String): Set? = prefs.getStringSet(name, null)?.toSet() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt index 5909a5a3a7..4148227f82 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt @@ -27,13 +27,16 @@ import androidx.appcompat.app.AppCompatDelegate import androidx.compose.runtime.Stable import androidx.core.content.getSystemService import androidx.core.os.LocaleListCompat +import androidx.datastore.core.DataMigration import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.MutablePreferences import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore import com.vitorpamplona.amethyst.LocalPreferences +import com.vitorpamplona.amethyst.commons.model.preferences.CopyOnceMigration import com.vitorpamplona.amethyst.model.AccentColorType import com.vitorpamplona.amethyst.model.BooleanType import com.vitorpamplona.amethyst.model.ConnectivityType @@ -58,7 +61,11 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.withContext import kotlin.coroutines.cancellation.CancellationException -val Context.sharedPreferencesDataStore: DataStore by preferencesDataStore(name = "shared_settings") +/** The UI settings store. See [UiSharedPreferences.migrations] for the copy it carries. */ +val Context.sharedPreferencesDataStore: DataStore by preferencesDataStore( + name = "shared_settings", + produceMigrations = { UiSharedPreferences.migrations() }, +) @Stable class UiSharedPreferences( @@ -275,43 +282,77 @@ class UiSharedPreferences( } } + /** + * Writes every UI setting into [preferences]. + * + * Shared by [save] and by the one-shot copy out of the old + * `shared_settings` blob, so the two cannot come to disagree about + * which keys a complete set of UI settings has. + */ + internal fun MutablePreferences.write(sharedSettings: UiSettings) { + val preferences = this + preferences[UI_THEME] = sharedSettings.theme.name + preferences[UI_LANGUAGE] = sharedSettings.preferredLanguage ?: "" + preferences[UI_SHOW_IMAGES] = sharedSettings.automaticallyShowImages.name + preferences[UI_START_PLAYBACK] = sharedSettings.automaticallyStartPlayback.name + preferences[UI_PLAY_VIDEOS] = sharedSettings.automaticallyPlayVideos.name + preferences[UI_SHOW_URL_PREVIEW] = sharedSettings.automaticallyShowUrlPreview.name + preferences[UI_HIDE_NAVIGATION_BARS] = sharedSettings.automaticallyHideNavigationBars.name + preferences[UI_SHOW_PROFILE_PICTURES] = sharedSettings.automaticallyShowProfilePictures.name + preferences[UI_DONT_SHOW_PUSH_NOTIFICATION_SELECTOR] = sharedSettings.dontShowPushNotificationSelector + preferences[UI_DONT_ASK_FOR_NOTIFICATION_PERMISSIONS] = sharedSettings.dontAskForNotificationPermissions + preferences[UI_FEATURE_SET] = sharedSettings.featureSet.name + preferences[UI_GALLERY_SET] = sharedSettings.gallerySet.name + preferences[UI_PROPOSE_AI_IMPROVEMENTS] = sharedSettings.automaticallyProposeAiImprovements.name + preferences[UI_USE_TRACKED_BROADCASTS] = sharedSettings.useTrackedBroadcasts.name + preferences[UI_AUTOMATICALLY_CREATE_DRAFTS] = sharedSettings.automaticallyCreateDrafts.name + preferences[UI_SHOW_HOME_NEW_THREADS_TAB] = sharedSettings.showHomeNewThreadsTab + preferences[UI_SHOW_HOME_CONVERSATIONS_TAB] = sharedSettings.showHomeConversationsTab + preferences[UI_SHOW_HOME_EVERYTHING_TAB] = sharedSettings.showHomeEverythingTab + preferences[UI_SHOW_PROFILE_BADGES] = sharedSettings.showProfileBadges + preferences[UI_SHOW_PROFILE_APP_RECOMMENDATIONS] = sharedSettings.showProfileAppRecommendations + preferences[UI_SHOW_PROFILE_ZAP_RECEIVED_FEED] = sharedSettings.showProfileZapReceivedFeed + preferences[UI_SHOW_PROFILE_FOLLOWERS_FEED] = sharedSettings.showProfileFollowersFeed + preferences[UI_DONT_SHOW_ONCHAIN_PUBLIC_WARNING] = sharedSettings.dontShowOnchainPublicWarning + preferences[UI_SUGGEST_WORKOUTS_FROM_HEALTH_CONNECT] = sharedSettings.suggestWorkoutsFromHealthConnect.name + preferences[UI_ACCENT_COLOR] = sharedSettings.accentColor.name + preferences[UI_FONT_FAMILY] = sharedSettings.fontFamily.name + preferences[UI_FONT_SIZE] = sharedSettings.fontSize.name + preferences[UI_COMPOSE_SIGNATURE] = sharedSettings.composeSignature + preferences[UI_SHOW_ONCHAIN_WALLET] = sharedSettings.showOnchainWallet + preferences[UI_SHOW_PAYTO_ZAP_CHIP] = sharedSettings.showPayToZapChip + } + + /** + * The one-shot copy out of the single `shared_settings` JSON blob these + * settings used to be kept as, in the global encrypted file. + * + * Guarded, and it has to be. Unlike the per-account migrations, this + * store has been the real home of these settings for a while, so most + * installs already have a populated one — and copying an old blob over + * it would undo every UI change the user has made since. [UI_THEME] is + * the test: [save] writes every key unconditionally and is the only + * writer, so its absence means this store has never been saved, which + * is exactly the install whose settings are still only in the legacy + * file. + */ + internal fun migrations(): List> = + listOf( + CopyOnceMigration("migrated.sharedSettings") { out -> + if (out[UI_THEME] == null) { + withContext(Dispatchers.IO) { + LocalPreferences.loadSharedSettings()?.let { out.write(it) } + } + } + }, + ) + suspend fun save( sharedSettings: UiSettings, context: Context, ) { try { - context.sharedPreferencesDataStore.edit { preferences -> - preferences[UI_THEME] = sharedSettings.theme.name - preferences[UI_LANGUAGE] = sharedSettings.preferredLanguage ?: "" - preferences[UI_SHOW_IMAGES] = sharedSettings.automaticallyShowImages.name - preferences[UI_START_PLAYBACK] = sharedSettings.automaticallyStartPlayback.name - preferences[UI_PLAY_VIDEOS] = sharedSettings.automaticallyPlayVideos.name - preferences[UI_SHOW_URL_PREVIEW] = sharedSettings.automaticallyShowUrlPreview.name - preferences[UI_HIDE_NAVIGATION_BARS] = sharedSettings.automaticallyHideNavigationBars.name - preferences[UI_SHOW_PROFILE_PICTURES] = sharedSettings.automaticallyShowProfilePictures.name - preferences[UI_DONT_SHOW_PUSH_NOTIFICATION_SELECTOR] = sharedSettings.dontShowPushNotificationSelector - preferences[UI_DONT_ASK_FOR_NOTIFICATION_PERMISSIONS] = sharedSettings.dontAskForNotificationPermissions - preferences[UI_FEATURE_SET] = sharedSettings.featureSet.name - preferences[UI_GALLERY_SET] = sharedSettings.gallerySet.name - preferences[UI_PROPOSE_AI_IMPROVEMENTS] = sharedSettings.automaticallyProposeAiImprovements.name - preferences[UI_USE_TRACKED_BROADCASTS] = sharedSettings.useTrackedBroadcasts.name - preferences[UI_AUTOMATICALLY_CREATE_DRAFTS] = sharedSettings.automaticallyCreateDrafts.name - preferences[UI_SHOW_HOME_NEW_THREADS_TAB] = sharedSettings.showHomeNewThreadsTab - preferences[UI_SHOW_HOME_CONVERSATIONS_TAB] = sharedSettings.showHomeConversationsTab - preferences[UI_SHOW_HOME_EVERYTHING_TAB] = sharedSettings.showHomeEverythingTab - preferences[UI_SHOW_PROFILE_BADGES] = sharedSettings.showProfileBadges - preferences[UI_SHOW_PROFILE_APP_RECOMMENDATIONS] = sharedSettings.showProfileAppRecommendations - preferences[UI_SHOW_PROFILE_ZAP_RECEIVED_FEED] = sharedSettings.showProfileZapReceivedFeed - preferences[UI_SHOW_PROFILE_FOLLOWERS_FEED] = sharedSettings.showProfileFollowersFeed - preferences[UI_DONT_SHOW_ONCHAIN_PUBLIC_WARNING] = sharedSettings.dontShowOnchainPublicWarning - preferences[UI_SUGGEST_WORKOUTS_FROM_HEALTH_CONNECT] = sharedSettings.suggestWorkoutsFromHealthConnect.name - preferences[UI_ACCENT_COLOR] = sharedSettings.accentColor.name - preferences[UI_FONT_FAMILY] = sharedSettings.fontFamily.name - preferences[UI_FONT_SIZE] = sharedSettings.fontSize.name - preferences[UI_COMPOSE_SIGNATURE] = sharedSettings.composeSignature - preferences[UI_SHOW_ONCHAIN_WALLET] = sharedSettings.showOnchainWallet - preferences[UI_SHOW_PAYTO_ZAP_CHIP] = sharedSettings.showPayToZapChip - } + context.sharedPreferencesDataStore.edit { preferences -> preferences.write(sharedSettings) } } catch (e: Exception) { if (e is CancellationException) throw e // Log any errors that occur while reading the DataStore. diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountIdentityStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountIdentityStore.kt new file mode 100644 index 0000000000..f562540913 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountIdentityStore.kt @@ -0,0 +1,153 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.preferences + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.core.stringSetPreferencesKey +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.first +import okio.IOException + +/** + * Who this account is, and the handful of per-account settings that sat beside + * that in the legacy file. + * + * [pubKeyHex] is the one value in the whole preference layer that the account + * cannot be loaded without: the loader returns null the moment it is missing, + * and the account disappears from the app even with its private key safe in the + * key store. Everything here is public — a pubkey, a signer's package name, the + * relay URLs the user typed — and the store file is already named after the + * account's npub, so keeping it in the plain per-account store reveals nothing + * the file name does not. + * + * `hasBackedUpKeys` is deliberately *not* a field here. It is written on its + * own, by the key-backup nudge, at moments unrelated to any of these; folding + * it into the group would mean every [AccountIdentityStore.save] carried a + * value its caller never knew about and would flip the nudge back on. It gets + * its own accessors below. + */ +data class AccountIdentity( + val pubKeyHex: String? = null, + val loginWithExternalSigner: Boolean = false, + val externalSignerPackageName: String? = null, + val localRelayServers: Set = emptySet(), + val openBackupConflictsJson: String? = null, +) + +/** Reads and writes [AccountIdentity] in the account's DataStore. */ +class AccountIdentityStore( + private val store: DataStore, +) { + companion object { + val pubKeyHex = stringPreferencesKey("nostr_pubkey") + val loginWithExternalSigner = booleanPreferencesKey("login_with_external_signer") + val externalSignerPackageName = stringPreferencesKey("signer_package_name") + val localRelayServers = stringSetPreferencesKey("localRelayServers") + val openBackupConflictsJson = stringPreferencesKey("openBackupConflicts") + val hasBackedUpKeys = booleanPreferencesKey("has_backed_up_keys") + + /** + * What the `secret_keeper_` file called these, for the one-shot copy. + * + * `has_backed_up_keys` is carried here even though it is not part of + * [AccountIdentity]: the copy is per *key*, not per group, and losing + * it would put the "back up your key" nudge back in front of every + * user who had already dismissed it. + */ + val legacyTable = + LegacyKeyTable( + "migrated.identity", + listOf( + LegacyStringKey("nostr_pubkey", pubKeyHex), + LegacyBooleanKey("login_with_external_signer", loginWithExternalSigner), + LegacyStringKey("signer_package_name", externalSignerPackageName), + LegacyStringSetKey("localRelayServers", localRelayServers), + LegacyStringKey("openBackupConflicts", openBackupConflictsJson), + LegacyBooleanKey("has_backed_up_keys", hasBackedUpKeys), + ), + ) + } + + private suspend fun read(): Preferences = + store.data + .catch { e -> if (e is IOException) emit(emptyPreferences()) else throw e } + .first() + + suspend fun load(): AccountIdentity { + val prefs = read() + + return AccountIdentity( + pubKeyHex = prefs[pubKeyHex], + loginWithExternalSigner = prefs[loginWithExternalSigner] ?: false, + externalSignerPackageName = prefs[externalSignerPackageName], + localRelayServers = prefs[localRelayServers] ?: emptySet(), + openBackupConflictsJson = prefs[openBackupConflictsJson], + ) + } + + /** + * Writes the whole group in one edit, so a crash cannot half-apply it. + * + * The removes matter as much as the puts and mirror the legacy block + * exactly: an account that drops its external signer, clears its local + * relays or answers its last backup conflict has to end up with those keys + * *absent*, not holding yesterday's value. + */ + suspend fun save(value: AccountIdentity) { + store.edit { prefs -> + value.pubKeyHex.let { if (it != null) prefs[pubKeyHex] = it else prefs.remove(pubKeyHex) } + prefs[loginWithExternalSigner] = value.loginWithExternalSigner + value.externalSignerPackageName.let { if (it != null) prefs[externalSignerPackageName] = it else prefs.remove(externalSignerPackageName) } + value.localRelayServers.let { if (it.isNotEmpty()) prefs[localRelayServers] = it else prefs.remove(localRelayServers) } + value.openBackupConflictsJson.let { if (it != null) prefs[openBackupConflictsJson] = it else prefs.remove(openBackupConflictsJson) } + } + } + + /** + * True unless a freshly generated account still has its key only in the + * app. Absent means true: every account logged in from an existing nsec, + * bunker or external signer already holds its key elsewhere and must not + * be nudged. + */ + suspend fun hasBackedUpKeys(): Boolean = read()[hasBackedUpKeys] ?: true + + suspend fun setHasBackedUpKeys(value: Boolean) { + store.edit { prefs -> prefs[hasBackedUpKeys] = value } + } +} + +/** + * Falls back to [legacy] as a whole when this identity cannot be used. + * + * The test is [AccountIdentity.pubKeyHex], and the fallback is all-or-nothing + * on purpose. A missing pubkey means the store answered from + * `emptyPreferences()` — its file is unreadable, or the one-shot copy never + * ran — and in that state the other fields are equally untrustworthy: an + * absent boolean and a `false` one are the same value here, so merging field + * by field would quietly report an external-signer account as a local one. + * A pubkey present means the store is live and authoritative. + */ +fun AccountIdentity.orIfUnusable(legacy: () -> AccountIdentity): AccountIdentity = if (pubKeyHex != null) this else legacy() diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/DialogDismissalStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/DialogDismissalStore.kt index 870a2bc2c1..5a53bfdacd 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/DialogDismissalStore.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/DialogDismissalStore.kt @@ -68,6 +68,28 @@ class DialogDismissalStore( val mutedPublicChats = stringSetPreferencesKey("mutedPublicChats") val hasDonatedInVersion = stringSetPreferencesKey("hasDonatedInVersion") val viewedPollResultNoteIdsJson = stringPreferencesKey("viewedPollResultNoteIds") + + /** + * What the `secret_keeper_` file called these, for the one-shot copy. + * + * Five of the nine were renamed on the way in, so the pairs below are + * not derivable from either side alone. + */ + val legacyTable = + LegacyKeyTable( + "migrated.dialogDismissal", + listOf( + LegacyBooleanKey("hide_delete_request_dialog", hideDeleteRequestDialog), + LegacyBooleanKey("hide_block_alert_dialog", hideBlockAlertDialog), + LegacyBooleanKey("hide_nip24_warning_dialog", hideNip17WarningDialog), + LegacyBooleanKey("hideCommunityRulesViolations", hideCommunityRulesViolations), + LegacyStringSetKey("dismissed_poll_note_ids", dismissedPollNoteIds), + LegacyStringSetKey("dismissed_channel_invites", dismissedChannelInvites), + LegacyStringSetKey("muted_public_chats", mutedPublicChats), + LegacyStringSetKey("has_donated_in_version", hasDonatedInVersion), + LegacyStringKey("viewed_poll_result_note_ids", viewedPollResultNoteIdsJson), + ), + ) } suspend fun load(): DialogDismissal { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/FeedVisibilityStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/FeedVisibilityStore.kt index f99321a914..a72a628c97 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/FeedVisibilityStore.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/FeedVisibilityStore.kt @@ -59,6 +59,19 @@ class FeedVisibilityStore( val relayGroupViewMode = stringPreferencesKey("relay_group_view_mode") val concordViewMode = stringPreferencesKey("concord_view_mode") val callsEnabled = booleanPreferencesKey("calls_enabled") + + /** What the `secret_keeper_` file called these, for the one-shot copy. */ + val legacyTable = + LegacyKeyTable( + "migrated.feedVisibility", + listOf( + LegacyStringKey("disabled_chat_feeds", disabledChatFeeds), + LegacyStringKey("disabled_home_feed_types", disabledHomeFeedTypes), + LegacyStringKey("relay_group_view_mode", relayGroupViewMode), + LegacyStringKey("concord_view_mode", concordViewMode), + LegacyBooleanKey("calls_enabled", callsEnabled), + ), + ) } suspend fun load(): FeedVisibility { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/LatestEventCacheStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/LatestEventCacheStore.kt index b86f434ee0..f743239662 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/LatestEventCacheStore.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/LatestEventCacheStore.kt @@ -84,6 +84,20 @@ enum class LatestEventSlot( class LatestEventCacheStore( private val store: DataStore, ) { + companion object { + /** + * The one-shot copy out of `secret_keeper_`. + * + * Both names come off the same enum entry, so this table cannot drift + * from the slots the store actually reads. + */ + val legacyTable = + LegacyKeyTable( + "migrated.latestEvents", + LatestEventSlot.entries.map { LegacyStringKey(it.prefKey, it.key) }, + ) + } + /** Only the slots actually present; an absent slot means nothing was cached. */ suspend fun load(): Map { val prefs = diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/LegacyKeyTable.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/LegacyKeyTable.kt new file mode 100644 index 0000000000..6d4e275996 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/LegacyKeyTable.kt @@ -0,0 +1,150 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.preferences + +import androidx.datastore.core.DataMigration +import androidx.datastore.preferences.core.MutablePreferences +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.withContext + +/** + * The older preference file a migration reads from, reduced to the four calls + * a migration makes. + * + * An interface because the legacy store is an Android + * `EncryptedSharedPreferences`, which commonMain cannot name — and because a + * test wants to hand a migration a map rather than a file. + * + * Every getter returns null for an absent key rather than a default, so a + * migration can tell "the user turned this off" from "the user never touched + * it" — the distinction the whole copy depends on, since a key left absent + * keeps reading as unset and falls back to its own default. + */ +interface LegacyPreferenceSource { + /** Every key the file holds, used to spot ones no migration claims. */ + fun keys(): Set + + fun getBoolean(name: String): Boolean? + + fun getString(name: String): String? + + fun getStringSet(name: String): Set? +} + +/** + * One legacy key, and the [Preferences.Key] it lands on. + * + * The legacy name is a compatibility surface: it is the string the Android app + * has written since its first release, and it is frequently *not* the new key's + * name (`has_donated_in_version` became `hasDonatedInVersion`, and five of + * [DialogDismissalStore]'s nine keys were renamed like that). So both names are + * spelled out here rather than derived from one another. + */ +sealed class LegacyKey( + val legacyName: String, + val key: Preferences.Key, +) { + abstract fun read(source: LegacyPreferenceSource): T? + + /** Absent stays absent — see [LegacyPreferenceSource]. */ + fun copyInto( + source: LegacyPreferenceSource, + out: MutablePreferences, + ) { + read(source)?.let { out[key] = it } + } +} + +class LegacyBooleanKey( + legacyName: String, + key: Preferences.Key, +) : LegacyKey(legacyName, key) { + override fun read(source: LegacyPreferenceSource) = source.getBoolean(legacyName) +} + +class LegacyStringKey( + legacyName: String, + key: Preferences.Key, +) : LegacyKey(legacyName, key) { + override fun read(source: LegacyPreferenceSource) = source.getString(legacyName) +} + +class LegacyStringSetKey( + legacyName: String, + key: Preferences.Key>, +) : LegacyKey>(legacyName, key) { + override fun read(source: LegacyPreferenceSource) = source.getStringSet(legacyName) +} + +/** + * The keys one migration carries, as data. + * + * The point of the table is that the copy and the later *check* that the copy + * happened read from the same list. A migration written as a block of + * `if (legacy.contains(k)) out[key] = legacy.getBoolean(k)` lines cannot be + * asked what it covers, so anything verifying it has to restate the list — and + * a key added to one copy and not the other is exactly the silent hole that + * makes deleting the legacy file unsafe. + * + * @param markerName the key recording, in the destination, that this copy has + * run. Distinct per table, so several can run against one store. + */ +class LegacyKeyTable( + val markerName: String, + val keys: List>, +) { + private val marker = booleanPreferencesKey(markerName) + + val legacyNames: Set = keys.mapTo(mutableSetOf()) { it.legacyName } + + /** + * Builds the one-shot copy. + * + * [openSource] is called only when the migration actually runs, so opening + * the legacy file is not a cost paid on every launch — decrypting an + * `EncryptedSharedPreferences` is not free. + */ + fun migration(openSource: () -> LegacyPreferenceSource): DataMigration = + CopyOnceMigration(markerName) { out -> + withContext(Dispatchers.IO) { + val source = openSource() + keys.forEach { it.copyInto(source, out) } + } + } + + /** + * Whether the copy has run against [destination]. + * + * This, and not a value-by-value comparison, is what says the legacy keys + * made it across. [CopyOnceMigration] writes the values and this marker as + * one `Preferences`, which DataStore commits atomically, so the marker + * being set means every value the copy read was written with it. + * + * A comparison would be the wrong question anyway: once migrated, these + * groups are written *only* to the new store, so the legacy file is a + * frozen snapshot and the two are expected to diverge the moment the user + * changes a setting. + */ + fun hasRun(destination: Preferences): Boolean = destination[marker] == true +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/NotificationPrefsStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/NotificationPrefsStore.kt index 6925a61289..fba9ded945 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/NotificationPrefsStore.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/NotificationPrefsStore.kt @@ -59,6 +59,17 @@ class NotificationPrefsStore( val alwaysOnService = booleanPreferencesKey("always_on_notification_service") val showMessagesInNotifications = booleanPreferencesKey("show_messages_in_notifications") val splitNotificationsEnabled = booleanPreferencesKey("split_notifications_enabled") + + /** What the `secret_keeper_` file called these, for the one-shot copy. */ + val legacyTable = + LegacyKeyTable( + "migrated.notificationPrefs", + listOf( + LegacyBooleanKey("always_on_notification_service", alwaysOnService), + LegacyBooleanKey("show_messages_in_notifications", showMessagesInNotifications), + LegacyBooleanKey("split_notifications_enabled", splitNotificationsEnabled), + ), + ) } private suspend fun read(): Preferences = diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/RelayAuthStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/RelayAuthStore.kt index 1f18ed793b..70e3b981e6 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/RelayAuthStore.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/RelayAuthStore.kt @@ -59,6 +59,24 @@ class RelayAuthStore( val trustReadFollows = booleanPreferencesKey("relay_auth_trust_read_follows") val trustMessageFollows = booleanPreferencesKey("relay_auth_trust_message_follows") val trustMessageStrangers = booleanPreferencesKey("relay_auth_trust_message_strangers") + + /** + * What the `secret_keeper_` file called these, for the one-shot copy. + * + * The two "trust my relays" spellings differ: the legacy key grew a + * `_and_venues` suffix that the new one dropped. + */ + val legacyTable = + LegacyKeyTable( + "migrated.relayAuth", + listOf( + LegacyStringKey("default_relay_auth_policy", policyName), + LegacyBooleanKey("relay_auth_trust_my_relays_and_venues", trustMyRelays), + LegacyBooleanKey("relay_auth_trust_read_follows", trustReadFollows), + LegacyBooleanKey("relay_auth_trust_message_follows", trustMessageFollows), + LegacyBooleanKey("relay_auth_trust_message_strangers", trustMessageStrangers), + ), + ) } suspend fun load(): RelayAuth { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/TopNavFollowListStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/TopNavFollowListStore.kt index 1e07d3154c..e14d87a219 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/TopNavFollowListStore.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/TopNavFollowListStore.kt @@ -95,6 +95,20 @@ enum class FollowListSlot( class TopNavFollowListStore( private val store: DataStore, ) { + companion object { + /** + * The one-shot copy out of `secret_keeper_`. + * + * Both names come off the same enum entry, so this table cannot drift + * from the slots the store actually reads. + */ + val legacyTable = + LegacyKeyTable( + "migrated.followLists", + FollowListSlot.entries.map { LegacyStringKey(it.prefKey, it.key) }, + ) + } + /** * Every slot's current filter, falling back to [FollowListSlot.default] * where the key is unset or unreadable. diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/UploadSettingsStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/UploadSettingsStore.kt index c63769dae7..632afa0670 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/UploadSettingsStore.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/UploadSettingsStore.kt @@ -58,6 +58,20 @@ class UploadSettingsStore( val useLocalBlossomCache = booleanPreferencesKey("useLocalBlossomCache") val localBlossomCacheProfilePicturesOnly = booleanPreferencesKey("localBlossomCacheProfilePicturesOnly") val defaultFileServerJson = stringPreferencesKey("defaultFileServer") + + /** What the `secret_keeper_` file called these, for the one-shot copy. */ + val legacyTable = + LegacyKeyTable( + "migrated.uploadSettings", + listOf( + LegacyBooleanKey("stripLocationOnUpload", stripLocationOnUpload), + LegacyBooleanKey("optimizeMediaOnUpload", optimizeMediaOnUpload), + LegacyBooleanKey("mirrorUploadsToAllServers", mirrorUploadsToAllServers), + LegacyBooleanKey("useLocalBlossomCache", useLocalBlossomCache), + LegacyBooleanKey("localBlossomCacheProfilePicturesOnly", localBlossomCacheProfilePicturesOnly), + LegacyStringKey("defaultFileServer", defaultFileServerJson), + ), + ) } suspend fun load(): UploadSettings { diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountSecrets.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountSecrets.kt index 220d73546c..7a18508ff1 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountSecrets.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountSecrets.kt @@ -78,3 +78,60 @@ internal object AccountSecretKeys { */ const val SET_SEPARATOR = "\n" } + +/** + * The names [AccountSecrets] had in the `secret_keeper_` file, and how to + * read a set of them back out. + * + * Unlike the plain-store groups, these are still written to both stores on + * every save, so this is not only a migration source: it is what lets a check + * read the legacy file and the current one and assert they agree before the + * legacy file is deleted. + */ +object LegacyAccountSecretNames { + const val NIP46_SIGNER_ENABLED = "nip46SignerEnabled" + const val NIP46_BUNKER_SECRET = "nip46BunkerSecret" + const val NIP46_TRANSPORT_KEY = "nip46TransportKey" + const val NIP46_SEEN_IDS = "nip46SeenRequestIds" + const val NWC_WALLETS = "nwcWallets" + const val CLINK_DEBIT_WALLETS = "clinkDebitWallets" + const val DEFAULT_PAYMENT_SOURCE_ID = "defaultPaymentSourceId" + const val DEFAULT_NWC_WALLET_ID = "defaultNwcWalletId" + const val ZAP_PAYMENT_REQUEST_SERVER = "zapPaymentServer" + + /** The private key, which lives in its own store rather than in [AccountSecrets]. */ + const val NOSTR_PRIVKEY = "nostr_privkey" + + val all = + setOf( + NIP46_SIGNER_ENABLED, + NIP46_BUNKER_SECRET, + NIP46_TRANSPORT_KEY, + NIP46_SEEN_IDS, + NWC_WALLETS, + CLINK_DEBIT_WALLETS, + DEFAULT_PAYMENT_SOURCE_ID, + DEFAULT_NWC_WALLET_ID, + ZAP_PAYMENT_REQUEST_SERVER, + NOSTR_PRIVKEY, + ) +} + +/** + * The secrets as the legacy file holds them. + * + * Absent keys become the same defaults the loader has always applied, so this + * is directly comparable with what the current store returns. + */ +fun readLegacyAccountSecrets(source: LegacyPreferenceSource) = + AccountSecrets( + nip46SignerEnabled = source.getBoolean(LegacyAccountSecretNames.NIP46_SIGNER_ENABLED) ?: false, + nip46BunkerSecret = source.getString(LegacyAccountSecretNames.NIP46_BUNKER_SECRET) ?: "", + nip46TransportKey = source.getString(LegacyAccountSecretNames.NIP46_TRANSPORT_KEY) ?: "", + nip46SeenRequestIds = source.getStringSet(LegacyAccountSecretNames.NIP46_SEEN_IDS) ?: emptySet(), + nwcWalletsJson = source.getString(LegacyAccountSecretNames.NWC_WALLETS), + clinkDebitWalletsJson = source.getString(LegacyAccountSecretNames.CLINK_DEBIT_WALLETS), + defaultPaymentSourceId = source.getString(LegacyAccountSecretNames.DEFAULT_PAYMENT_SOURCE_ID), + legacyDefaultNwcWalletId = source.getString(LegacyAccountSecretNames.DEFAULT_NWC_WALLET_ID), + legacyZapPaymentRequestServer = source.getString(LegacyAccountSecretNames.ZAP_PAYMENT_REQUEST_SERVER), + ) diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountIdentityStoreTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountIdentityStoreTest.kt new file mode 100644 index 0000000000..f9e19f0e29 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountIdentityStoreTest.kt @@ -0,0 +1,200 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.preferences + +import androidx.datastore.core.DataMigration +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.test.runTest +import okio.Path.Companion.toOkioPath +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +class AccountIdentityStoreTest { + @get:Rule + val folder = TemporaryFolder() + + private var seq = 0 + + private fun raw(migrations: List> = emptyList()): DataStore { + val file = File(folder.root, "identity_${seq++}.preferences_pb") + return PreferenceDataStoreFactory.createWithPath( + scope = CoroutineScope(Dispatchers.IO + SupervisorJob()), + migrations = migrations, + produceFile = { file.toOkioPath() }, + ) + } + + @Test + fun defaultsMatchTheLegacyOnes() = + runTest { + val loaded = AccountIdentityStore(raw()).load() + + assertNull(loaded.pubKeyHex) + assertEquals(false, loaded.loginWithExternalSigner) + assertNull(loaded.externalSignerPackageName) + assertEquals(emptySet(), loaded.localRelayServers) + assertNull(loaded.openBackupConflictsJson) + } + + @Test + fun roundTrip() = + runTest { + val store = AccountIdentityStore(raw()) + val value = + AccountIdentity( + pubKeyHex = "aabbcc", + loginWithExternalSigner = true, + externalSignerPackageName = "com.greenart7c3.nostrsigner", + localRelayServers = setOf("ws://localhost:4869"), + openBackupConflictsJson = """[["a","b","c"]]""", + ) + + store.save(value) + + assertEquals(value, store.load()) + } + + /** + * Dropping an external signer, clearing the local relays or answering the + * last backup conflict has to leave those keys absent — not holding + * yesterday's value. + */ + @Test + fun savingEmptyValuesClearsWhatWasThere() = + runTest { + val store = AccountIdentityStore(raw()) + store.save( + AccountIdentity( + pubKeyHex = "aabbcc", + loginWithExternalSigner = true, + externalSignerPackageName = "com.example.signer", + localRelayServers = setOf("ws://localhost:4869"), + openBackupConflictsJson = "[]", + ), + ) + + store.save(AccountIdentity(pubKeyHex = "aabbcc")) + + val loaded = store.load() + assertEquals("aabbcc", loaded.pubKeyHex) + assertEquals(false, loaded.loginWithExternalSigner) + assertNull(loaded.externalSignerPackageName) + assertEquals(emptySet(), loaded.localRelayServers) + assertNull(loaded.openBackupConflictsJson) + } + + /** Absent means "already backed up elsewhere", so the nudge stays off. */ + @Test + fun hasBackedUpKeysDefaultsToTrue() = + runTest { + assertEquals(true, AccountIdentityStore(raw()).hasBackedUpKeys()) + } + + /** + * The nudge writes this on its own. A group save must not carry a value + * its caller never knew about and flip the nudge back on. + */ + @Test + fun aGroupSaveLeavesHasBackedUpKeysAlone() = + runTest { + val store = AccountIdentityStore(raw()) + store.setHasBackedUpKeys(false) + + store.save(AccountIdentity(pubKeyHex = "aabbcc", localRelayServers = setOf("ws://x"))) + + assertEquals(false, store.hasBackedUpKeys()) + } + + @Test + fun theLegacyCopyCarriesEveryFieldIncludingTheBackupFlag() = + runTest { + val legacy = + FakeLegacySource( + mapOf( + "nostr_pubkey" to "aabbcc", + "login_with_external_signer" to true, + "signer_package_name" to "com.example.signer", + "localRelayServers" to setOf("ws://localhost:4869"), + "openBackupConflicts" to """[["a","b","c"]]""", + "has_backed_up_keys" to false, + ), + ) + + val store = AccountIdentityStore(raw(listOf(AccountIdentityStore.legacyTable.migration { legacy }))) + + assertEquals( + AccountIdentity( + pubKeyHex = "aabbcc", + loginWithExternalSigner = true, + externalSignerPackageName = "com.example.signer", + localRelayServers = setOf("ws://localhost:4869"), + openBackupConflictsJson = """[["a","b","c"]]""", + ), + store.load(), + ) + assertEquals(false, store.hasBackedUpKeys()) + } + + /** + * The fallback is all-or-nothing, and that is the point: with no pubkey + * the store answered from `emptyPreferences()`, where an absent boolean and + * a false one are the same value. Merging field by field would report an + * external-signer account as a local one — and a local one has no signer, + * so the account would go read-only. + */ + @Test + fun anIdentityWithoutAPubKeyFallsBackWholesale() { + val legacy = + AccountIdentity( + pubKeyHex = "aabbcc", + loginWithExternalSigner = true, + externalSignerPackageName = "com.example.signer", + ) + + assertEquals(legacy, AccountIdentity().orIfUnusable { legacy }) + } + + @Test + fun anIdentityWithAPubKeyIsAuthoritative() { + val stored = AccountIdentity(pubKeyHex = "aabbcc") + var called = false + + val result = + stored.orIfUnusable { + called = true + AccountIdentity(pubKeyHex = "ddeeff") + } + + assertSame(stored, result) + assertTrue(!called) + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/LegacyKeyTableTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/LegacyKeyTableTest.kt new file mode 100644 index 0000000000..8b5787bc83 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/LegacyKeyTableTest.kt @@ -0,0 +1,232 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.preferences + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.core.stringSetPreferencesKey +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.job +import kotlinx.coroutines.test.runTest +import okio.Path.Companion.toOkioPath +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +/** A [LegacyPreferenceSource] over a map, standing in for the encrypted file. */ +class FakeLegacySource( + private val values: Map, +) : LegacyPreferenceSource { + var opened = 0 + private set + + init { + opened++ + } + + override fun keys() = values.keys + + override fun getBoolean(name: String) = values[name] as Boolean? + + override fun getString(name: String) = values[name] as String? + + @Suppress("UNCHECKED_CAST") + override fun getStringSet(name: String) = values[name] as Set? +} + +class LegacyKeyTableTest { + @get:Rule + val folder = TemporaryFolder() + + private val flag = booleanPreferencesKey("flag") + private val text = stringPreferencesKey("text") + private val bag = stringSetPreferencesKey("bag") + + private val table = + LegacyKeyTable( + "migrated.test", + listOf( + LegacyBooleanKey("legacy_flag", flag), + LegacyStringKey("legacy_text", text), + LegacyStringSetKey("legacy_bag", bag), + ), + ) + + private var seq = 0 + + private fun store( + source: () -> LegacyPreferenceSource, + name: String = "t_${seq++}", + ): DataStore { + val file = File(folder.root, "$name.preferences_pb") + return PreferenceDataStoreFactory.createWithPath( + scope = CoroutineScope(Dispatchers.IO + SupervisorJob()), + migrations = listOf(table.migration(source)), + produceFile = { file.toOkioPath() }, + ) + } + + @Test + fun copiesEveryTypeAcrossTheRename() = + runTest { + val legacy = + FakeLegacySource( + mapOf( + "legacy_flag" to true, + "legacy_text" to "hello", + "legacy_bag" to setOf("a", "b"), + ), + ) + + val prefs = store({ legacy }).data.first() + + assertEquals(true, prefs[flag]) + assertEquals("hello", prefs[text]) + assertEquals(setOf("a", "b"), prefs[bag]) + } + + /** + * The distinction the whole copy rests on: a key the user never set must + * stay absent, so it keeps reading as unset and falls back to its own + * default. Writing `false` here would turn off features whose default is on. + */ + @Test + fun anAbsentLegacyKeyStaysAbsent() = + runTest { + val prefs = store({ FakeLegacySource(mapOf("legacy_text" to "only me")) }).data.first() + + assertEquals("only me", prefs[text]) + assertNull(prefs[flag]) + assertNull(prefs[bag]) + } + + @Test + fun hasRunReportsTheMarker() = + runTest { + assertFalse(table.hasRun(emptyPreferences())) + + val prefs = store({ FakeLegacySource(emptyMap()) }).data.first() + + assertTrue(table.hasRun(prefs)) + } + + /** + * Decrypting an `EncryptedSharedPreferences` is not free, so the legacy + * file must not be opened on launches where the copy has already run. + */ + @Test + fun theLegacyFileIsNotOpenedOnceTheCopyHasRun() = + runTest { + var opens = 0 + val open = { + opens++ + FakeLegacySource(mapOf("legacy_text" to "x")) as LegacyPreferenceSource + } + val file = File(folder.root, "reopen.preferences_pb") + + // DataStore registers a live store per file path and only releases + // it when the owning scope ends, so each "launch" gets its own + // scope and gives it back. + fun open(scope: CoroutineScope) = + PreferenceDataStoreFactory.createWithPath( + scope = scope, + migrations = listOf(table.migration(open)), + produceFile = { file.toOkioPath() }, + ) + + val first = CoroutineScope(Dispatchers.IO + SupervisorJob()) + assertEquals("x", open(first).data.first()[text]) + assertEquals(1, opens) + first.cancel() + first.coroutineContext.job.join() + + val second = CoroutineScope(Dispatchers.IO + SupervisorJob()) + assertEquals("x", open(second).data.first()[text]) + assertEquals(1, opens) + second.cancel() + } + + /** + * The legacy names are what the Android app has written since its first + * release. A rename here resets that setting for everyone who had it, so + * the list is pinned rather than regenerated. + */ + @Test + fun theShippedTablesCoverTheirKeys() { + assertEquals( + setOf( + "stripLocationOnUpload", + "optimizeMediaOnUpload", + "mirrorUploadsToAllServers", + "useLocalBlossomCache", + "localBlossomCacheProfilePicturesOnly", + "defaultFileServer", + ), + UploadSettingsStore.legacyTable.legacyNames, + ) + + assertEquals( + setOf( + "nostr_pubkey", + "login_with_external_signer", + "signer_package_name", + "localRelayServers", + "openBackupConflicts", + "has_backed_up_keys", + ), + AccountIdentityStore.legacyTable.legacyNames, + ) + + assertEquals(FollowListSlot.entries.size, TopNavFollowListStore.legacyTable.keys.size) + assertEquals(LatestEventSlot.entries.size, LatestEventCacheStore.legacyTable.keys.size) + } + + /** Several tables share one store, so their markers must not collide. */ + @Test + fun everyShippedMarkerIsDistinct() { + val markers = + listOf( + TopNavFollowListStore.legacyTable, + LatestEventCacheStore.legacyTable, + UploadSettingsStore.legacyTable, + DialogDismissalStore.legacyTable, + RelayAuthStore.legacyTable, + FeedVisibilityStore.legacyTable, + NotificationPrefsStore.legacyTable, + AccountIdentityStore.legacyTable, + ).map { it.markerName } + + assertEquals(markers.size, markers.toSet().size) + } +} From 75a0f738c8b738e0e5fc3531050e5e8090e154e3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 22:25:18 +0000 Subject: [PATCH 14/50] feat: gate deleting a legacy preference file behind a read-back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LegacyPreferenceCleanup runs after each successful account load and deletes that account's `secret_keeper_` file only when it can show nothing in it would be lost. It refuses today, deliberately, and says why. The check is not one rule, because the two halves of the migration are in different states. The plain per-account groups stopped being legacy-written when they moved, so that file is a frozen snapshot of the day they migrated — comparing values would flag every setting the user has changed since. For those the question is "did the copy run", which the migration marker answers exactly: CopyOnceMigration commits the values and the marker as one Preferences, so the marker cannot be set without them. The secrets and the private key *are* still written to both stores, so for those the stronger question is available and is asked: read both back, require them to agree. Coverage runs from the file's own keys rather than a checklist. A checklist fails silently in the one direction that matters — a key added later that no migration carries — so instead every key present must be claimed by a table, be one of the secrets, or be on the accepted-loss list, and anything else stops the deletion by name. LegacyKeyCoverageTest reflects over PrefKeys and fails the build if a key falls outside all of those, so that surfaces at the commit that adds it rather than as a file that quietly never gets deleted. A store that cannot be read is a reason, never a pass. Nothing is deleted yet, and not because the check fails: LEGACY_WRITES_RETIRED is false. The identity, key, secret and roster stores still mirror into the legacy file so a rolled-back build finds a complete account, and while that is true, deleting the file achieves nothing — the next save recreates it — and would look like it had worked. Retiring those writes ends the rollback window and waits on the device pass, which nothing here has had: no AndroidKeyStore path has executed in this environment, so what is tested is the decision logic against fakes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AXvKXakvup4inNFfAhhr4L --- ...2026-09-23-encrypted-storage-retirement.md | 109 ++++-- .../vitorpamplona/amethyst/AccountKeyStore.kt | 7 + .../amethyst/AccountSecretsStore.kt | 8 + .../amethyst/LegacyPreferenceCleanup.kt | 302 ++++++++++++++++ .../amethyst/LocalPreferences.kt | 283 ++++++++------- .../amethyst/LegacyKeyCoverageTest.kt | 117 +++++++ .../amethyst/LegacyPreferenceCleanupTest.kt | 321 ++++++++++++++++++ 7 files changed, 981 insertions(+), 166 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/LegacyPreferenceCleanup.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/LegacyKeyCoverageTest.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/LegacyPreferenceCleanupTest.kt diff --git a/amethyst/plans/2026-09-23-encrypted-storage-retirement.md b/amethyst/plans/2026-09-23-encrypted-storage-retirement.md index 4a6ff1ab2f..ceb220068c 100644 --- a/amethyst/plans/2026-09-23-encrypted-storage-retirement.md +++ b/amethyst/plans/2026-09-23-encrypted-storage-retirement.md @@ -1,15 +1,17 @@ # Retiring EncryptedStorage -Status: **blocked** — the legacy files cannot be deleted yet, and the reader -can never be. +Status: **migrated, not yet deleted.** Every key has a home in the new stores. +The legacy files are still written, so they are still there — and the reader +can never go. ## The constraint Every migration in the preference layer is *lazy*: it reads the legacy store -when it runs, not when the app is installed. Nine come through -`EncryptedStorage` — the seven `CopyOnceMigration`s in `LocalPreferences` plus -the key, secret and roster stores. Only the Cashu counters and calendar -reminders read a plain (non-encrypted) source and would survive its removal. +when it runs, not when the app is installed. Ten come through +`EncryptedStorage` — the eight `LegacyKeyTable` copies on the per-account +DataStore plus the key, secret and roster stores. Only the Cashu counters and +calendar reminders read a plain (non-encrypted) source and would survive its +removal. So deleting `EncryptedStorage` does not merely affect installs that have not upgraded yet. It strands anyone who **skips** the release introducing the new @@ -25,54 +27,89 @@ from a backup all skip releases. an unmaintained-library risk, not an active vulnerability, and a much smaller cost than stranding users. -## Outstanding before any legacy file is deleted +## Where each key went -Deletion is only safe for an account whose every key has a new home. These do -not yet, and are still read from the legacy files: +`LegacyKeyCoverageTest` holds this to being exhaustive: every constant in +`PrefKeys` is either claimed by a migration table, one of the secrets, on the +accepted-loss list, or a key of the global file. A key added to `PrefKeys` and +to none of those fails that test at the commit that adds it. -| key | scope | if deleted today | +| group | destination | legacy write | |---|---|---| -| `NOSTR_PUBKEY` | per-account | **fatal** — `loadAccountConfigFromEncryptedStorage` returns null without it, so the account disappears even though its private key migrated | -| `LOGIN_WITH_EXTERNAL_SIGNER` | per-account | external-signer accounts stop resolving their signer | -| `SIGNER_PACKAGE_NAME` | per-account | as above | -| `HAS_BACKED_UP_KEYS` | per-account | the key-backup nag returns for everyone | -| `LOCAL_RELAY_SERVERS` | per-account | silently lost | -| `OPEN_BACKUP_CONFLICTS` | per-account | silently lost | -| `SHARED_SETTINGS` | global | UI settings reset | +| follow lists, cached events, upload, dialogs, relay auth, feed visibility, notifications | the account's plain DataStore | already retired | +| identity — pubkey, signer, local relays, backup conflicts, backup flag | the account's plain DataStore | **kept** | +| private key | `SecureKeyStorage` | **kept** | +| NIP-46 material, wallets, payment source | the account's encrypted DataStore | **kept** | +| current account, saved accounts | the encrypted roster store | **kept** | +| UI settings (`shared_settings`) | `UiSharedPreferences`' own DataStore | none left | + +The three stores that still mirror are the ones whose loss is not an +annoyance: an account that cannot be listed, signed with, or paid from. They +keep the rollback window open until the device pass below has happened. + +The UI settings copy is **guarded** where the others are not. That store has +been the real home of these settings for a while, so most installs already +have a populated one and copying the old blob over it would undo every UI +change since. The copy only runs into a store that has never been saved +(`ui.theme` absent, which `save` always writes). ## Deliberately not migrated -Three keys are accepted losses rather than outstanding work — the cost of -losing them is one-off and small, and carrying them is not worth the code: - | key | what is lost | |---|---| | `PENDING_ATTESTATIONS` | queued OTS attestations are not published | | `NOTIF_GLOBAL_TO_CURATED_MIGRATED` | the one-shot notification filter migration runs once more | | `LAST_READ_PER_ROUTE` | every feed reads as unread once | +| `USE_PROXY`, `PROXY_PORT` | nothing — only ever removed, never read | +| `TOR_SETTINGS` | nothing — no reader left anywhere | -`NotificationPrefsStore` was given `hasRunGlobalToCuratedMigration`, -`markGlobalToCuratedMigrated`, `lastReadPerRoute` and `saveLastReadPerRoute` -for the last two of these. Nothing ever called them, and now nothing will; -they have been removed rather than left looking like a feature. +These are listed in `LegacyAccountKeys.accepted`, which is what lets the +cleanup treat any *other* unclaimed key as a reason to keep the file. -`USE_PROXY` and `PROXY_PORT` need nothing either: they are only ever `remove`d, -being cleaned up rather than read. +## Deleting a legacy file + +`LegacyPreferenceCleanup` runs after every successful account load and deletes +that account's file only when it can prove nothing would be lost: + +1. **Every key in the file is accounted for** — claimed by a table, one of the + secrets, or on the accepted list. Driven from the file's own keys, not from + a checklist, because a checklist fails silently in the one direction that + matters. +2. **Every copy that had something to copy has run.** Marker-based, not a value + comparison: those groups stopped being legacy-written when they moved, so + the file is a frozen snapshot and the two are *expected* to diverge as soon + as the user changes a setting. `CopyOnceMigration` commits the values and + its marker as one `Preferences`, so the marker cannot be set without them. +3. **The secrets and the private key read back identical** from the current + stores. Those *are* still dual-written, so the stronger question is + available and is asked. +4. A store that cannot be read is a reason, never a pass. + +It refuses today, and says so, because of the fifth condition: +`LEGACY_WRITES_RETIRED` is false. While the app still mirrors into the file, +deleting it achieves nothing — the next save recreates it — and would look +like it had worked. ## Order of work -1. Migrate the seven keys above, on the same dual-store terms as the rest. -2. Add a per-account completeness check — every key present in the new stores — - and only then delete that account's legacy file, after reading back what was - written. -3. Retire the legacy writes once (2) holds for every account on a device. -4. Keep the reader, and the dependency, indefinitely. +1. ~~Migrate the remaining keys.~~ Done. +2. ~~Gate deletion on a per-account read-back.~~ Done. +3. Do the device pass below. +4. Flip `LEGACY_WRITES_RETIRED` and drop the legacy writes for the identity, + key, secret and roster stores. This ends the rollback window, so it is a + release of its own. +5. Keep the reader, and `androidx.security.crypto`, indefinitely. ## Verification this needs and has not had None of the AndroidKeyStore paths have executed: this environment has no device or emulator, and `commons` has no Robolectric. What is tested is the decision -logic against fakes. On a real device, before any deletion ships: upgrade an -install holding accounts and confirm they all list; open one and sign; -force-stop and relaunch; add and remove an account; pair a NIP-46 signer; pay -from a wallet. +logic against fakes — which is why step 3 is not optional, and why deletion is +the one irreversible step in the whole series. + +On a real device, before step 4 ships: upgrade an install holding accounts and +confirm they all list; open one and sign; force-stop and relaunch; add and +remove an account; pair a NIP-46 signer; pay from a wallet; check the +key-backup nudge stays dismissed; confirm UI settings survive the upgrade. +Then let the cleanup run with the flag flipped, and confirm the files are gone +and everything above still holds on the next cold start. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AccountKeyStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AccountKeyStore.kt index 71ac28878d..5ef8b4af68 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AccountKeyStore.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AccountKeyStore.kt @@ -168,6 +168,13 @@ class AccountKeyStore( } } + /** + * What the current store holds, with no fallback to the legacy value. + * + * For [LegacyPreferenceCleanup]; [read] deliberately hides this distinction. + */ + suspend fun stored(npub: String): String? = vault.get(npub) + /** Drops the key from the new store; the caller clears the legacy file itself. */ suspend fun delete(npub: String) { try { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AccountSecretsStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AccountSecretsStore.kt index 6e3c3bcf12..e60aaa48cf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AccountSecretsStore.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AccountSecretsStore.kt @@ -90,6 +90,14 @@ class AccountSecretsStore( } } + /** + * What the current store holds, with no fallback to the legacy file. + * + * For [LegacyPreferenceCleanup], which has to tell "migrated" from + * "falling back and looking migrated" — the read above deliberately cannot. + */ + suspend fun stored(npub: String): AccountSecrets? = stores.loadSecrets(npub) + suspend fun delete(npub: String) { try { stores.removeAccount(npub) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LegacyPreferenceCleanup.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LegacyPreferenceCleanup.kt new file mode 100644 index 0000000000..fc7973abe6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LegacyPreferenceCleanup.kt @@ -0,0 +1,302 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst + +import androidx.datastore.preferences.core.Preferences +import com.vitorpamplona.amethyst.commons.model.preferences.AccountIdentityStore +import com.vitorpamplona.amethyst.commons.model.preferences.AccountSecrets +import com.vitorpamplona.amethyst.commons.model.preferences.DialogDismissalStore +import com.vitorpamplona.amethyst.commons.model.preferences.FeedVisibilityStore +import com.vitorpamplona.amethyst.commons.model.preferences.LatestEventCacheStore +import com.vitorpamplona.amethyst.commons.model.preferences.LegacyAccountSecretNames +import com.vitorpamplona.amethyst.commons.model.preferences.LegacyKeyTable +import com.vitorpamplona.amethyst.commons.model.preferences.LegacyPreferenceSource +import com.vitorpamplona.amethyst.commons.model.preferences.NotificationPrefsStore +import com.vitorpamplona.amethyst.commons.model.preferences.RelayAuthStore +import com.vitorpamplona.amethyst.commons.model.preferences.TopNavFollowListStore +import com.vitorpamplona.amethyst.commons.model.preferences.UploadSettingsStore +import com.vitorpamplona.amethyst.commons.model.preferences.readLegacyAccountSecrets +import com.vitorpamplona.quartz.utils.Log + +/** + * How every key that can appear in a `secret_keeper_` file is accounted + * for. + * + * Together with [LegacyAccountSecretNames], these two lists are what let + * [LegacyPreferenceCleanup] treat any *other* key in the file as a reason not + * to delete it. `LegacyKeyCoverageTest` holds them to covering all of + * `PrefKeys`, so a key added later cannot quietly fall outside both. + */ +internal object LegacyAccountKeys { + /** + * The one-shot copies out of the account's legacy file. + * + * Each store owns the table of legacy names it came from, so the copy and + * the check that the copy happened read the same list — see [LegacyKeyTable]. + */ + val tables = + listOf( + TopNavFollowListStore.legacyTable, + LatestEventCacheStore.legacyTable, + UploadSettingsStore.legacyTable, + DialogDismissalStore.legacyTable, + RelayAuthStore.legacyTable, + FeedVisibilityStore.legacyTable, + NotificationPrefsStore.legacyTable, + AccountIdentityStore.legacyTable, + ) + + /** + * Keys that are deliberately not carried across. + * + * Each costs something once and nothing after, and none is worth the code + * to move it: queued attestations go unpublished, the one-shot + * Global -> Curated notification rewrite runs one more time, and every feed + * reads as unread once. `use_proxy` and `proxy_port` are only ever removed, + * never read, and `tor_settings` has no reader left at all. + */ + val accepted = + setOf( + PrefKeys.PENDING_ATTESTATIONS, + PrefKeys.NOTIF_GLOBAL_TO_CURATED_MIGRATED, + PrefKeys.LAST_READ_PER_ROUTE, + PrefKeys.USE_PROXY, + PrefKeys.PROXY_PORT, + PrefKeys.TOR_SETTINGS, + ) +} + +/** What [LegacyPreferenceCleanup] did, and why. */ +sealed interface LegacyCleanupResult { + /** There was no legacy file for this account. */ + data object NothingToDelete : LegacyCleanupResult + + data object Deleted : LegacyCleanupResult + + /** Nothing was touched. Each reason names one thing that would have been lost. */ + data class Kept( + val reasons: List, + ) : LegacyCleanupResult +} + +/** The per-account legacy file, as this needs it. */ +interface LegacyAccountFiles { + fun source(npub: String): LegacyPreferenceSource + + fun exists(npub: String): Boolean + + /** Returns false when there was nothing to delete. */ + suspend fun delete(npub: String): Boolean +} + +/** What the current, encrypted stores hold for an account. */ +interface MigratedSecrets { + /** Null when this account has not been copied across yet. */ + suspend fun secrets(npub: String): AccountSecrets? + + /** Null only when the account genuinely has no private key. Throws when the store is unreadable. */ + suspend fun privateKey(npub: String): String? +} + +/** + * Deletes an account's `secret_keeper_` file, but only once it can prove + * nothing in it would be lost. + * + * # Why the check is not one rule + * + * The two halves of the migration are in different states, and asking the same + * question of both would give the wrong answer for one of them. + * + * The plain per-account groups — settings, dialogs, feeds, cached events — + * stopped being written to the legacy file when they moved, so that file is a + * frozen snapshot of the day they migrated. Comparing values would flag every + * setting the user has changed since. What is actually being asked of them is + * "did the copy run", and [LegacyKeyTable.hasRun] answers it exactly: + * `CopyOnceMigration` writes the values and its marker as a single + * `Preferences`, committed atomically, so the marker cannot be set without them. + * + * The secrets and the private key are still written to *both* stores on every + * save, so for those the stronger question is available and is asked: read both + * back and require them to agree. + * + * # Why an unrecognised key blocks + * + * A list of keys to check, maintained by hand, fails silently in the one + * direction that matters: a key added later that no migration carries. So the + * check runs the other way round — every key *in the file* must be claimed by + * a table, be one of the secrets, or be on [accepted], the short list of + * deliberate losses. Anything else stops the deletion and says so by name. + * + * # Cost + * + * [LegacyPreferenceSource.keys] goes through `EncryptedSharedPreferences.all`, + * which decrypts every value in the file — there is no keys-only API. It is + * called from the one place an account load is not already cached, so it costs + * at most once per account per process, and nothing at all while + * [legacyWritesRetired] is false. + * + * # Why deletion also waits on the legacy writes + * + * [legacyWritesRetired] is the other half. While the app still mirrors into + * this file on every save, deleting it achieves nothing — the next save + * recreates it, with a subset of what was there. Worse, it would look like it + * had worked. So the file is only removed once it is no longer being written, + * which is a separate release from this one. + */ +class LegacyPreferenceCleanup( + private val tables: List, + private val accepted: Set, + private val files: LegacyAccountFiles, + private val currentStore: suspend (String) -> Preferences, + private val secrets: MigratedSecrets, + private val legacyWritesRetired: Boolean, +) { + companion object { + private const val TAG = "LegacyPreferenceCleanup" + + const val STILL_WRITTEN = "the legacy file is still written on every save" + } + + private val claimed: Set = tables.flatMapTo(mutableSetOf()) { it.legacyNames } + LegacyAccountSecretNames.all + + /** + * Everything that would be lost by deleting this account's legacy file. + * Empty means nothing would be. + * + * A store that cannot be read is a reason, never a pass: the whole point is + * to be sure, and "the check itself failed" is not sure. + */ + suspend fun verify(npub: String): List { + val legacy = + try { + files.source(npub) + } catch (e: Exception) { + Log.w(TAG, "Could not open the legacy file for $npub", e) + return listOf("the legacy file could not be read") + } + + val reasons = mutableListOf() + + val present = legacy.keys() + (present - claimed - accepted).sorted().forEach { + reasons += "no migration claims '$it'" + } + + val current = + try { + currentStore(npub) + } catch (e: Exception) { + Log.w(TAG, "Could not read the current store for $npub", e) + return reasons + "the current store could not be read" + } + + tables.forEach { table -> + // A table whose keys the file never held has nothing to prove. + if (present.none { it in table.legacyNames }) return@forEach + if (!table.hasRun(current)) reasons += "the '${table.markerName}' copy has not run" + } + + reasons += secretMismatches(npub, legacy) + + return reasons + } + + private suspend fun secretMismatches( + npub: String, + legacy: LegacyPreferenceSource, + ): List { + val expected = readLegacyAccountSecrets(legacy) + val reasons = mutableListOf() + + try { + val stored = secrets.secrets(npub) + when { + stored == null -> reasons += "the secrets have not been copied across" + // Field names only. These values are bunker secrets and wallet + // connection strings; a log line is the last place for them. + stored != expected -> reasons += "the stored secrets differ from the legacy file: ${differingFields(expected, stored)}" + } + } catch (e: Exception) { + Log.w(TAG, "Could not read the secrets store for $npub", e) + reasons += "the secrets store could not be read" + } + + val legacyKey = legacy.getString(LegacyAccountSecretNames.NOSTR_PRIVKEY) + if (legacyKey != null) { + try { + when (secrets.privateKey(npub)) { + null -> reasons += "the private key has not been copied across" + legacyKey -> Unit + else -> reasons += "the stored private key differs from the legacy file" + } + } catch (e: Exception) { + Log.w(TAG, "Could not read the key store for $npub", e) + reasons += "the key store could not be read" + } + } + + return reasons + } + + private fun differingFields( + expected: AccountSecrets, + stored: AccountSecrets, + ): String = + listOfNotNull( + "nip46SignerEnabled".takeIf { expected.nip46SignerEnabled != stored.nip46SignerEnabled }, + "nip46BunkerSecret".takeIf { expected.nip46BunkerSecret != stored.nip46BunkerSecret }, + "nip46TransportKey".takeIf { expected.nip46TransportKey != stored.nip46TransportKey }, + "nip46SeenRequestIds".takeIf { expected.nip46SeenRequestIds != stored.nip46SeenRequestIds }, + "nwcWallets".takeIf { expected.nwcWalletsJson != stored.nwcWalletsJson }, + "clinkDebitWallets".takeIf { expected.clinkDebitWalletsJson != stored.clinkDebitWalletsJson }, + "defaultPaymentSourceId".takeIf { expected.defaultPaymentSourceId != stored.defaultPaymentSourceId }, + "defaultNwcWalletId".takeIf { expected.legacyDefaultNwcWalletId != stored.legacyDefaultNwcWalletId }, + "zapPaymentServer".takeIf { expected.legacyZapPaymentRequestServer != stored.legacyZapPaymentRequestServer }, + ).joinToString() + + /** + * Deletes the account's legacy file if — and only if — [verify] comes back + * empty and the app has stopped writing to it. + */ + suspend fun deleteIfVerified(npub: String): LegacyCleanupResult { + if (!files.exists(npub)) return LegacyCleanupResult.NothingToDelete + + if (!legacyWritesRetired) return LegacyCleanupResult.Kept(listOf(STILL_WRITTEN)) + + val reasons = verify(npub) + if (reasons.isNotEmpty()) { + Log.i(TAG) { "Keeping the legacy file for $npub: ${reasons.joinToString("; ")}" } + return LegacyCleanupResult.Kept(reasons) + } + + return try { + if (files.delete(npub)) { + Log.i(TAG) { "Deleted the migrated legacy file for $npub" } + LegacyCleanupResult.Deleted + } else { + LegacyCleanupResult.NothingToDelete + } + } catch (e: Exception) { + Log.w(TAG, "Could not delete the legacy file for $npub", e) + LegacyCleanupResult.Kept(listOf("the legacy file could not be deleted")) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 37e72033e3..9df3f2a2f5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -34,6 +34,8 @@ import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerName import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupViewMode import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntry import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntryNorm +import com.vitorpamplona.amethyst.commons.model.preferences.AccountIdentity +import com.vitorpamplona.amethyst.commons.model.preferences.AccountIdentityStore import com.vitorpamplona.amethyst.commons.model.preferences.AccountPreferenceStores import com.vitorpamplona.amethyst.commons.model.preferences.AccountSecrets import com.vitorpamplona.amethyst.commons.model.preferences.CopyOnceMigration @@ -44,6 +46,7 @@ import com.vitorpamplona.amethyst.commons.model.preferences.FeedVisibilityStore import com.vitorpamplona.amethyst.commons.model.preferences.FollowListSlot import com.vitorpamplona.amethyst.commons.model.preferences.LatestEventCacheStore import com.vitorpamplona.amethyst.commons.model.preferences.LatestEventSlot +import com.vitorpamplona.amethyst.commons.model.preferences.LegacyPreferenceSource import com.vitorpamplona.amethyst.commons.model.preferences.NotificationPrefs import com.vitorpamplona.amethyst.commons.model.preferences.NotificationPrefsStore import com.vitorpamplona.amethyst.commons.model.preferences.RelayAuth @@ -51,12 +54,15 @@ import com.vitorpamplona.amethyst.commons.model.preferences.RelayAuthStore import com.vitorpamplona.amethyst.commons.model.preferences.TopNavFollowListStore import com.vitorpamplona.amethyst.commons.model.preferences.UploadSettings import com.vitorpamplona.amethyst.commons.model.preferences.UploadSettingsStore +import com.vitorpamplona.amethyst.commons.model.preferences.orIfUnusable +import com.vitorpamplona.amethyst.commons.model.preferences.readLegacyAccountSecrets import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.model.UiSettings import com.vitorpamplona.amethyst.model.backups.BackupConflictStorage import com.vitorpamplona.amethyst.model.nip60Cashu.CashuPreferences +import com.vitorpamplona.amethyst.model.preferences.UiSharedPreferences import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent @@ -98,6 +104,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext @@ -120,7 +127,7 @@ data class AccountInfo( val isTransient: Boolean = false, ) -private object PrefKeys { +internal object PrefKeys { const val CURRENT_ACCOUNT = "currently_logged_in_account" // Global (non-account) master switch for the always-on notification service. @@ -268,12 +275,13 @@ object LocalPreferences { private val cachedAccounts: MutableMap = mutableMapOf() /** - * The per-account DataStore, and the top-nav filter selections inside it. + * The per-account DataStore: every non-secret setting this account has. * - * Each account's store carries a [CopyOnceMigration] that lifts the filters - * out of that account's legacy encrypted SharedPreferences the first time - * the store is read. The copy leaves the legacy keys in place, so a build - * that reads the old location still works — see [CopyOnceMigration]. + * Each account's store carries one [CopyOnceMigration] per group in + * [LegacyAccountKeys.tables], lifting that group out of the account's legacy + * encrypted SharedPreferences the first time the store is read. The copies + * leave the legacy keys in place, so a build that reads the old location + * still works — see [CopyOnceMigration]. */ private val accountStores: AccountPreferenceStores by lazy { AccountPreferenceStores( @@ -282,8 +290,7 @@ object LocalPreferences { .toOkioPath() }, migrations = { npub -> - listOf(followListMigration(npub), latestEventMigration(npub)) + - listOf(uploadSettingsMigration(npub), dialogDismissalMigration(npub), relayAuthMigration(npub), feedVisibilityMigration(npub), notificationPrefsMigration(npub)) + LegacyAccountKeys.tables.map { it.migration { legacySource(npub) } } }, ) } @@ -302,68 +309,64 @@ object LocalPreferences { private fun notificationPrefsStore(npub: String) = NotificationPrefsStore(accountStores.getDataStore(npub)) - private fun uploadSettingsMigration(npub: String) = - CopyOnceMigration("migrated.uploadSettings") { out -> - withContext(Dispatchers.IO) { - val legacy = encryptedPreferences(npub) - if (legacy.contains(PrefKeys.STRIP_LOCATION_ON_UPLOAD)) out[UploadSettingsStore.stripLocationOnUpload] = legacy.getBoolean(PrefKeys.STRIP_LOCATION_ON_UPLOAD, false) - if (legacy.contains(PrefKeys.OPTIMIZE_MEDIA_ON_UPLOAD)) out[UploadSettingsStore.optimizeMediaOnUpload] = legacy.getBoolean(PrefKeys.OPTIMIZE_MEDIA_ON_UPLOAD, false) - if (legacy.contains(PrefKeys.MIRROR_UPLOADS_TO_ALL_SERVERS)) out[UploadSettingsStore.mirrorUploadsToAllServers] = legacy.getBoolean(PrefKeys.MIRROR_UPLOADS_TO_ALL_SERVERS, false) - if (legacy.contains(PrefKeys.USE_LOCAL_BLOSSOM_CACHE)) out[UploadSettingsStore.useLocalBlossomCache] = legacy.getBoolean(PrefKeys.USE_LOCAL_BLOSSOM_CACHE, false) - if (legacy.contains(PrefKeys.LOCAL_BLOSSOM_CACHE_PROFILE_PICTURES_ONLY)) out[UploadSettingsStore.localBlossomCacheProfilePicturesOnly] = legacy.getBoolean(PrefKeys.LOCAL_BLOSSOM_CACHE_PROFILE_PICTURES_ONLY, false) - legacy.getString(PrefKeys.DEFAULT_FILE_SERVER, null)?.let { out[UploadSettingsStore.defaultFileServerJson] = it } - } - } + private fun identityStore(npub: String) = AccountIdentityStore(accountStores.getDataStore(npub)) - private fun dialogDismissalMigration(npub: String) = - CopyOnceMigration("migrated.dialogDismissal") { out -> - withContext(Dispatchers.IO) { - val legacy = encryptedPreferences(npub) - if (legacy.contains(PrefKeys.HIDE_DELETE_REQUEST_DIALOG)) out[DialogDismissalStore.hideDeleteRequestDialog] = legacy.getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false) - if (legacy.contains(PrefKeys.HIDE_BLOCK_ALERT_DIALOG)) out[DialogDismissalStore.hideBlockAlertDialog] = legacy.getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false) - if (legacy.contains(PrefKeys.HIDE_NIP_17_WARNING_DIALOG)) out[DialogDismissalStore.hideNip17WarningDialog] = legacy.getBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, false) - if (legacy.contains(PrefKeys.HIDE_COMMUNITY_RULES_VIOLATIONS)) out[DialogDismissalStore.hideCommunityRulesViolations] = legacy.getBoolean(PrefKeys.HIDE_COMMUNITY_RULES_VIOLATIONS, false) - legacy.getStringSet(PrefKeys.DISMISSED_POLL_NOTE_IDS, null)?.let { out[DialogDismissalStore.dismissedPollNoteIds] = it } - legacy.getStringSet(PrefKeys.DISMISSED_CHANNEL_INVITES, null)?.let { out[DialogDismissalStore.dismissedChannelInvites] = it } - legacy.getStringSet(PrefKeys.MUTED_PUBLIC_CHATS, null)?.let { out[DialogDismissalStore.mutedPublicChats] = it } - legacy.getStringSet(PrefKeys.HAS_DONATED_IN_VERSION, null)?.let { out[DialogDismissalStore.hasDonatedInVersion] = it } - legacy.getString(PrefKeys.VIEWED_POLL_RESULT_NOTE_IDS, null)?.let { out[DialogDismissalStore.viewedPollResultNoteIdsJson] = it } - } - } + private fun legacySource(npub: String): LegacyPreferenceSource = LegacySharedPreferences(encryptedPreferences(npub)) - private fun relayAuthMigration(npub: String) = - CopyOnceMigration("migrated.relayAuth") { out -> - withContext(Dispatchers.IO) { - val legacy = encryptedPreferences(npub) - legacy.getString(PrefKeys.DEFAULT_RELAY_AUTH_POLICY, null)?.let { out[RelayAuthStore.policyName] = it } - if (legacy.contains(PrefKeys.RELAY_AUTH_TRUST_MY_RELAYS)) out[RelayAuthStore.trustMyRelays] = legacy.getBoolean(PrefKeys.RELAY_AUTH_TRUST_MY_RELAYS, false) - if (legacy.contains(PrefKeys.RELAY_AUTH_TRUST_READ_FOLLOWS)) out[RelayAuthStore.trustReadFollows] = legacy.getBoolean(PrefKeys.RELAY_AUTH_TRUST_READ_FOLLOWS, false) - if (legacy.contains(PrefKeys.RELAY_AUTH_TRUST_MESSAGE_FOLLOWS)) out[RelayAuthStore.trustMessageFollows] = legacy.getBoolean(PrefKeys.RELAY_AUTH_TRUST_MESSAGE_FOLLOWS, false) - if (legacy.contains(PrefKeys.RELAY_AUTH_TRUST_MESSAGE_STRANGERS)) out[RelayAuthStore.trustMessageStrangers] = legacy.getBoolean(PrefKeys.RELAY_AUTH_TRUST_MESSAGE_STRANGERS, false) - } - } + /** + * Whether the app has stopped mirroring into `secret_keeper_`. + * + * False, and deliberately so: the private key, the secrets and the identity + * group are all still written there, so that a build rolled back to reading + * only the legacy file still finds a complete account. Deleting the file + * while that is true would achieve nothing — the next save recreates it — + * so [legacyCleanup] refuses to. + * + * Flipping this is a release of its own, and it ends the rollback window. + * It waits on the device pass in + * `amethyst/plans/2026-09-23-encrypted-storage-retirement.md`. + */ + private const val LEGACY_WRITES_RETIRED = false - private fun feedVisibilityMigration(npub: String) = - CopyOnceMigration("migrated.feedVisibility") { out -> - withContext(Dispatchers.IO) { - val legacy = encryptedPreferences(npub) - legacy.getString(PrefKeys.DISABLED_CHAT_FEEDS, null)?.let { out[FeedVisibilityStore.disabledChatFeeds] = it } - legacy.getString(PrefKeys.DISABLED_HOME_FEED_TYPES, null)?.let { out[FeedVisibilityStore.disabledHomeFeedTypes] = it } - legacy.getString(PrefKeys.RELAY_GROUP_VIEW_MODE, null)?.let { out[FeedVisibilityStore.relayGroupViewMode] = it } - legacy.getString(PrefKeys.CONCORD_VIEW_MODE, null)?.let { out[FeedVisibilityStore.concordViewMode] = it } - if (legacy.contains(PrefKeys.CALLS_ENABLED)) out[FeedVisibilityStore.callsEnabled] = legacy.getBoolean(PrefKeys.CALLS_ENABLED, false) - } - } + private val legacyCleanup: LegacyPreferenceCleanup by lazy { + LegacyPreferenceCleanup( + tables = LegacyAccountKeys.tables, + accepted = LegacyAccountKeys.accepted, + files = + object : LegacyAccountFiles { + override fun source(npub: String) = legacySource(npub) - private fun notificationPrefsMigration(npub: String) = - CopyOnceMigration("migrated.notificationPrefs") { out -> - withContext(Dispatchers.IO) { - val legacy = encryptedPreferences(npub) - if (legacy.contains(PrefKeys.ALWAYS_ON_NOTIFICATION_SERVICE)) out[NotificationPrefsStore.alwaysOnService] = legacy.getBoolean(PrefKeys.ALWAYS_ON_NOTIFICATION_SERVICE, false) - if (legacy.contains(PrefKeys.SHOW_MESSAGES_IN_NOTIFICATIONS)) out[NotificationPrefsStore.showMessagesInNotifications] = legacy.getBoolean(PrefKeys.SHOW_MESSAGES_IN_NOTIFICATIONS, false) - if (legacy.contains(PrefKeys.SPLIT_NOTIFICATIONS_ENABLED)) out[NotificationPrefsStore.splitNotificationsEnabled] = legacy.getBoolean(PrefKeys.SPLIT_NOTIFICATIONS_ENABLED, false) - } - } + override fun exists(npub: String) = legacyAccountFile(npub).exists() + + override suspend fun delete(npub: String): Boolean { + // Clear before unlinking, as deleteAccount does: the live + // SharedPreferences still holds the values in memory and + // would write them straight back out. + encryptedPreferences(npub).edit(commit = true) { clear() } + return legacyAccountFile(npub).delete() + } + }, + currentStore = { npub -> accountStores.getDataStore(npub).data.first() }, + secrets = + object : MigratedSecrets { + override suspend fun secrets(npub: String) = accountSecretsStore.stored(npub) + + override suspend fun privateKey(npub: String) = accountKeyStore.stored(npub) + }, + legacyWritesRetired = LEGACY_WRITES_RETIRED, + ) + } + + /** + * The file behind [encryptedPreferences], following the same branch it + * does — a name taken from the other side of that `if` would have the + * cleanup checking for, and deleting, a file that is not the one being + * read. + */ + private fun legacyAccountFile(npub: String): File { + val name = if (BuildConfig.DEBUG && DEBUG_PLAINTEXT_PREFERENCES) "${DEBUG_PREFERENCES_NAME}_$npub" else EncryptedStorage.prefsFileName(npub) + return File(prefsDirPath, "$name.xml") + } /** * Everything the account's DataStore holds, read in one hop. @@ -375,6 +378,7 @@ object LocalPreferences { * over. One call, one state. */ private class AccountStoreData( + val identity: AccountIdentity, val followLists: Map, val latestEvents: Map, val uploadSettings: UploadSettings, @@ -384,36 +388,19 @@ object LocalPreferences { val notificationPrefs: NotificationPrefs, ) - private suspend fun loadAccountStores(npub: String) = - AccountStoreData( - followLists = followListStore(npub).load(), - latestEvents = latestEventStore(npub).load(), - uploadSettings = uploadSettingsStore(npub).load(), - dialogDismissal = dialogDismissalStore(npub).load(), - relayAuth = relayAuthStore(npub).load(), - feedVisibility = feedVisibilityStore(npub).load(), - notificationPrefs = notificationPrefsStore(npub).load(), - ) - - private fun followListMigration(npub: String) = - CopyOnceMigration("migrated.followLists") { out -> - withContext(Dispatchers.IO) { - val legacy = encryptedPreferences(npub) - FollowListSlot.entries.forEach { slot -> - legacy.getString(slot.prefKey, null)?.let { out[slot.key] = it } - } - } - } - - private fun latestEventMigration(npub: String) = - CopyOnceMigration("migrated.latestEvents") { out -> - withContext(Dispatchers.IO) { - val legacy = encryptedPreferences(npub) - LatestEventSlot.entries.forEach { slot -> - legacy.getString(slot.prefKey, null)?.let { out[slot.key] = it } - } - } - } + private suspend fun loadAccountStores( + npub: String, + legacyIdentity: () -> AccountIdentity, + ) = AccountStoreData( + identity = identityStore(npub).load().orIfUnusable(legacyIdentity), + followLists = followListStore(npub).load(), + latestEvents = latestEventStore(npub).load(), + uploadSettings = uploadSettingsStore(npub).load(), + dialogDismissal = dialogDismissalStore(npub).load(), + relayAuth = relayAuthStore(npub).load(), + feedVisibility = feedVisibilityStore(npub).load(), + notificationPrefs = notificationPrefsStore(npub).load(), + ) // NOT migrated to DataStore, and cannot be: DataStore is suspend-only, while // NotificationRelayService.isEnabled(context) is a synchronous Boolean read @@ -796,6 +783,18 @@ object LocalPreferences { privKeyHex = settings.keyPair.privKey?.toHexKey(), ) } + // Mirrored, not moved: NOSTR_PUBKEY is the one key whose loss empties + // the app, so the legacy write above stays until a release has + // proved this one — see [EncryptedStorage]. + identityStore(settings.keyPair.pubKey.toNpub()).save( + AccountIdentity( + pubKeyHex = settings.keyPair.pubKey.toHexKey(), + loginWithExternalSigner = settings.externalSignerPackageName != null, + externalSignerPackageName = settings.externalSignerPackageName, + localRelayServers = settings.localRelayServers.value, + openBackupConflictsJson = settings.openBackupConflicts().takeIf { it.isNotEmpty() }?.let { BackupConflictStorage.encode(it) }, + ), + ) uploadSettingsStore(settings.keyPair.pubKey.toNpub()).save( UploadSettings( stripLocationOnUpload = settings.stripLocationOnUpload, @@ -915,16 +914,15 @@ object LocalPreferences { suspend fun loadAccountConfigFromEncryptedStorage(): AccountSettings? = currentAccount()?.let { loadAccountConfigFromEncryptedStorage(it) } - fun saveSharedSettings( - sharedSettings: UiSettings, - prefs: SharedPreferences = encryptedPreferences(), - ) { - Log.d("LocalPreferences", "Saving to shared settings") - prefs.edit { - putString(PrefKeys.SHARED_SETTINGS, JsonMapper.toJson(sharedSettings)) - } - } - + /** + * The UI settings as the global `secret_keeper` file holds them. + * + * A migration source only: [UiSharedPreferences] owns these now and writes + * them to its own DataStore, which carries a one-shot copy out of this blob + * for installs that predate it. Nothing writes here any more — the matching + * `saveSharedSettings` was removed once it had no callers — but the read + * stays for good, like every other legacy reader; see [EncryptedStorage]. + */ fun loadSharedSettings(prefs: SharedPreferences = encryptedPreferences()): UiSettings? { Log.d("LocalPreferences", "Load shared settings") with(prefs) { @@ -953,10 +951,9 @@ object LocalPreferences { private suspend fun hasBackedUpKeysFlow(npub: String): MutableStateFlow = hasBackedUpKeysMutex.withLock { hasBackedUpKeysFlows.getOrPut(npub) { - val stored = - withContext(Dispatchers.IO) { - encryptedPreferences(npub).getBoolean(PrefKeys.HAS_BACKED_UP_KEYS, true) - } + // Absent reads as true in both stores, so a store that cannot be + // read leaves the nudge off rather than showing it to everyone. + val stored = withContext(Dispatchers.IO) { identityStore(npub).hasBackedUpKeys() } MutableStateFlow(stored) } } @@ -969,7 +966,10 @@ object LocalPreferences { npub: String, ) { withContext(Dispatchers.IO) { + // Legacy write kept alongside the new one, as for the rest of the + // identity group — see [EncryptedStorage]. encryptedPreferences(npub).edit { putBoolean(PrefKeys.HAS_BACKED_UP_KEYS, value) } + identityStore(npub).setHasBackedUpKeys(value) } hasBackedUpKeysFlow(npub).value = value } @@ -991,6 +991,12 @@ object LocalPreferences { // raced in before the per-npub file finished being written. if (accountSettings != null) { cachedAccounts.put(npub, accountSettings) + + // Everything this account has is now migrated and just been + // read back, which is the only moment the legacy file can be + // shown to be redundant. It will not be, yet — see + // [LEGACY_WRITES_RETIRED]. + legacyCleanup.deleteIfVerified(npub) } return@withContext accountSettings @@ -998,26 +1004,52 @@ object LocalPreferences { } } - private suspend fun innerLoadCurrentAccountFromEncryptedStorage(npub: String?): AccountSettings? { + private suspend fun innerLoadCurrentAccountFromEncryptedStorage(npub: String): AccountSettings? { Log.d("LocalPreferences") { "Load account from file $npub" } val startedAtMs = TimeUtils.nowMillis() val result = withContext(Dispatchers.IO) { return@withContext with(encryptedPreferences(npub)) { Log.d("LocalPreferences") { "Load account from file $npub - opened file" } - // pubKey first: the key store is keyed by npub, which is derived - // from it, and this is the same npub the save side writes under. - val pubKey = getString(PrefKeys.NOSTR_PUBKEY, null) ?: return@with null + // Every store this account has, read in one hop — including + // the identity the rest of this function is derived from, so + // that read does not cost its own state in the generated + // coroutine state machine (see [AccountStoreData]). + // + // Keyed by the npub handed in, which is the npub the save side + // writes under and the name of the legacy file just opened. The + // identity falls back to that file when its store cannot + // produce a pubkey: an account without one vanishes from the + // app entirely, private key intact. + val stores = + loadAccountStores(npub) { + AccountIdentity( + pubKeyHex = getString(PrefKeys.NOSTR_PUBKEY, null), + loginWithExternalSigner = getBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, false), + externalSignerPackageName = getString(PrefKeys.SIGNER_PACKAGE_NAME, null), + localRelayServers = getStringSet(PrefKeys.LOCAL_RELAY_SERVERS, null) ?: setOf(), + openBackupConflictsJson = getString(PrefKeys.OPEN_BACKUP_CONFLICTS, null), + ) + } + val identity = stores.identity + val pubKey = identity.pubKeyHex ?: return@with null val privKey = accountKeyStore.read( npub = pubKey.hexToByteArray().toNpub(), legacyValue = getString(PrefKeys.NOSTR_PRIVKEY, null), ) - val externalSignerPackageName = getString(PrefKeys.SIGNER_PACKAGE_NAME, null) ?: if (getBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, false)) "com.greenart7c3.nostrsigner" else null + val externalSignerPackageName = identity.externalSignerPackageName ?: if (identity.loginWithExternalSigner) "com.greenart7c3.nostrsigner" else null val keyPair = KeyPair(privKey = privKey?.hexToByteArray(), pubKey = pubKey.hexToByteArray()) - val stores = loadAccountStores(keyPair.pubKey.toNpub()) + // The npub handed in names the file just read, and the save + // side writes every store under the npub derived from the + // pubkey inside it, so the two are the same by construction. + // Say so if they ever are not: it would mean this load is + // reading stores that a save never wrote. + if (keyPair.pubKey.toNpub() != npub) { + Log.e("LocalPreferences", "Account file $npub holds pubkey ${keyPair.pubKey.toNpub()}; its stores were read under the file's name", null) + } Log.d("LocalPreferences") { "Load account from file $npub - keys ready" } @@ -1043,7 +1075,7 @@ object LocalPreferences { val dismissedChannelInvites = stores.dialogDismissal.dismissedChannelInvites val mutedPublicChats = stores.dialogDismissal.mutedPublicChats val viewedPollResultNoteIdsStr = stores.dialogDismissal.viewedPollResultNoteIdsJson - val localRelayServers = getStringSet(PrefKeys.LOCAL_RELAY_SERVERS, null) ?: setOf() + val localRelayServers = identity.localRelayServers val followListPrefs = toFollowListPrefs(stores.followLists) @@ -1052,18 +1084,9 @@ object LocalPreferences { val secrets = accountSecretsStore.read( npub = keyPair.pubKey.toNpub(), - legacy = - AccountSecrets( - nip46SignerEnabled = getBoolean(PrefKeys.NIP46_SIGNER_ENABLED, false), - nip46BunkerSecret = getString(PrefKeys.NIP46_BUNKER_SECRET, "") ?: "", - nip46TransportKey = getString(PrefKeys.NIP46_TRANSPORT_KEY, "") ?: "", - nip46SeenRequestIds = getStringSet(PrefKeys.NIP46_SEEN_IDS, null) ?: setOf(), - nwcWalletsJson = getString(PrefKeys.NWC_WALLETS, null), - clinkDebitWalletsJson = getString(PrefKeys.CLINK_DEBIT_WALLETS, null), - defaultPaymentSourceId = getString(PrefKeys.DEFAULT_PAYMENT_SOURCE_ID, null), - legacyDefaultNwcWalletId = getString(PrefKeys.DEFAULT_NWC_WALLET_ID, null), - legacyZapPaymentRequestServer = getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null), - ), + // Through the shared reader, so the loader and the check + // that gates deleting this file read the same keys. + legacy = readLegacyAccountSecrets(LegacySharedPreferences(this)), ) val nip46SignerEnabled = secrets.nip46SignerEnabled val nip46BunkerSecret = secrets.nip46BunkerSecret @@ -1077,7 +1100,7 @@ object LocalPreferences { val defaultFileServerStr = stores.uploadSettings.defaultFileServerJson val pendingAttestationsStr = getString(PrefKeys.PENDING_ATTESTATIONS, null) - val openBackupConflictsStr = getString(PrefKeys.OPEN_BACKUP_CONFLICTS, null) + val openBackupConflictsStr = identity.openBackupConflictsJson val latestUserMetadataStr = stores.latestEvents[LatestEventSlot.USER_METADATA] val latestContactListStr = stores.latestEvents[LatestEventSlot.CONTACT_LIST] val latestDmRelayListStr = stores.latestEvents[LatestEventSlot.DM_RELAY_LIST] diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/LegacyKeyCoverageTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/LegacyKeyCoverageTest.kt new file mode 100644 index 0000000000..e218c8be32 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/LegacyKeyCoverageTest.kt @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst + +import com.vitorpamplona.amethyst.commons.model.preferences.LegacyAccountSecretNames +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.lang.reflect.Modifier + +/** + * Every key the app has ever written to a `secret_keeper` file has to be + * accounted for somewhere, and this is what says so. + * + * [LegacyPreferenceCleanup] refuses to delete a file holding a key it does not + * recognise, which is the right runtime behaviour but a slow way to find out. + * A key added to `PrefKeys` and to neither a migration table nor the accepted + * list fails here instead — at the commit that adds it, naming it. + */ +class LegacyKeyCoverageTest { + /** Read off the object rather than restated, so the test cannot go stale. */ + private val allPrefKeys: Set = + PrefKeys::class.java.declaredFields + .filter { Modifier.isStatic(it.modifiers) && it.type == String::class.java } + .map { + it.isAccessible = true + it.get(null) as String + }.toSet() + + /** + * Keys of the *global* `secret_keeper` file, which has no per-account + * counterpart and is not what the cleanup deletes. + * `notification_service_enabled` is not even in it — it lives in a plain + * file, deliberately, because it is read synchronously in fresh processes. + */ + private val globalFileKeys = + setOf( + PrefKeys.CURRENT_ACCOUNT, + PrefKeys.SAVED_ACCOUNTS, + PrefKeys.ALL_ACCOUNT_INFO, + PrefKeys.SHARED_SETTINGS, + PrefKeys.NOTIFICATION_SERVICE_ENABLED, + ) + + @Test + fun thePrefKeysListWasActuallyRead() { + assertTrue(allPrefKeys.size.toString(), allPrefKeys.size > 100) + assertTrue(PrefKeys.NOSTR_PUBKEY in allPrefKeys) + } + + @Test + fun everyLegacyKeyIsEitherMigratedOrDeliberatelyDropped() { + val migrated = LegacyAccountKeys.tables.flatMapTo(mutableSetOf()) { it.legacyNames } + val classified = migrated + LegacyAccountSecretNames.all + LegacyAccountKeys.accepted + globalFileKeys + + assertEquals( + "Unclassified legacy keys. Add each to a migration table, or to LegacyAccountKeys.accepted if losing it is deliberate.", + emptySet(), + allPrefKeys - classified, + ) + } + + /** + * The reverse direction: a table claiming a key `PrefKeys` no longer has + * means the copy is reading a name nothing writes. + */ + @Test + fun noTableClaimsAKeyThatNoLongerExists() { + val migrated = LegacyAccountKeys.tables.flatMapTo(mutableSetOf()) { it.legacyNames } + + assertEquals(emptySet(), migrated - allPrefKeys) + assertEquals(emptySet(), LegacyAccountSecretNames.all - allPrefKeys) + assertEquals(emptySet(), LegacyAccountKeys.accepted - allPrefKeys) + } + + /** + * The seven that were still read only from the legacy file. Named + * individually because `nostr_pubkey` is the one whose loss empties the + * app: without it the loader returns null and the account disappears, with + * its private key sitting safe and unreachable in the key store. + */ + @Test + fun theLastSevenKeysAreMigrated() { + val migrated = LegacyAccountKeys.tables.flatMapTo(mutableSetOf()) { it.legacyNames } + + listOf( + PrefKeys.NOSTR_PUBKEY, + PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, + PrefKeys.SIGNER_PACKAGE_NAME, + PrefKeys.HAS_BACKED_UP_KEYS, + PrefKeys.LOCAL_RELAY_SERVERS, + PrefKeys.OPEN_BACKUP_CONFLICTS, + ).forEach { assertTrue(it, it in migrated) } + + // The seventh is global, and moved into the UI settings DataStore + // rather than a per-account one. + assertTrue(PrefKeys.SHARED_SETTINGS in globalFileKeys) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/LegacyPreferenceCleanupTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/LegacyPreferenceCleanupTest.kt new file mode 100644 index 0000000000..c79704c29a --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/LegacyPreferenceCleanupTest.kt @@ -0,0 +1,321 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst + +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.mutablePreferencesOf +import androidx.datastore.preferences.core.stringPreferencesKey +import com.vitorpamplona.amethyst.commons.model.preferences.AccountSecrets +import com.vitorpamplona.amethyst.commons.model.preferences.LegacyBooleanKey +import com.vitorpamplona.amethyst.commons.model.preferences.LegacyKeyTable +import com.vitorpamplona.amethyst.commons.model.preferences.LegacyPreferenceSource +import com.vitorpamplona.amethyst.commons.model.preferences.LegacyStringKey +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +private const val NPUB = "npub1test" + +private class MapSource( + private val values: Map, +) : LegacyPreferenceSource { + override fun keys() = values.keys + + override fun getBoolean(name: String) = values[name] as Boolean? + + override fun getString(name: String) = values[name] as String? + + @Suppress("UNCHECKED_CAST") + override fun getStringSet(name: String) = values[name] as Set? +} + +private class FakeFiles( + private val values: Map, +) : LegacyAccountFiles { + var deleted = false + private set + + var present = true + + override fun source(npub: String) = MapSource(values) + + override fun exists(npub: String) = present + + override suspend fun delete(npub: String): Boolean { + deleted = true + present = false + return true + } +} + +private class FakeSecrets( + private val stored: AccountSecrets? = AccountSecrets(), + private val key: String? = null, + private val throws: Boolean = false, +) : MigratedSecrets { + override suspend fun secrets(npub: String): AccountSecrets? { + if (throws) throw IllegalStateException("keystore unavailable") + return stored + } + + override suspend fun privateKey(npub: String): String? { + if (throws) throw IllegalStateException("keystore unavailable") + return key + } +} + +/** + * The gate in front of deleting an account's `secret_keeper_` file. + * + * Every test here is a way the deletion could destroy something, so the + * assertions are mostly that it did *not* happen. + */ +class LegacyPreferenceCleanupTest { + private val flag = booleanPreferencesKey("flag") + private val text = stringPreferencesKey("text") + + private val table = + LegacyKeyTable( + "migrated.group", + listOf(LegacyBooleanKey("legacy_flag", flag), LegacyStringKey("legacy_text", text)), + ) + + private val migrated = mutablePreferencesOf().also { it[booleanPreferencesKey("migrated.group")] = true } + + private fun cleanup( + values: Map, + current: Preferences = migrated, + secrets: MigratedSecrets = FakeSecrets(), + files: FakeFiles = FakeFiles(values), + retired: Boolean = true, + accepted: Set = setOf("pending_attestations"), + ) = files to + LegacyPreferenceCleanup( + tables = listOf(table), + accepted = accepted, + files = files, + currentStore = { current }, + secrets = secrets, + legacyWritesRetired = retired, + ) + + @Test + fun deletesOnceEverythingIsAccountedFor() = + runTest { + val (files, subject) = cleanup(mapOf("legacy_flag" to true, "pending_attestations" to "[]")) + + assertEquals(emptyList(), subject.verify(NPUB)) + assertEquals(LegacyCleanupResult.Deleted, subject.deleteIfVerified(NPUB)) + assertTrue(files.deleted) + } + + /** + * The hole a hand-maintained checklist leaves: a key added later that no + * migration carries. The check runs from the file's own keys so that it + * cannot be missed. + */ + @Test + fun anUnrecognisedKeyStopsTheDeletion() = + runTest { + val (files, subject) = cleanup(mapOf("legacy_flag" to true, "something_new" to "value")) + + val result = subject.deleteIfVerified(NPUB) + + assertEquals(LegacyCleanupResult.Kept(listOf("no migration claims 'something_new'")), result) + assertTrue(!files.deleted) + } + + @Test + fun aCopyThatHasNotRunStopsTheDeletion() = + runTest { + val (files, subject) = cleanup(mapOf("legacy_flag" to true), current = emptyPreferences()) + + val result = subject.deleteIfVerified(NPUB) + + assertEquals(LegacyCleanupResult.Kept(listOf("the 'migrated.group' copy has not run")), result) + assertTrue(!files.deleted) + } + + /** + * A file that never held a group's keys has nothing for that copy to prove, + * so an account predating a setting is not held back by it forever. + */ + @Test + fun aGroupTheFileNeverHeldDoesNotBlock() = + runTest { + val (_, subject) = cleanup(mapOf("pending_attestations" to "[]"), current = emptyPreferences()) + + assertEquals(emptyList(), subject.verify(NPUB)) + } + + @Test + fun secretsThatHaveNotBeenCopiedStopTheDeletion() = + runTest { + val (files, subject) = cleanup(mapOf("legacy_flag" to true), secrets = FakeSecrets(stored = null)) + + val result = subject.deleteIfVerified(NPUB) + + assertEquals(LegacyCleanupResult.Kept(listOf("the secrets have not been copied across")), result) + assertTrue(!files.deleted) + } + + /** Both stores are still written, so a disagreement means a write was lost. */ + @Test + fun secretsThatDisagreeStopTheDeletion() = + runTest { + val (files, subject) = + cleanup( + mapOf("legacy_flag" to true, "nip46BunkerSecret" to "from-the-file"), + secrets = FakeSecrets(stored = AccountSecrets(nip46BunkerSecret = "stale")), + ) + + val result = subject.deleteIfVerified(NPUB) + + assertEquals( + LegacyCleanupResult.Kept(listOf("the stored secrets differ from the legacy file: nip46BunkerSecret")), + result, + ) + assertTrue(!files.deleted) + } + + /** The values themselves are bunker secrets and wallet strings. */ + @Test + fun aSecretsMismatchNamesTheFieldAndNotTheValue() = + runTest { + val (_, subject) = + cleanup( + mapOf("nwcWallets" to "nostr+walletconnect://deadbeef?secret=hunter2"), + secrets = FakeSecrets(stored = AccountSecrets()), + ) + + val reason = subject.verify(NPUB).single() + + assertTrue(reason, !reason.contains("hunter2")) + assertTrue(reason, reason.contains("nwcWallets")) + } + + @Test + fun aPrivateKeyThatHasNotBeenCopiedStopsTheDeletion() = + runTest { + val (files, subject) = + cleanup( + mapOf("nostr_privkey" to "abc123"), + secrets = FakeSecrets(key = null), + ) + + val result = subject.deleteIfVerified(NPUB) + + assertEquals(LegacyCleanupResult.Kept(listOf("the private key has not been copied across")), result) + assertTrue(!files.deleted) + } + + @Test + fun aPrivateKeyThatDisagreesStopsTheDeletion() = + runTest { + val (_, subject) = cleanup(mapOf("nostr_privkey" to "abc123"), secrets = FakeSecrets(key = "def456")) + + assertEquals(listOf("the stored private key differs from the legacy file"), subject.verify(NPUB)) + } + + @Test + fun aMatchingPrivateKeyPasses() = + runTest { + val (_, subject) = cleanup(mapOf("nostr_privkey" to "abc123"), secrets = FakeSecrets(key = "abc123")) + + assertEquals(emptyList(), subject.verify(NPUB)) + } + + /** + * An external-signer account has no private key in either store, and must + * not be held back for the one it never had. + */ + @Test + fun anAccountWithNoPrivateKeyIsNotHeldBack() = + runTest { + val (_, subject) = cleanup(mapOf("legacy_flag" to true), secrets = FakeSecrets(key = null)) + + assertEquals(emptyList(), subject.verify(NPUB)) + } + + /** "The check itself failed" is not "the check passed". */ + @Test + fun aStoreThatCannotBeReadStopsTheDeletion() = + runTest { + val (files, subject) = cleanup(mapOf("nostr_privkey" to "abc123"), secrets = FakeSecrets(throws = true)) + + val result = subject.deleteIfVerified(NPUB) + + assertEquals( + LegacyCleanupResult.Kept(listOf("the secrets store could not be read", "the key store could not be read")), + result, + ) + assertTrue(!files.deleted) + } + + /** + * While the app still mirrors into this file, deleting it achieves nothing + * — the next save recreates it — and would look like it had worked. + */ + @Test + fun nothingIsDeletedWhileTheLegacyFileIsStillWritten() = + runTest { + val (files, subject) = cleanup(mapOf("legacy_flag" to true), retired = false) + + val result = subject.deleteIfVerified(NPUB) + + assertEquals(LegacyCleanupResult.Kept(listOf(LegacyPreferenceCleanup.STILL_WRITTEN)), result) + assertTrue(!files.deleted) + } + + @Test + fun anAccountWithNoLegacyFileIsAlreadyDone() = + runTest { + val files = FakeFiles(emptyMap()).also { it.present = false } + val (_, subject) = cleanup(emptyMap(), files = files) + + assertEquals(LegacyCleanupResult.NothingToDelete, subject.deleteIfVerified(NPUB)) + } + + /** Every reason is reported, so one fix does not merely reveal the next. */ + @Test + fun everyReasonIsReportedAtOnce() = + runTest { + val (_, subject) = + cleanup( + mapOf("legacy_flag" to true, "mystery" to "x", "nostr_privkey" to "abc123"), + current = emptyPreferences(), + secrets = FakeSecrets(stored = null, key = null), + ) + + assertEquals( + listOf( + "no migration claims 'mystery'", + "the 'migrated.group' copy has not run", + "the secrets have not been copied across", + "the private key has not been copied across", + ), + subject.verify(NPUB), + ) + } +} From e8af62717ebafa28a44c4a1ad14eae0358d18556 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 23:12:12 +0000 Subject: [PATCH 15/50] fix: correctness and cost problems found reviewing the migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A review pass over the full range turned up eleven things. The two that lose user data: **A deleted account came back.** deleteAccount cleared the legacy file, the key store and the secrets store, but never the account's plain DataStore — deleteUserPreferenceFile sweeps shared_prefs/ and that store lives in filesDir/datastore/. AccountPreferenceStores.removeAccount existed and had no caller. Before this branch the orphan was merely litter, because nothing read it; now it holds nostr_pubkey, so re-adding the same npub found a live identity, setDefaultAccount's downgrade guard saw a writeable account and restored the one the user had just deleted. Everything else in there — cached contact and mute lists, 31 follow-list filters, dismissed polls — also stayed on disk unencrypted for good. **The notifications Global -> Selected migration stamped itself done and threw the result away.** It wrote the corrected filter to the legacy DEFAULT_NOTIFICATION_FOLLOW_LIST, which nothing reads once the follow-list copy marker is set, while the stamp went somewhere that persists. End a session with no save and the next launch skips the migration and reads Global back out of the DataStore — the account stays on raw Global notifications permanently. It now writes through followListStore and stamps only after that write succeeds. Two more that would have bitten later: - The SAVED_ACCOUNTS upgrade branch wrote ALL_ACCOUNT_INFO to the legacy file without mirroring it into the roster store, whose own copy had already run against a key that did not exist yet and whose marker was already set. Those installs would open as a fresh install the moment the legacy write goes — the failure AccountRoster's KDoc calls the most consequential to get wrong. - deletePrivateKey gated its removal on a decrypting read, so a rotated or wiped keystore — exactly when the value is unreadable — skipped the delete and left a deleted account's key on disk. It now tests presence without decrypting, via a new EncryptedDataStore.contains. Two crashes from DataStore's one-store-per-path registry, which only releases on scope cancellation: desktopChessDismissedGamesStore() and Android's SecureKeyStorage both built a store per call over a fixed path. The chess one is reachable today — the view model builds one in its constructor under a remember(account), so reopening that screen threw from an unhandled scope.launch and took the screen's scope down with it. Both are now one store per process. EncryptedSharedPreferences.create was idempotent, which is why neither needed this before. Chess dismissals were fire-and-forget saves of a read-modify-write snapshot, so two in quick succession could land out of order and drop one, and a dismissal racing the async seed wrote a snapshot missing everything already stored. They now go through a single conflated channel with one consumer that writes the current set, and the seed asks for a write when it finds it unioned into a set someone had already persisted. Cost, on paths that run constantly: - saveSecrets did ten separate encrypted-file rewrites per account save, none of which DataStore could skip since AES-GCM re-randomises the IV so the ciphertext differs even when the value does not. One edit now — which also makes the marker mean what LegacyPreferenceCleanup reads it as, since it can no longer exist without the values beside it. loadSecrets likewise reads one snapshot instead of ten flow collections. - SecretEncryption was default-constructed per store: eight AndroidKeyStore loads and eight per-thread Cipher caches for one key alias. One shared instance; both actuals document concurrent use. - updateSavedAccounts compared a MutableStateFlow to a List, so the guard was unconditionally true and every call rewrote both stores. Pre-existing, but this branch put an encrypt and an encrypted-store write behind it. - The cleanup ran inside the mutex that serialises every account load, so once enabled each account on a multi-account cold start would wait for the previous one's full pass. It now runs outside the lock, once, on the call that did the loading. And one design flaw in the new cleanup itself: it compared the stored secrets against the legacy file, justified by their being dual-written — but the check only runs once LEGACY_WRITES_RETIRED turns that off, from which point the legacy copy is frozen. Any account that re-paired a bunker after upgrading would have differed forever and never had its file deleted. Secrets are now gated on the migration marker, like the plain groups. The private-key comparison stays: an npub is derived from its key, so that one cannot legitimately change. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AXvKXakvup4inNFfAhhr4L --- .../amethyst/LegacyPreferenceCleanup.kt | 42 +++---- .../amethyst/LocalPreferences.kt | 116 +++++++++++------- .../amethyst/LegacyPreferenceCleanupTest.kt | 43 +++---- .../commons/keystorage/SecureKeyStorage.kt | 44 +++++-- .../commons/nip64Chess/ChessLobbyLogic.kt | 48 +++++--- .../AccountSecretsEncryptedStores.kt | 68 +++++----- .../model/preferences/EncryptedDataStore.kt | 86 ++++++++++++- .../nip64Chess/ChessDismissedGamesStoreJvm.kt | 18 ++- .../preferences/EncryptedDataStoreTest.kt | 84 +++++++++++++ .../ChessDismissedGamesStoreTest.kt | 15 +++ 10 files changed, 405 insertions(+), 159 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LegacyPreferenceCleanup.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LegacyPreferenceCleanup.kt index fc7973abe6..9872ab3ac8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LegacyPreferenceCleanup.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LegacyPreferenceCleanup.kt @@ -33,7 +33,6 @@ import com.vitorpamplona.amethyst.commons.model.preferences.NotificationPrefsSto import com.vitorpamplona.amethyst.commons.model.preferences.RelayAuthStore import com.vitorpamplona.amethyst.commons.model.preferences.TopNavFollowListStore import com.vitorpamplona.amethyst.commons.model.preferences.UploadSettingsStore -import com.vitorpamplona.amethyst.commons.model.preferences.readLegacyAccountSecrets import com.vitorpamplona.quartz.utils.Log /** @@ -133,9 +132,19 @@ interface MigratedSecrets { * `CopyOnceMigration` writes the values and its marker as a single * `Preferences`, committed atomically, so the marker cannot be set without them. * - * The secrets and the private key are still written to *both* stores on every - * save, so for those the stronger question is available and is asked: read both - * back and require them to agree. + * The private key takes the strongest form: read it back and require it to + * equal the legacy one. That comparison stays valid forever, because an npub is + * derived from its private key, so the key for a given npub can never change. + * + * The secrets cannot be compared, and the reason is worth stating because the + * obvious reading is wrong. They *are* dual-written today — but this whole + * check only runs once [legacyWritesRetired] is true, and from that release on + * the legacy copy is frozen while the live one keeps moving. An account that + * re-pairs a bunker or adds a wallet after upgrading would then differ from the + * file forever and never have it deleted. So they are gated the same way as the + * plain groups: on the copy having run, which + * [AccountSecretsEncryptedStores.loadSecrets] reports by returning non-null + * only once its marker is set, and it writes that marker last. * * # Why an unrecognised key blocks * @@ -223,17 +232,10 @@ class LegacyPreferenceCleanup( npub: String, legacy: LegacyPreferenceSource, ): List { - val expected = readLegacyAccountSecrets(legacy) val reasons = mutableListOf() try { - val stored = secrets.secrets(npub) - when { - stored == null -> reasons += "the secrets have not been copied across" - // Field names only. These values are bunker secrets and wallet - // connection strings; a log line is the last place for them. - stored != expected -> reasons += "the stored secrets differ from the legacy file: ${differingFields(expected, stored)}" - } + if (secrets.secrets(npub) == null) reasons += "the secrets have not been copied across" } catch (e: Exception) { Log.w(TAG, "Could not read the secrets store for $npub", e) reasons += "the secrets store could not be read" @@ -256,22 +258,6 @@ class LegacyPreferenceCleanup( return reasons } - private fun differingFields( - expected: AccountSecrets, - stored: AccountSecrets, - ): String = - listOfNotNull( - "nip46SignerEnabled".takeIf { expected.nip46SignerEnabled != stored.nip46SignerEnabled }, - "nip46BunkerSecret".takeIf { expected.nip46BunkerSecret != stored.nip46BunkerSecret }, - "nip46TransportKey".takeIf { expected.nip46TransportKey != stored.nip46TransportKey }, - "nip46SeenRequestIds".takeIf { expected.nip46SeenRequestIds != stored.nip46SeenRequestIds }, - "nwcWallets".takeIf { expected.nwcWalletsJson != stored.nwcWalletsJson }, - "clinkDebitWallets".takeIf { expected.clinkDebitWalletsJson != stored.clinkDebitWalletsJson }, - "defaultPaymentSourceId".takeIf { expected.defaultPaymentSourceId != stored.defaultPaymentSourceId }, - "defaultNwcWalletId".takeIf { expected.legacyDefaultNwcWalletId != stored.legacyDefaultNwcWalletId }, - "zapPaymentServer".takeIf { expected.legacyZapPaymentRequestServer != stored.legacyZapPaymentRequestServer }, - ).joinToString() - /** * Deletes the account's legacy file if — and only if — [verify] comes back * empty and the app has stopped writing to it. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 9df3f2a2f5..8c514f3774 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -390,10 +390,19 @@ object LocalPreferences { private suspend fun loadAccountStores( npub: String, - legacyIdentity: () -> AccountIdentity, + legacy: SharedPreferences, ) = AccountStoreData( - identity = identityStore(npub).load().orIfUnusable(legacyIdentity), - followLists = followListStore(npub).load(), + identity = + identityStore(npub).load().orIfUnusable { + AccountIdentity( + pubKeyHex = legacy.getString(PrefKeys.NOSTR_PUBKEY, null), + loginWithExternalSigner = legacy.getBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, false), + externalSignerPackageName = legacy.getString(PrefKeys.SIGNER_PACKAGE_NAME, null), + localRelayServers = legacy.getStringSet(PrefKeys.LOCAL_RELAY_SERVERS, null) ?: setOf(), + openBackupConflictsJson = legacy.getString(PrefKeys.OPEN_BACKUP_CONFLICTS, null), + ) + }, + followLists = migrateNotificationFilter(npub, legacy, followListStore(npub).load()), latestEvents = latestEventStore(npub).load(), uploadSettings = uploadSettingsStore(npub).load(), dialogDismissal = dialogDismissalStore(npub).load(), @@ -522,9 +531,17 @@ object LocalPreferences { ) } + val json = JsonMapper.toJson(migrated) edit { - putString(PrefKeys.ALL_ACCOUNT_INFO, JsonMapper.toJson(migrated)) + putString(PrefKeys.ALL_ACCOUNT_INFO, json) } + // Mirrored as well, exactly as updateSavedAccounts does. The + // roster's own copy has already run by this point, against an + // ALL_ACCOUNT_INFO that did not exist yet, and its marker is + // set — so without this the roster store stays permanently + // empty for these installs and they open as a fresh install + // the moment the legacy write goes. + accountRoster.mirrorAllAccountInfoJson(json) migrated } @@ -535,7 +552,10 @@ object LocalPreferences { private suspend fun updateSavedAccounts(accounts: List) = withContext(Dispatchers.IO) { - if (savedAccounts != accounts) { + // .value, not the flow: StateFlow does not override equals, so + // comparing the holder to a List was unconditionally true and every + // call rewrote both stores. + if (savedAccounts.value != accounts) { savedAccounts.emit(accounts) val json = JsonMapper.toJson(accounts.filter { !it.isTransient }) @@ -614,6 +634,12 @@ object LocalPreferences { encryptedPreferences(accountInfo.npub).edit(commit = true) { clear() } accountKeyStore.delete(accountInfo.npub) accountSecretsStore.delete(accountInfo.npub) + // The account's plain DataStore, which deleteUserPreferenceFile cannot + // reach: that sweeps shared_prefs/, this lives in filesDir/datastore/. + // Left behind it would keep the deleted account's pubkey, signer and + // cached events on disk — and re-adding the same npub would find a + // live identity there and resurrect the account that was just deleted. + accountStores.removeAccount(accountInfo.npub) removeAccount(accountInfo) deleteUserPreferenceFile(accountInfo.npub) @@ -981,26 +1007,33 @@ object LocalPreferences { cachedAccounts[npub]?.let { return it } return withContext(Dispatchers.IO) { - mutex.withLock { - cachedAccounts[npub]?.let { return@withContext it } + var loadedHere = false - val accountSettings = innerLoadCurrentAccountFromEncryptedStorage(npub) + val accountSettings = + mutex.withLock { + cachedAccounts[npub]?.let { return@withLock it } - // Only cache successful loads. Caching null would leave the account - // permanently unreachable for the rest of the session if a reader - // raced in before the per-npub file finished being written. - if (accountSettings != null) { - cachedAccounts.put(npub, accountSettings) + val loaded = innerLoadCurrentAccountFromEncryptedStorage(npub) - // Everything this account has is now migrated and just been - // read back, which is the only moment the legacy file can be - // shown to be redundant. It will not be, yet — see - // [LEGACY_WRITES_RETIRED]. - legacyCleanup.deleteIfVerified(npub) + // Only cache successful loads. Caching null would leave the account + // permanently unreachable for the rest of the session if a reader + // raced in before the per-npub file finished being written. + if (loaded != null) { + cachedAccounts.put(npub, loaded) + loadedHere = true + } + + loaded } - return@withContext accountSettings - } + // Outside the lock, and only for the call that did the loading. + // Verifying decrypts the whole legacy file and reads three stores, + // while `mutex` serialises every account load — under the lock, each + // account on a multi-account cold start would wait for the previous + // one's full cleanup pass. Nothing here feeds the load. + if (loadedHere) legacyCleanup.deleteIfVerified(npub) + + accountSettings } } @@ -1021,16 +1054,7 @@ object LocalPreferences { // identity falls back to that file when its store cannot // produce a pubkey: an account without one vanishes from the // app entirely, private key intact. - val stores = - loadAccountStores(npub) { - AccountIdentity( - pubKeyHex = getString(PrefKeys.NOSTR_PUBKEY, null), - loginWithExternalSigner = getBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, false), - externalSignerPackageName = getString(PrefKeys.SIGNER_PACKAGE_NAME, null), - localRelayServers = getStringSet(PrefKeys.LOCAL_RELAY_SERVERS, null) ?: setOf(), - openBackupConflictsJson = getString(PrefKeys.OPEN_BACKUP_CONFLICTS, null), - ) - } + val stores = loadAccountStores(npub, this) val identity = stores.identity val pubKey = identity.pubKeyHex ?: return@with null val privKey = @@ -1427,17 +1451,27 @@ object LocalPreferences { * deliberate raw-Global choice is never reverted. Accounts created after the * split are stamped at save time, so they are never touched here. */ - private fun SharedPreferences.migrateNotificationFilter(current: TopFilter): TopFilter { - if (getBoolean(PrefKeys.NOTIF_GLOBAL_TO_CURATED_MIGRATED, false)) return current + private suspend fun migrateNotificationFilter( + npub: String, + legacy: SharedPreferences, + filters: Map, + ): Map { + if (legacy.getBoolean(PrefKeys.NOTIF_GLOBAL_TO_CURATED_MIGRATED, false)) return filters + val current = filters.getValue(FollowListSlot.NOTIFICATION) val migrated = if (current is TopFilter.Global) TopFilter.Selected else current - edit { - if (migrated !== current) { - putString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, JsonMapper.toJson(migrated)) - } - putBoolean(PrefKeys.NOTIF_GLOBAL_TO_CURATED_MIGRATED, true) - } - return migrated + + // Into the store the loader reads, not the legacy key it no longer does. + // Writing it to the legacy file and stamping anyway left the account on + // raw Global for good: the stamp survives, the corrected value does not, + // and the next launch reads Global back out of the DataStore. + if (migrated !== current) followListStore(npub).save(FollowListSlot.NOTIFICATION, migrated) + + // Stamped only once the value is actually stored, so a failed write + // means the migration runs again rather than being lost. + legacy.edit { putBoolean(PrefKeys.NOTIF_GLOBAL_TO_CURATED_MIGRATED, true) } + + return if (migrated === current) filters else filters + (FollowListSlot.NOTIFICATION to migrated) } /** @@ -1446,11 +1480,11 @@ object LocalPreferences { * returns every slot, so a missing one is a bug in this mapping rather * than a user with no saved filter, and should fail loudly. */ - private fun SharedPreferences.toFollowListPrefs(filters: Map): FollowListPrefs = + private fun toFollowListPrefs(filters: Map): FollowListPrefs = FollowListPrefs( home = filters.getValue(FollowListSlot.HOME), stories = filters.getValue(FollowListSlot.STORIES), - notification = migrateNotificationFilter(filters.getValue(FollowListSlot.NOTIFICATION)), + notification = filters.getValue(FollowListSlot.NOTIFICATION), discovery = filters.getValue(FollowListSlot.DISCOVERY), polls = filters.getValue(FollowListSlot.POLLS), pictures = filters.getValue(FollowListSlot.PICTURES), diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/LegacyPreferenceCleanupTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/LegacyPreferenceCleanupTest.kt index c79704c29a..18bad7f959 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/LegacyPreferenceCleanupTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/LegacyPreferenceCleanupTest.kt @@ -180,39 +180,28 @@ class LegacyPreferenceCleanupTest { assertTrue(!files.deleted) } - /** Both stores are still written, so a disagreement means a write was lost. */ + /** + * A migrated secrets group that has since moved on from the legacy file + * must not block deletion. + * + * This check only ever runs in the release that stopped writing the legacy + * file, so from then on that copy is frozen while the live one keeps + * changing. Comparing the two would mean any account that re-pairs a bunker + * or adds a wallet after upgrading never gets its file deleted. The gate is + * the migration marker, which is what a non-null read reports. + */ @Test - fun secretsThatDisagreeStopTheDeletion() = + fun secretsThatHaveMovedOnSinceTheCopyDoNotBlock() = runTest { val (files, subject) = cleanup( - mapOf("legacy_flag" to true, "nip46BunkerSecret" to "from-the-file"), - secrets = FakeSecrets(stored = AccountSecrets(nip46BunkerSecret = "stale")), + mapOf("legacy_flag" to true, "nip46BunkerSecret" to "what-the-file-still-says"), + secrets = FakeSecrets(stored = AccountSecrets(nip46BunkerSecret = "re-paired since")), ) - val result = subject.deleteIfVerified(NPUB) - - assertEquals( - LegacyCleanupResult.Kept(listOf("the stored secrets differ from the legacy file: nip46BunkerSecret")), - result, - ) - assertTrue(!files.deleted) - } - - /** The values themselves are bunker secrets and wallet strings. */ - @Test - fun aSecretsMismatchNamesTheFieldAndNotTheValue() = - runTest { - val (_, subject) = - cleanup( - mapOf("nwcWallets" to "nostr+walletconnect://deadbeef?secret=hunter2"), - secrets = FakeSecrets(stored = AccountSecrets()), - ) - - val reason = subject.verify(NPUB).single() - - assertTrue(reason, !reason.contains("hunter2")) - assertTrue(reason, reason.contains("nwcWallets")) + assertEquals(emptyList(), subject.verify(NPUB)) + assertEquals(LegacyCleanupResult.Deleted, subject.deleteIfVerified(NPUB)) + assertTrue(files.deleted) } @Test diff --git a/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorage.kt b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorage.kt index f3b08ae257..65ce93363e 100644 --- a/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorage.kt +++ b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorage.kt @@ -68,21 +68,33 @@ actual class SecureKeyStorage private actual constructor() { appContext = context.applicationContext return SecureKeyStorage() } - } - private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + /** + * One scope and one store for the whole process, not one per instance. + * + * [create] hands out a new [SecureKeyStorage] on every call — harmless + * when the store was `EncryptedSharedPreferences.create`, which is + * idempotent, but DataStore keeps a process-wide registry keyed by file + * path and only releases an entry when the owning scope ends. A + * per-instance store over a fixed path meant the second instance threw + * "multiple DataStores active for the same file" on its first read — + * which, for this store, reads as the account having no private key. + */ + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - private val store by lazy { - EncryptedDataStore( - PreferenceDataStoreFactory.createWithPath( + private val sharedStore by lazy { + EncryptedDataStore( + PreferenceDataStoreFactory.createWithPath( + scope = scope, + produceFile = { File(appContext.filesDir, STORE_FILE).toOkioPath() }, + ), scope = scope, - produceFile = { File(appContext.filesDir, STORE_FILE).toOkioPath() }, - ), - SecretEncryption(), - scope = scope, - ) + ) + } } + private val store get() = sharedStore + private fun keyFor(npub: String) = stringPreferencesKey(KEY_PREFIX + npub) actual suspend fun savePrivateKey( @@ -117,10 +129,18 @@ actual class SecureKeyStorage private actual constructor() { throw SecureStorageException("Failed to retrieve private key", e) } + /** + * Removes the key unconditionally, and reports whether one was there. + * + * The presence test deliberately does not decrypt. Gating the removal on a + * successful decrypting read meant a rotated or wiped AndroidKeyStore — + * exactly when the value is unreadable — skipped the delete, leaving the + * private key of a deleted account on disk. + */ actual suspend fun deletePrivateKey(npub: String): Boolean = try { - val existed = store.get(keyFor(npub)) != null - if (existed) store.remove(keyFor(npub)) + val existed = store.contains(keyFor(npub)) + store.remove(keyFor(npub)) existed } catch (e: Exception) { throw SecureStorageException("Failed to delete private key", e) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessLobbyLogic.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessLobbyLogic.kt index d799e48997..eda246f63e 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessLobbyLogic.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessLobbyLogic.kt @@ -35,6 +35,7 @@ import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.cache.LargeCache import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -141,16 +142,43 @@ class ChessLobbyLogic( * or two. * * The seed unions rather than replaces, so a dismissal the user makes - * before the read lands is not overwritten by it. + * before the read lands is not overwritten by it — and it asks for a write + * when it did union something in, because that earlier dismissal already + * persisted a snapshot that did not have the stored ids in it. */ private val dismissedGameIds: MutableSet = mutableSetOf() + /** + * Serialises persistence, so a save cannot land out of order. + * + * Each dismissal used to launch its own `storage.save(snapshot)`. Two of + * them are unordered on the same dispatcher, so the first dismissal's + * smaller snapshot could be written *after* the second's and drop it — the + * game came back on the next launch. One consumer, writing whatever the set + * currently holds, cannot reorder; conflation is safe for the same reason, + * since a dropped signal is one whose contents the next write includes. + */ + private val persistRequests = Channel(Channel.CONFLATED) + init { dismissedStorage?.let { storage -> + scope.launch { + for (unused in persistRequests) { + storage.save(userPubkey, dismissedGameIdsLock.withLock { dismissedGameIds.toSet() }) + } + } scope.launch { val stored = storage.load(userPubkey) if (stored.isNotEmpty()) { - dismissedGameIdsLock.withLock { dismissedGameIds.addAll(stored) } + val union = + dismissedGameIdsLock.withLock { + dismissedGameIds.addAll(stored) + dismissedGameIds.size + } + // Larger than what was on disk means a dismissal beat this + // read, and the snapshot it wrote is missing everything that + // was already stored. Write the union back. + if (union > stored.size) persistRequests.trySend(Unit) } } } @@ -984,23 +1012,15 @@ class ChessLobbyLogic( fun dismissCompletedGame(gameId: String) { state.removeCompletedGame(gameId) - val snapshot = - dismissedGameIdsLock.withLock { - dismissedGameIds.add(gameId) - dismissedGameIds.toSet() - } - dismissedStorage?.let { storage -> scope.launch { storage.save(userPubkey, snapshot) } } + dismissedGameIdsLock.withLock { dismissedGameIds.add(gameId) } + persistRequests.trySend(Unit) } fun dismissAllCompletedGames() { val allIds = state.completedGames.value.map { it.gameId } state.clearCompletedGames() - val snapshot = - dismissedGameIdsLock.withLock { - dismissedGameIds.addAll(allIds) - dismissedGameIds.toSet() - } - dismissedStorage?.let { storage -> scope.launch { storage.save(userPubkey, snapshot) } } + dismissedGameIdsLock.withLock { dismissedGameIds.addAll(allIds) } + persistRequests.trySend(Unit) } /** diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountSecretsEncryptedStores.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountSecretsEncryptedStores.kt index c56bf6b9bd..a359fbcfd2 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountSecretsEncryptedStores.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountSecretsEncryptedStores.kt @@ -126,53 +126,53 @@ class AccountSecretsEncryptedStores( * genuinely holds no secrets must not trigger it forever. */ suspend fun loadSecrets(npub: String): AccountSecrets? { - val store = getDataStore(npub) - if (store.get(AccountSecretKeys.migrated) == null) return null + // One snapshot for the whole group rather than ten flow collections. + val stored = getDataStore(npub).snapshot() + if (stored[AccountSecretKeys.migrated] == null) return null return AccountSecrets( - nip46SignerEnabled = store.get(AccountSecretKeys.nip46SignerEnabled).toBoolean(), - nip46BunkerSecret = store.get(AccountSecretKeys.nip46BunkerSecret) ?: "", - nip46TransportKey = store.get(AccountSecretKeys.nip46TransportKey) ?: "", - nip46SeenRequestIds = decodeSet(store.get(AccountSecretKeys.nip46SeenRequestIds)), - nwcWalletsJson = store.get(AccountSecretKeys.nwcWallets), - clinkDebitWalletsJson = store.get(AccountSecretKeys.clinkDebitWallets), - defaultPaymentSourceId = store.get(AccountSecretKeys.defaultPaymentSourceId), - legacyDefaultNwcWalletId = store.get(AccountSecretKeys.legacyDefaultNwcWalletId), - legacyZapPaymentRequestServer = store.get(AccountSecretKeys.legacyZapPaymentRequestServer), + nip46SignerEnabled = stored[AccountSecretKeys.nip46SignerEnabled].toBoolean(), + nip46BunkerSecret = stored[AccountSecretKeys.nip46BunkerSecret] ?: "", + nip46TransportKey = stored[AccountSecretKeys.nip46TransportKey] ?: "", + nip46SeenRequestIds = decodeSet(stored[AccountSecretKeys.nip46SeenRequestIds]), + nwcWalletsJson = stored[AccountSecretKeys.nwcWallets], + clinkDebitWalletsJson = stored[AccountSecretKeys.clinkDebitWallets], + defaultPaymentSourceId = stored[AccountSecretKeys.defaultPaymentSourceId], + legacyDefaultNwcWalletId = stored[AccountSecretKeys.legacyDefaultNwcWalletId], + legacyZapPaymentRequestServer = stored[AccountSecretKeys.legacyZapPaymentRequestServer], ) } /** - * Writes the group, then the marker. + * Writes the group and its marker as one edit. * - * Marker last on purpose: a crash midway leaves the account looking - * unmigrated, so the next load copies from the legacy file again rather - * than reading a half-written set of secrets as complete. + * One edit, not ten. Every account save runs this, and a key at a time cost + * ten encrypted-file rewrites — none of which DataStore could skip, because + * AES-GCM re-randomises the IV so the ciphertext differs even when the value + * does not. + * + * It also makes the marker meaningful. Written in its own transaction after + * the others it merely *tended* to be last; in the same one it cannot exist + * without them, so a marker found on disk proves a complete group — which is + * what `LegacyPreferenceCleanup` reads it as before deleting the legacy file. */ suspend fun saveSecrets( npub: String, value: AccountSecrets, ) { - val store = getDataStore(npub) + getDataStore(npub).edit { + put(AccountSecretKeys.nip46SignerEnabled, value.nip46SignerEnabled.toString()) + put(AccountSecretKeys.nip46BunkerSecret, value.nip46BunkerSecret) + put(AccountSecretKeys.nip46TransportKey, value.nip46TransportKey) + put(AccountSecretKeys.nip46SeenRequestIds, value.nip46SeenRequestIds.joinToString(AccountSecretKeys.SET_SEPARATOR)) + putOrRemove(AccountSecretKeys.nwcWallets, value.nwcWalletsJson) + putOrRemove(AccountSecretKeys.clinkDebitWallets, value.clinkDebitWalletsJson) + putOrRemove(AccountSecretKeys.defaultPaymentSourceId, value.defaultPaymentSourceId) + putOrRemove(AccountSecretKeys.legacyDefaultNwcWalletId, value.legacyDefaultNwcWalletId) + putOrRemove(AccountSecretKeys.legacyZapPaymentRequestServer, value.legacyZapPaymentRequestServer) - store.save(AccountSecretKeys.nip46SignerEnabled, value.nip46SignerEnabled.toString()) - store.save(AccountSecretKeys.nip46BunkerSecret, value.nip46BunkerSecret) - store.save(AccountSecretKeys.nip46TransportKey, value.nip46TransportKey) - store.save(AccountSecretKeys.nip46SeenRequestIds, value.nip46SeenRequestIds.joinToString(AccountSecretKeys.SET_SEPARATOR)) - store.putOrRemove(AccountSecretKeys.nwcWallets, value.nwcWalletsJson) - store.putOrRemove(AccountSecretKeys.clinkDebitWallets, value.clinkDebitWalletsJson) - store.putOrRemove(AccountSecretKeys.defaultPaymentSourceId, value.defaultPaymentSourceId) - store.putOrRemove(AccountSecretKeys.legacyDefaultNwcWalletId, value.legacyDefaultNwcWalletId) - store.putOrRemove(AccountSecretKeys.legacyZapPaymentRequestServer, value.legacyZapPaymentRequestServer) - - store.save(AccountSecretKeys.migrated, "true") - } - - private suspend fun EncryptedDataStore.putOrRemove( - key: androidx.datastore.preferences.core.Preferences.Key, - value: String?, - ) { - if (value != null) save(key, value) else remove(key) + put(AccountSecretKeys.migrated, "true") + } } private fun decodeSet(raw: String?): Set = diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/EncryptedDataStore.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/EncryptedDataStore.kt index 01b26082bb..477e50fe0b 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/EncryptedDataStore.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/EncryptedDataStore.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.commons.model.preferences import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.MutablePreferences import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.emptyPreferences @@ -41,7 +42,7 @@ import kotlin.io.encoding.Base64 */ class EncryptedDataStore( private val store: DataStore, - private val encryption: SecretEncryption = SecretEncryption(), + private val encryption: SecretEncryption = sharedSecretEncryption, private val scope: CoroutineScope, ) { private fun encrypt(value: String): String = Base64.encode(encryption.encrypt(value.encodeToByteArray())) @@ -74,6 +75,19 @@ class EncryptedDataStore( ?.get(key) ?.let { decrypt(it) } + /** + * Whether the key is present, without decrypting it. + * + * For callers that only need presence — deleting, say. [get] would report a + * value it cannot decrypt as absent, which is the wrong answer when the + * decision being made is whether to remove it. + */ + suspend fun contains(key: Preferences.Key): Boolean = + store.data + .catch { e -> if (e is IOException) emit(emptyPreferences()) else throw e } + .firstOrNull() + ?.contains(key) == true + /** * The value, or null only when the key is genuinely absent. * @@ -84,6 +98,66 @@ class EncryptedDataStore( */ suspend fun getOrThrow(key: Preferences.Key): String? = store.data.first()[key]?.let { decrypt(it) } + /** + * Reads or writes several keys against one snapshot of the store. + * + * A key at a time costs a full DataStore round trip each — a transform, a + * serialize, a temp-file write, an fsync and a rename to save; a fresh flow + * collection to read. Worse for writes, AES-GCM re-randomises the IV, so the + * ciphertext differs every time and DataStore's "value unchanged, skip the + * write" shortcut never fires: all of them always reach disk. + * + * One [edit] is also a single transaction, which is what lets a group be + * written with its own migration marker and never be seen half-applied. + */ + suspend fun edit(block: Editor.() -> Unit) { + store.edit { prefs -> Editor(prefs, ::encrypt).block() } + } + + class Editor internal constructor( + private val prefs: MutablePreferences, + private val encrypt: (String) -> String, + ) { + fun put( + key: Preferences.Key, + value: String, + ) { + prefs[key] = encrypt(value) + } + + fun remove(key: Preferences.Key) { + prefs.remove(key) + } + + fun putOrRemove( + key: Preferences.Key, + value: String?, + ) { + if (value != null) put(key, value) else remove(key) + } + } + + /** + * One snapshot of the store, decrypting on access. + * + * Reads every key of a group against the same collection, and — since the + * values are one atomic write — against the same version of it. + */ + suspend fun snapshot(): Snapshot = + Snapshot( + store.data + .catch { e -> if (e is IOException) emit(emptyPreferences()) else throw e } + .firstOrNull() ?: emptyPreferences(), + ::decrypt, + ) + + class Snapshot internal constructor( + private val prefs: Preferences, + private val decrypt: (String) -> String?, + ) { + operator fun get(key: Preferences.Key): String? = prefs[key]?.let(decrypt) + } + fun getProperty( key: Preferences.Key, parser: (String) -> T, @@ -108,3 +182,13 @@ class EncryptedDataStore( scope = scope, ) } + +/** + * The one [SecretEncryption] every encrypted store shares. + * + * Its constructor loads the AndroidKeyStore and its first use probes the key's + * security level, and each instance keeps its own per-thread Cipher cache — all + * for a single key alias. There were eight instances doing that independently. + * Both actuals are documented as safe for concurrent use, so one will do. + */ +internal val sharedSecretEncryption: SecretEncryption by lazy { SecretEncryption() } diff --git a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessDismissedGamesStoreJvm.kt b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessDismissedGamesStoreJvm.kt index 5d0153c51d..436f56a2a9 100644 --- a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessDismissedGamesStoreJvm.kt +++ b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessDismissedGamesStoreJvm.kt @@ -33,8 +33,22 @@ import java.io.File * carrying it over — the dismissed list is a convenience, and chess has few * enough users that a migration is not worth the code. */ -fun desktopChessDismissedGamesStore(): ChessDismissedGamesStore { +fun desktopChessDismissedGamesStore(): ChessDismissedGamesStore = sharedStore + +/** + * One store for the process. + * + * DataStore keeps a process-wide registry keyed by file path and only releases + * an entry when the owning scope ends; the factory's own scope never does. So + * building a fresh store per call — and the chess view model builds one in its + * constructor, under a `remember(account)` — made the second one throw + * "multiple DataStores active for the same file" on its first read. That + * surfaced from a `scope.launch` with no handler, taking the screen's whole + * scope down with it. The `java.util.prefs` node this replaced was safe to + * construct repeatedly, so nothing here used to need a singleton. + */ +private val sharedStore: ChessDismissedGamesStore by lazy { val file = File(appDataDir, "chess_dismissed_games.preferences_pb") file.parentFile?.mkdirs() - return ChessDismissedGamesStore(PreferenceDataStoreFactory.createWithPath(produceFile = { file.toOkioPath() })) + ChessDismissedGamesStore(PreferenceDataStoreFactory.createWithPath(produceFile = { file.toOkioPath() })) } diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/EncryptedDataStoreTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/EncryptedDataStoreTest.kt index 6354f5e924..97c2b6ec3a 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/EncryptedDataStoreTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/EncryptedDataStoreTest.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.commons.model.preferences import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -211,4 +212,87 @@ class EncryptedDataStoreTest { ) scope.cancel() } + + @Test + fun editWritesEveryKeyInOneGo() = + runTest { + val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + val subject = store(scope) + val other = stringPreferencesKey("bunker") + + subject.edit { + put(key, "wallet") + put(other, "secret") + } + + val snapshot = subject.snapshot() + assertEquals("wallet", snapshot[key]) + assertEquals("secret", snapshot[other]) + } + + /** + * The whole point of writing a group in one edit: a marker written beside + * its values cannot be found on disk without them. + */ + @Test + fun editIsOneTransaction() = + runTest { + val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + val subject = store(scope) + val marker = stringPreferencesKey("migrated") + + runCatching { + subject.edit { + put(key, "wallet") + put(marker, "true") + throw IllegalStateException("crash midway") + } + } + + val snapshot = subject.snapshot() + assertNull(snapshot[marker]) + assertNull(snapshot[key]) + } + + @Test + fun putOrRemoveClearsANullValue() = + runTest { + val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + val subject = store(scope) + + subject.edit { put(key, "wallet") } + subject.edit { putOrRemove(key, null) } + + assertNull(subject.snapshot()[key]) + } + + /** + * `contains` must not decrypt. + * + * A value the current key cannot decrypt — a rotated or wiped keystore — is + * still a value that is there, and deleting it has to happen anyway. + * `deletePrivateKey` gated its removal on a decrypting read and so skipped + * exactly the case that needed it, leaving a deleted account's private key + * on disk. + */ + @Test + fun containsSeesAValueThatCannotBeDecrypted() = + runTest { + val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + val n = seq++ + val dataFile = File(folder.root, "secrets_$n.preferences_pb") + val raw = + PreferenceDataStoreFactory.createWithPath(scope = scope, produceFile = { dataFile.toOkioPath() }) + // Ciphertext this store's key was never used to produce. + raw.edit { prefs -> prefs[key] = "bm90LWFjdHVhbGx5LWNpcGhlcnRleHQ=" } + + val subject = + EncryptedDataStore(raw, SecretEncryption(File(folder.root, "secret_$n.key")), scope = scope) + + assertTrue(subject.contains(key)) + assertNull(runCatching { subject.get(key) }.getOrNull()) + + subject.remove(key) + assertTrue(!subject.contains(key)) + } } diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessDismissedGamesStoreTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessDismissedGamesStoreTest.kt index 0d4efd334f..9e28130961 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessDismissedGamesStoreTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessDismissedGamesStoreTest.kt @@ -31,6 +31,7 @@ import kotlinx.coroutines.test.runTest import okio.Path.Companion.toOkioPath import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertSame import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test @@ -104,4 +105,18 @@ class ChessDismissedGamesStoreTest { assertEquals(setOf("c"), store.load("npub1")) } + + /** + * The desktop factory has to hand back one store, not a new one per call. + * + * DataStore registers a live store per file path and only releases it when + * the owning scope ends — and the factory's own scope never does. Building + * a fresh one per call meant the second `IllegalStateException: multiple + * DataStores active for the same file`, thrown from the chess view model's + * constructor the second time that screen opened. + */ + @Test + fun theDesktopFactoryReturnsOneStore() { + assertSame(desktopChessDismissedGamesStore(), desktopChessDismissedGamesStore()) + } } From aed654b76beb2f5fbdd4be3367a9cc80a26e59da Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 00:30:47 +0000 Subject: [PATCH 16/50] fix: wait for a removed account's DataStore to shut down, not just ask it to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI caught this on `:commons:jvmTest`: "There are multiple DataStores active for the same file: .../npub1abc.secrets.preferences_pb", from AccountSecretsStoreTest. It passes locally and fails on a loaded runner, which is the tell. `removeAccount` cancelled the store's scope and moved on. But DataStore keeps its process-wide registry entry until the owning scope's job actually *completes* — cancel() only asks. Anything that opens the same path inside that window throws, and for a per-account store that window is exactly the delete-then-add-the-same-npub flow. Reproduced it deterministically before fixing: cancel a scope holding a child that takes a moment to wind down, open a second store on the same path, and it throws the CI message verbatim. Adding `job.join()` after the cancel makes it pass. Both per-account stores had the pattern, so both now join, and both removeAccount are suspend for it — every caller already was. AccountSecretsStoreTest gains the other half of the flow it was missing: remove the account, then add the same npub back. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AXvKXakvup4inNFfAhhr4L --- .../preferences/AccountPreferenceStores.kt | 14 ++++++++++-- .../AccountSecretsEncryptedStores.kt | 15 +++++++++---- .../preferences/AccountSecretsStoreTest.kt | 22 +++++++++++++++++++ 3 files changed, 45 insertions(+), 6 deletions(-) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountPreferenceStores.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountPreferenceStores.kt index 9695fe17a5..1cdcc25588 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountPreferenceStores.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountPreferenceStores.kt @@ -31,6 +31,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel +import kotlinx.coroutines.job import okio.Path /** @@ -92,9 +93,18 @@ class AccountPreferenceStores( * would leave it writing the account's settings back out on the next edit, * re-creating what this call is meant to erase — and would keep the path * registered, so the same account could not be added again. + * + * Cancelling is not enough on its own, and this is suspend for that + * reason. `cancel()` only *asks*; DataStore releases the path when the + * scope's job actually completes, so a store opened on it before then + * still throws "multiple DataStores active for the same file". The window + * is small enough to pass locally and fail on a loaded CI runner. */ - fun removeAccount(npub: String): Boolean { - storeCache.get(npub)?.scope?.cancel() + suspend fun removeAccount(npub: String): Boolean { + storeCache.get(npub)?.scope?.let { + it.cancel() + it.coroutineContext.job.join() + } storeCache.remove(npub) val path = file(npub) if (!platformFileSystem.exists(path)) return false diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountSecretsEncryptedStores.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountSecretsEncryptedStores.kt index a359fbcfd2..082c14fd06 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountSecretsEncryptedStores.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountSecretsEncryptedStores.kt @@ -29,6 +29,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel +import kotlinx.coroutines.job import okio.Path /** @@ -103,11 +104,17 @@ class AccountSecretsEncryptedStores( /** * Drops the account's secrets. * - * Cancels the store's scope before deleting, so DataStore releases the - * path and the same account can be added again in this session. + * Cancels the store's scope and waits for it, so DataStore releases the + * path and the same account can be added again in this session. The wait + * is the point: `cancel()` only asks, and the path stays registered until + * the job completes — [AccountPreferenceStores.removeAccount] has the + * longer note. */ - fun removeAccount(npub: String): Boolean { - storeCache.get(npub)?.scope?.cancel() + suspend fun removeAccount(npub: String): Boolean { + storeCache.get(npub)?.scope?.let { + it.cancel() + it.coroutineContext.job.join() + } storeCache.remove(npub) val path = file(npub) if (!platformFileSystem.exists(path)) return false diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountSecretsStoreTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountSecretsStoreTest.kt index d560d48709..32962ebd25 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountSecretsStoreTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountSecretsStoreTest.kt @@ -184,4 +184,26 @@ class AccountSecretsStoreTest { assertNull(subject.loadSecrets(npub)) } + + /** + * Delete an account, then add the same npub back. + * + * DataStore keeps a process-wide registry keyed by file path and releases + * an entry only when the owning scope's job *completes* — cancelling it is + * just the request. [AccountSecretsEncryptedStores.removeAccount] waits for + * that, and without the wait this throws "multiple DataStores active for + * the same file" whenever the next open wins the race, which on a loaded + * machine it does. + */ + @Test + fun anAccountCanBeAddedBackAfterBeingRemoved() = + runTest { + val subject = stores() + subject.saveSecrets(npub, filled) + subject.removeAccount(npub) + + subject.saveSecrets(npub, filled) + + assertEquals(filled, subject.loadSecrets(npub)) + } } From 0abebe71ee55bf035fefffcedb9c920b7ab2f0a4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 00:35:46 +0000 Subject: [PATCH 17/50] fix: do not assert POSIX permissions on a filesystem without them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows CI failed SecretEncryptionTest.keyFileIsOwnerOnly with UnsupportedOperationException: NTFS has no POSIX permissions, and asking for them raises rather than returning an empty set. The production side was already right — `restrictToOwner` catches that exception and documents Windows as a silent no-op where the user profile's ACLs apply instead. Only the test assumed otherwise, and it is the one test in the file that cannot hold there. Guarded the same way FilePermissionsTest and ScheduledPostStoreTest already do in this repo, a convention I should have followed when writing it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AXvKXakvup4inNFfAhhr4L --- .../commons/model/preferences/SecretEncryptionTest.kt | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/SecretEncryptionTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/SecretEncryptionTest.kt index 854eef57ab..65a93958ec 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/SecretEncryptionTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/SecretEncryptionTest.kt @@ -30,6 +30,7 @@ import org.junit.Rule import org.junit.Test import org.junit.rules.TemporaryFolder import java.io.File +import java.nio.file.FileSystems import java.nio.file.Files import java.nio.file.attribute.PosixFilePermission @@ -92,8 +93,16 @@ class SecretEncryptionTest { assertThrows(Exception::class.java) { subject("second.key").decrypt(ciphertext) } } + private fun isPosix() = FileSystems.getDefault().supportedFileAttributeViews().contains("posix") + @Test fun keyFileIsOwnerOnly() { + // Windows has no POSIX permissions, and `restrictToOwner` is documented + // as a silent no-op there — the user profile's NTFS ACLs apply instead. + // Asking for them anyway raises UnsupportedOperationException, which is + // how this failed on the Windows CI runner while passing everywhere else. + if (!isPosix()) return + subject().encrypt("x".encodeToByteArray()) val perms = Files.getPosixFilePermissions(File(folder.root, "secret.key").toPath()) From 08a2710039de89a622b846a3516b2d632571d2ce Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 01:50:47 +0000 Subject: [PATCH 18/50] ci: give the Android job room for a cold Gradle cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test-and-build-android was killed at its 60-minute cap, 64 minutes into the Gradle step, with no test report — the step was still running, so steps 6-11 (lint reports, test report, APK uploads) never ran. Not a test failure, and not a slow runner. On main the same step takes ~46 minutes, using 76% of the budget. PR runs set `cache-read-only`, so they restore main's Gradle cache and never save one; a PR touching `quartz` or `commons` invalidates most of what that cache holds downstream and rebuilds it cold. Measured across this PR's runs, every job takes 2-3x its main-branch time — Windows desktop 10m to 21m, macOS 8m to 25m, Linux deb 3.5m to 11m. Android had the least headroom, so it is the one that died. Re-running would not help: `cache-read-only` means the timed-out attempt saved nothing, and the next attempt starts from the same main cache. 90 rather than 60. It costs nothing on runs that finish early — the setting bounds a job, it does not reserve the time — and leaves room for the observed 2x without hiding a genuine hang behind an enormous cap. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AXvKXakvup4inNFfAhhr4L --- .github/workflows/build.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index bc5c96b109..9e5dc9e284 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -338,7 +338,16 @@ jobs: test-and-build-android: needs: lint runs-on: ubuntu-latest - timeout-minutes: 60 + # 90, not 60. On main this job's Gradle step takes ~46 minutes, which used + # 76% of a 60-minute budget — fine for an incremental run, but PR runs set + # `cache-read-only` (below), so they restore main's Gradle cache and never + # save. A PR that touches `quartz` or `commons` invalidates most of what + # that cache holds for everything downstream, and the job then rebuilds it + # from cold: measured 2-3x the main-branch time across every job in the + # workflow, which puts this one past the cap and gets it killed mid-step + # with no test report. Raising the cap costs nothing on runs that finish + # early — `timeout-minutes` bounds a job, it does not reserve the time. + timeout-minutes: 90 steps: - name: Checkout code uses: actions/checkout@v7 From 766c38fc8946e0071813c13e6b51a01bc8c3feba Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 02:21:58 +0000 Subject: [PATCH 19/50] refactor: move six app-wide settings stores into commons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-account preference layer moved to commons earlier in this branch; the app-wide stores did not, and stayed behind on `Context.sharedPreferencesDataStore`. Six of them move here: Ots, Namecoin, the three Buzz groups and RelayGroupDeletion. Each was already a DataStore — the only thing tying it to Android was taking a `Context` to find the file. `AppPreferenceStores` is the app-wide counterpart to `AccountPreferenceStores`, keyed by file name rather than npub. It reproduces exactly what Android's `preferencesDataStore(name)` delegate resolves to, `filesDir/datastore/.preferences_pb`, so a store taken off the delegate opens the file it was already using: nothing to migrate, and a rollback finds its data where it left it. `AppPreferenceStoresTest` pins that path shape, because getting it wrong would silently start every install from empty settings rather than failing. The stores are not yet built through it. They take a `DataStore` and the call sites still hand them `appContext.sharedPreferencesDataStore` — deliberately, because these six share one file with UI and Tor, which have not moved yet. Two DataStores on one path is not a merge conflict, it is an IllegalStateException; the provider swaps in one step once the whole family is across. OtsSettingsStore lands in `jvmAndroid` rather than `commonMain` because OtsSettings does — it names OkHttp's explorer constants. Its blocking initial load moves out to AppModules: `current` has to answer synchronously for the resolver builder, so someone has to wait, and commonMain has no `runBlocking` to hide that behind. Explicit at the call site is an improvement. Renamed from `*SharedPreferences`/`*Preferences` to `*Store`, matching the commons convention and no longer naming a SharedPreferences that has not been involved for some time. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AXvKXakvup4inNFfAhhr4L --- .../com/vitorpamplona/amethyst/AppModules.kt | 35 +++--- .../vitorpamplona/amethyst/model/Account.kt | 6 +- .../DrawerSectionCollapsePreferences.kt | 2 +- .../loggedIn/buzz/AgentAttestationScreen.kt | 2 +- .../settings/NamecoinSettingsScreen.kt | 4 +- .../loggedIn/settings/OtsSettingsScreen.kt | 4 +- .../model/preferences/AppPreferenceStores.kt | 106 ++++++++++++++++++ .../model/preferences/BuzzAttestationStore.kt | 15 +-- .../model/preferences/BuzzChannelStarStore.kt | 13 ++- .../model/preferences/BuzzWorkspaceStore.kt | 13 ++- .../preferences/NamecoinSettingsStore.kt | 33 +++--- .../preferences/RelayGroupDeletionStore.kt | 13 ++- .../model/preferences/OtsSettingsStore.kt | 68 ++++++----- .../preferences/AppPreferenceStoresTest.kt | 88 +++++++++++++++ .../preferences/BuzzAttestationRestoreTest.kt | 16 +-- 15 files changed, 311 insertions(+), 107 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AppPreferenceStores.kt rename amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/BuzzAttestationPreferences.kt => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/BuzzAttestationStore.kt (95%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/BuzzChannelStarPreferences.kt => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/BuzzChannelStarStore.kt (92%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/BuzzWorkspacePreferences.kt => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/BuzzWorkspaceStore.kt (94%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/NamecoinSharedPreferences.kt => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/NamecoinSettingsStore.kt (90%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/RelayGroupDeletionPreferences.kt => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/RelayGroupDeletionStore.kt (89%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/OtsSharedPreferences.kt => commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/OtsSettingsStore.kt (60%) create mode 100644 commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AppPreferenceStoresTest.kt rename {amethyst/src/test/java/com/vitorpamplona/amethyst => commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons}/model/preferences/BuzzAttestationRestoreTest.kt (81%) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index a1c418dba3..80e1666fc1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -32,6 +32,12 @@ import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.model.nip03Timestamp.BitcoinExplorerEndpoint import com.vitorpamplona.amethyst.commons.model.nip03Timestamp.IncomingOtsEventVerifier import com.vitorpamplona.amethyst.commons.model.nip03Timestamp.TorAwareOkHttpOtsResolverBuilder +import com.vitorpamplona.amethyst.commons.model.preferences.BuzzAttestationStore +import com.vitorpamplona.amethyst.commons.model.preferences.BuzzChannelStarStore +import com.vitorpamplona.amethyst.commons.model.preferences.BuzzWorkspaceStore +import com.vitorpamplona.amethyst.commons.model.preferences.NamecoinSettingsStore +import com.vitorpamplona.amethyst.commons.model.preferences.OtsSettingsStore +import com.vitorpamplona.amethyst.commons.model.preferences.RelayGroupDeletionStore import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger import com.vitorpamplona.amethyst.commons.relayClient.BlockedRelayFilteringClient import com.vitorpamplona.amethyst.commons.relayClient.diagnostics.BootRelayDiagnostics @@ -62,13 +68,7 @@ import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.UiSettings import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState import com.vitorpamplona.amethyst.model.nip11RelayInfo.Nip11CachedRetriever -import com.vitorpamplona.amethyst.model.preferences.BuzzAttestationPreferences -import com.vitorpamplona.amethyst.model.preferences.BuzzChannelStarPreferences -import com.vitorpamplona.amethyst.model.preferences.BuzzWorkspacePreferences import com.vitorpamplona.amethyst.model.preferences.DrawerSectionCollapsePreferences -import com.vitorpamplona.amethyst.model.preferences.NamecoinSharedPreferences -import com.vitorpamplona.amethyst.model.preferences.OtsSharedPreferences -import com.vitorpamplona.amethyst.model.preferences.RelayGroupDeletionPreferences import com.vitorpamplona.amethyst.model.preferences.TorSharedPreferences import com.vitorpamplona.amethyst.model.preferences.UiSharedPreferences import com.vitorpamplona.amethyst.model.preferences.sharedPreferencesDataStore @@ -258,14 +258,20 @@ class AppModules( // Namecoin ElectrumX server preferences (global, like Tor settings) val namecoinPrefs by lazy { - Log.d("AppModules", "NamecoinSharedPreferences Init") - NamecoinSharedPreferences(appContext, applicationIOScope) + Log.d("AppModules", "NamecoinSettingsStore Init") + NamecoinSettingsStore(appContext.sharedPreferencesDataStore, applicationIOScope) } // OTS blockchain explorer preferences (global, like Tor settings) + // + // The blocking load is the one the store used to do inside its own + // constructor: `current` has to answer synchronously for the resolver + // builder, so somebody has to wait. It is explicit here rather than hidden + // in commonMain, which has no runBlocking to hide it behind. val otsPrefs by lazy { - Log.d("AppModules", "OtsSharedPreferences Init") - OtsSharedPreferences(appContext, applicationIOScope) + Log.d("AppModules", "OtsSettingsStore Init") + val store = appContext.sharedPreferencesDataStore + OtsSettingsStore(store, runBlocking { OtsSettingsStore.load(store) }) } // App services that should be run as soon as there are subscribers to their @@ -308,7 +314,8 @@ class AppModules( // Restore + persist the set of relay-group channels deleted (kind-9008) on this device, so a // deleted channel stays hidden across a restart even if the host relay re-announces a stale // kind-44100 for it (device-global; a delete is authoritative and terminal for everyone). - val relayGroupDeletionPrefs = RelayGroupDeletionPreferences(appContext, applicationIOScope) + val relayGroupDeletionPrefs = + RelayGroupDeletionStore(appContext.sharedPreferencesDataStore, applicationIOScope) // Restore + persist which drawer section headings the user has folded away, so the side menu // opens the way they left it (device-global: a collapsed heading is a per-device view choice, @@ -969,11 +976,11 @@ class AppModules( // start — Buzz membership is server-side) and the starred channels. Per account: the // joined set makes a relay first-party for NIP-42, and a star is personal. startBuzzPersistence = { account -> - BuzzWorkspacePreferences(appContext, account.scope, account.pubKey, account.buzzWorkspaces) - BuzzChannelStarPreferences(appContext, account.scope, account.pubKey, account.buzzChannelStars) + BuzzWorkspaceStore(appContext.sharedPreferencesDataStore, account.scope, account.pubKey, account.buzzWorkspaces) + BuzzChannelStarStore(appContext.sharedPreferencesDataStore, account.scope, account.pubKey, account.buzzChannelStars) // Eager like the rest, so a held NIP-OA attestation is loaded before this account's // first Buzz-relay AUTH rather than after it. - BuzzAttestationPreferences(appContext, account.scope, account.pubKey, account.buzzAttestation) + BuzzAttestationStore(appContext.sharedPreferencesDataStore, account.scope, account.pubKey, account.buzzAttestation) }, ) 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 3ba243658a..c9fcdde9a7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -457,17 +457,17 @@ class Account( // redeemed by this key and the relay grants membership to it alone — and this set makes the // relay first-party for NIP-42 (see AuthCoordinator.isFirstParty), so a device-global set would // hand every other logged-in account an automatic login on a workspace it never joined. - // Restored/persisted per account by BuzzWorkspacePreferences (see AccountCacheState). + // Restored/persisted per account by BuzzWorkspaceStore (see AccountCacheState). val buzzWorkspaces = BuzzWorkspaces() // The Buzz channels THIS account pinned. A star says which channels this user wants at the top // of the community view, so a shared set let one account reorder and badge every other one's - // channel list. Restored/persisted per account by BuzzChannelStarPreferences. + // channel list. Restored/persisted per account by BuzzChannelStarStore. val buzzChannelStars = BuzzChannelStars() // The NIP-OA attestation an owner issued to THIS account's key, attached to its Buzz-relay // AUTH so the relay grants virtual membership. Restored/persisted per account by - // BuzzAttestationPreferences. + // BuzzAttestationStore. val buzzAttestation = BuzzHeldAttestations(pubKey) // The relays this account approved by answering the NIP-42 prompt *without* the "remember" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/DrawerSectionCollapsePreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/DrawerSectionCollapsePreferences.kt index acca11936f..c29ae2cf13 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/DrawerSectionCollapsePreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/DrawerSectionCollapsePreferences.kt @@ -45,7 +45,7 @@ import kotlin.coroutines.cancellation.CancellationException * headings are folded is a per-device view choice, so unlike the hidden rows beside it in the drawer * it is never published to relays. * - * Mirrors [RelayGroupDeletionPreferences]: app-wide (not per-account), loads the saved names on + * Mirrors [RelayGroupDeletionStore]: app-wide (not per-account), loads the saved names on * construction, then writes every later change back. Takes the [DataStore] rather than a `Context` * so the whole cycle is exercised by a plain unit test against a temp file. * diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentAttestationScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentAttestationScreen.kt index d19c2c0c36..ccbbe2739b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentAttestationScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentAttestationScreen.kt @@ -181,7 +181,7 @@ fun AgentAttestationScreen( * Agent-side: paste an `auth` tag an owner issued to this account's key. [parseHeldAttestation] * turns it into a typed failure the field can show, and [BuzzHeldAttestations.put] re-checks the * signature before storing, so the auth coordinator attaches it when this account AUTHs to a Buzz - * relay. Persisted across restarts, per account, by `BuzzAttestationPreferences`. + * relay. Persisted across restarts, per account, by `BuzzAttestationStore`. */ @Composable private fun HoldAttestationSection( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsScreen.kt index 965b48da38..c20834d797 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsScreen.kt @@ -34,12 +34,12 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.commons.model.preferences.NamecoinSettingsStore import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.namecoin_settings import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.commons.ui.stringRes -import com.vitorpamplona.amethyst.model.preferences.NamecoinSharedPreferences import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumXClient import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinCoreRpcClient import kotlinx.coroutines.launch @@ -58,7 +58,7 @@ fun NamecoinSettingsScreen(nav: INav) { @OptIn(ExperimentalMaterial3Api::class) @Composable fun NamecoinSettingsScreen( - namecoinPrefs: NamecoinSharedPreferences, + namecoinPrefs: NamecoinSettingsStore, electrumXClient: () -> ElectrumXClient, namecoinCoreRpcClient: () -> NamecoinCoreRpcClient, nav: INav, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/OtsSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/OtsSettingsScreen.kt index afcfd317da..97ee6ae701 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/OtsSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/OtsSettingsScreen.kt @@ -34,13 +34,13 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.commons.model.preferences.OtsSettingsStore import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.ots_explorer_settings import com.vitorpamplona.amethyst.commons.tor.TorType import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.commons.ui.stringRes -import com.vitorpamplona.amethyst.model.preferences.OtsSharedPreferences import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow import kotlinx.coroutines.launch @@ -53,7 +53,7 @@ fun OtsSettingsScreen(nav: INav) { @OptIn(ExperimentalMaterial3Api::class) @Composable fun OtsSettingsScreen( - otsPrefs: OtsSharedPreferences, + otsPrefs: OtsSettingsStore, torSettings: TorSettingsFlow, nav: INav, ) { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AppPreferenceStores.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AppPreferenceStores.kt new file mode 100644 index 0000000000..c1f7bd5c5b --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AppPreferenceStores.kt @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.preferences + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import com.vitorpamplona.quartz.utils.cache.LargeCache +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.SupervisorJob +import okio.Path + +/** + * The app-wide DataStore files — the ones that belong to the install rather + * than to an account. + * + * [AccountPreferenceStores] is the same idea keyed by npub; this is keyed by + * file name, because these stores are one-per-app and several of them share a + * single file under different key prefixes (see [SHARED_SETTINGS]). + * + * # Why a holder rather than a `Context` delegate + * + * Android's `Context.preferencesDataStore(name)` delegate does this job, but + * only on Android and only from a `Context`. Taking the root directory as a + * parameter is what lets the stores themselves live in `commonMain` — every + * front end says where its data lives: `filesDir` on Android, the app data + * directory on desktop, a temp folder in tests. + * + * # The paths are the delegate's paths + * + * `preferencesDataStore(name = "x")` resolves to + * `filesDir/datastore/x.preferences_pb`, and [file] reproduces that exactly. + * Wired with `filesDir` as the root, a store moved off the delegate onto this + * holder opens the file it was already using, so nothing has to be migrated + * and a rollback finds its data where it left it. Changing [file]'s shape + * would silently orphan every existing install's settings. + */ +class AppPreferenceStores( + val rootFilesDir: () -> Path, +) { + companion object { + /** + * The file that UI, Tor, OTS, Namecoin and several smaller settings + * groups all share, each under its own key prefix (`ui.`, `tor.`, …). + * + * One file rather than one per group, which is how it has always been + * on Android: these are read together at startup, and a DataStore is + * a whole-file read. + */ + const val SHARED_SETTINGS = "shared_settings" + } + + /** + * One store per file, each on a scope this class owns. + * + * DataStore keeps a process-wide registry keyed by file path and refuses a + * second store on a path that already has a live one. Handing out a new + * store per call is therefore a crash rather than a waste — it has already + * happened twice in this codebase — so going through the cache is the + * point of the class, not an optimisation. + */ + private class Entry( + val scope: CoroutineScope, + val store: DataStore, + ) + + private val storeCache = LargeCache() + + fun file(name: String): Path = rootFilesDir() / "datastore" / "$name.preferences_pb" + + fun getDataStore(name: String): DataStore = + storeCache + .getOrCreate(name) { + val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + Entry( + scope, + PreferenceDataStoreFactory.createWithPath( + scope = scope, + produceFile = { file(name) }, + ), + ) + }.store + + /** The file UI, Tor, OTS, Namecoin and friends share. */ + fun sharedSettings(): DataStore = getDataStore(SHARED_SETTINGS) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/BuzzAttestationPreferences.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/BuzzAttestationStore.kt similarity index 95% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/BuzzAttestationPreferences.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/BuzzAttestationStore.kt index 618518e919..c7336b9cfd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/BuzzAttestationPreferences.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/BuzzAttestationStore.kt @@ -18,10 +18,11 @@ * 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.model.preferences +package com.vitorpamplona.amethyst.commons.model.preferences -import android.content.Context import androidx.compose.runtime.Stable +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey import com.vitorpamplona.amethyst.commons.model.buzz.BuzzHeldAttestations @@ -49,8 +50,8 @@ import kotlin.coroutines.cancellation.CancellationException * by the same gate that rejects a mistyped one. Construct once per account, eagerly. */ @Stable -class BuzzAttestationPreferences( - private val context: Context, +class BuzzAttestationStore( + private val store: DataStore, private val scope: CoroutineScope, private val pubKeyHex: HexKey, private val attestation: BuzzHeldAttestations, @@ -84,7 +85,7 @@ class BuzzAttestationPreferences( private suspend fun restoreFromDisk() { try { - val prefs = context.sharedPreferencesDataStore.data.first() + val prefs = store.data.first() // put() verifies, so a credential that no longer checks out is dropped either way. restoreFrom(prefs[key], prefs[LEGACY_KEY], pubKeyHex)?.let(attestation::put) } catch (e: Exception) { @@ -95,7 +96,7 @@ class BuzzAttestationPreferences( private suspend fun persist(held: OwnerAttestation?) { try { - context.sharedPreferencesDataStore.edit { prefs -> + store.edit { prefs -> // Write [NONE] rather than removing the key: removing it is indistinguishable from // never having migrated, which would let the legacy list re-seed a credential the // user just deleted. See [restoreFrom]. @@ -126,7 +127,7 @@ class BuzzAttestationPreferences( /** * Which attestation to reinstate, given this account's saved value and the pre-namespacing - * device-global list. Pure, so the migration precedence is testable without a `Context`. + * device-global list. Pure, so the migration precedence is testable without a store. * * [saved] wins whenever it is present, [NONE] included. Only a never-migrated account falls * back to [legacy], and it takes just the entry issued to its own key — that list was diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/BuzzChannelStarPreferences.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/BuzzChannelStarStore.kt similarity index 92% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/BuzzChannelStarPreferences.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/BuzzChannelStarStore.kt index 96c62f23eb..d4597fb1e3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/BuzzChannelStarPreferences.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/BuzzChannelStarStore.kt @@ -18,10 +18,11 @@ * 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.model.preferences +package com.vitorpamplona.amethyst.commons.model.preferences -import android.content.Context import androidx.compose.runtime.Stable +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringSetPreferencesKey import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelStars @@ -42,8 +43,8 @@ import kotlin.coroutines.cancellation.CancellationException * then writes every later change back. Construct once per account, eagerly. */ @Stable -class BuzzChannelStarPreferences( - private val context: Context, +class BuzzChannelStarStore( + private val store: DataStore, private val scope: CoroutineScope, private val pubKeyHex: HexKey, private val stars: BuzzChannelStars, @@ -60,7 +61,7 @@ class BuzzChannelStarPreferences( private suspend fun restoreFromDisk() { try { - val prefs = context.sharedPreferencesDataStore.data.first() + val prefs = store.data.first() // Fall back to the pre-namespacing device-global key so an upgrade doesn't unpin // everything. That set is what every account already saw; the next toggle writes to this // account's own key and takes over. The legacy key is left for other accounts to seed @@ -78,7 +79,7 @@ class BuzzChannelStarPreferences( // Always write the starred set, empty included — never remove the key. An absent key // means "never migrated" and re-seeds from the legacy one above, so removing it // would undo the user's last removal on the next launch. - context.sharedPreferencesDataStore.edit { prefs -> prefs[key] = ids } + store.edit { prefs -> prefs[key] = ids } } catch (e: Exception) { if (e is CancellationException) throw e Log.e("BuzzChannelStarPrefs") { "Error writing starred channels: ${e.message}" } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/BuzzWorkspacePreferences.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/BuzzWorkspaceStore.kt similarity index 94% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/BuzzWorkspacePreferences.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/BuzzWorkspaceStore.kt index 10cc89faa1..60d051c855 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/BuzzWorkspacePreferences.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/BuzzWorkspaceStore.kt @@ -18,10 +18,11 @@ * 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.model.preferences +package com.vitorpamplona.amethyst.commons.model.preferences -import android.content.Context import androidx.compose.runtime.Stable +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringSetPreferencesKey import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaces @@ -57,8 +58,8 @@ import kotlin.coroutines.cancellation.CancellationException * per account, eagerly. */ @Stable -class BuzzWorkspacePreferences( - private val context: Context, +class BuzzWorkspaceStore( + private val store: DataStore, private val scope: CoroutineScope, private val pubKeyHex: HexKey, private val workspaces: BuzzWorkspaces, @@ -76,7 +77,7 @@ class BuzzWorkspacePreferences( private suspend fun restoreFromDisk() { try { - val prefs = context.sharedPreferencesDataStore.data.first() + val prefs = store.data.first() // Fall back to the pre-namespacing device-global key so an upgrade doesn't empty the // workspaces hub. That set is whatever any account joined, which is exactly what every // account already saw before this became per-account — so seeding from it changes @@ -97,7 +98,7 @@ class BuzzWorkspacePreferences( // Always write the joined set, empty included — never remove the key. An absent key // means "never migrated" and re-seeds from the legacy one above, so removing it // would undo the user's last removal on the next launch. - context.sharedPreferencesDataStore.edit { prefs -> + store.edit { prefs -> prefs[key] = relays.map { it.url }.toSet() } } catch (e: Exception) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/NamecoinSharedPreferences.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/NamecoinSettingsStore.kt similarity index 90% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/NamecoinSharedPreferences.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/NamecoinSettingsStore.kt index 0844905ab4..964157ff42 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/NamecoinSharedPreferences.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/NamecoinSettingsStore.kt @@ -18,10 +18,11 @@ * 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.model.preferences +package com.vitorpamplona.amethyst.commons.model.preferences -import android.content.Context import androidx.compose.runtime.Stable +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey @@ -39,20 +40,20 @@ import kotlinx.serialization.json.Json import kotlin.coroutines.cancellation.CancellationException /** - * Persistent storage for [NamecoinSettings], following the same pattern as - * [TorSharedPreferences]. + * Persistent storage for [NamecoinSettings] — which ElectrumX servers and + * backend resolve `.bit` names, and the certificates the user has pinned. * - * Uses the app-wide [sharedPreferencesDataStore] so Namecoin resolution - * settings (like Tor settings) are global — not per-account. + * App-wide, not per-account, and shares [AppPreferenceStores.SHARED_SETTINGS] + * with the other global settings groups under its own `namecoin.` key prefix. * * The current settings are available synchronously via [settings] (a * [StateFlow]) and can be read in non-suspend contexts (e.g. in a * `serverListProvider` lambda). */ @Stable -class NamecoinSharedPreferences( - private val context: Context, - private val scope: CoroutineScope, +class NamecoinSettingsStore( + private val store: DataStore, + scope: CoroutineScope, ) { private val json = Json { ignoreUnknownKeys = true } @@ -148,18 +149,18 @@ class NamecoinSharedPreferences( private suspend fun savePinnedCerts(certs: List) { try { - context.sharedPreferencesDataStore.edit { prefs -> + store.edit { prefs -> prefs[KEY_PINNED_CERTS] = json.encodeToString(certs) } } catch (e: Exception) { if (e is CancellationException) throw e - Log.e("NamecoinPrefs") { "Error writing pinned certs: ${e.message}" } + Log.e("NamecoinSettingsStore") { "Error writing pinned certs: ${e.message}" } } } private suspend fun loadPinnedCertsFromDisk(): List = try { - val prefs = context.sharedPreferencesDataStore.data.first() + val prefs = store.data.first() val certsJson = prefs[KEY_PINNED_CERTS] if (certsJson != null) { json.decodeFromString>(certsJson) @@ -176,7 +177,7 @@ class NamecoinSharedPreferences( private suspend fun persist(settings: NamecoinSettings) { _settings.value = settings try { - context.sharedPreferencesDataStore.edit { prefs -> + store.edit { prefs -> prefs[KEY_ENABLED] = settings.enabled prefs[KEY_CUSTOM_SERVERS] = json.encodeToString( @@ -189,13 +190,13 @@ class NamecoinSharedPreferences( } } catch (e: Exception) { if (e is CancellationException) throw e - Log.e("NamecoinPrefs") { "Error writing DataStore: ${e.message}" } + Log.e("NamecoinSettingsStore") { "Error writing DataStore: ${e.message}" } } } private suspend fun loadFromDisk(): NamecoinSettings? = try { - val prefs = context.sharedPreferencesDataStore.data.first() + val prefs = store.data.first() val enabled = prefs[KEY_ENABLED] ?: true val serversJson = prefs[KEY_CUSTOM_SERVERS] val servers = @@ -236,7 +237,7 @@ class NamecoinSharedPreferences( ) } catch (e: Exception) { if (e is CancellationException) throw e - Log.e("NamecoinPrefs") { "Error reading DataStore: ${e.message}" } + Log.e("NamecoinSettingsStore") { "Error reading DataStore: ${e.message}" } null } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/RelayGroupDeletionPreferences.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/RelayGroupDeletionStore.kt similarity index 89% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/RelayGroupDeletionPreferences.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/RelayGroupDeletionStore.kt index 99e1f99c01..2a00912633 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/RelayGroupDeletionPreferences.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/RelayGroupDeletionStore.kt @@ -18,10 +18,11 @@ * 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.model.preferences +package com.vitorpamplona.amethyst.commons.model.preferences -import android.content.Context import androidx.compose.runtime.Stable +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringSetPreferencesKey import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupDeletions @@ -40,8 +41,8 @@ import kotlin.coroutines.cancellation.CancellationException * back. Construct once, eagerly. */ @Stable -class RelayGroupDeletionPreferences( - private val context: Context, +class RelayGroupDeletionStore( + private val store: DataStore, private val scope: CoroutineScope, ) { init { @@ -54,7 +55,7 @@ class RelayGroupDeletionPreferences( private suspend fun restoreFromDisk() { try { - val raw = context.sharedPreferencesDataStore.data.first()[KEY] ?: return + val raw = store.data.first()[KEY] ?: return if (raw.isNotEmpty()) RelayGroupDeletions.restore(raw) } catch (e: Exception) { if (e is CancellationException) throw e @@ -64,7 +65,7 @@ class RelayGroupDeletionPreferences( private suspend fun persist(keys: Set) { try { - context.sharedPreferencesDataStore.edit { prefs -> prefs[KEY] = keys } + store.edit { prefs -> prefs[KEY] = keys } } catch (e: Exception) { if (e is CancellationException) throw e Log.e("RelayGroupDeletionPrefs") { "Error writing deleted channels: ${e.message}" } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/OtsSharedPreferences.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/OtsSettingsStore.kt similarity index 60% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/OtsSharedPreferences.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/OtsSettingsStore.kt index 9063633321..b3e7815201 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/OtsSharedPreferences.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/OtsSettingsStore.kt @@ -18,51 +18,62 @@ * 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.model.preferences +package com.vitorpamplona.amethyst.commons.model.preferences -import android.content.Context import androidx.compose.runtime.Stable +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey import com.vitorpamplona.amethyst.commons.model.nip03Timestamp.OtsSettings import com.vitorpamplona.quartz.utils.Log -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.first -import kotlinx.coroutines.runBlocking import kotlin.coroutines.cancellation.CancellationException /** - * Persistent storage for [OtsSettings], following the same pattern as - * [NamecoinSharedPreferences]. + * Persistent storage for [OtsSettings] — which blockchain explorer the user + * has pointed OpenTimestamps at. * - * Uses the app-wide [sharedPreferencesDataStore] so OTS explorer settings - * are global — not per-account. + * App-wide, not per-account, and shares [AppPreferenceStores.SHARED_SETTINGS] + * with the other global settings groups under its own `ots.` key prefix. + * + * Lives in `jvmAndroid` rather than `commonMain` only because [OtsSettings] + * does: it names OkHttp's explorer constants, and OkHttp is JVM-bound. Android + * and Desktop still share it. + * + * [initial] is taken rather than read here because the current value has to be + * available synchronously from [current] — a resolver builder reads it from a + * non-suspending lambda. The caller loads it with [load] and decides how to + * wait; commonMain has no `runBlocking` to hide that decision behind. */ @Stable -class OtsSharedPreferences( - private val context: Context, - private val scope: CoroutineScope, +class OtsSettingsStore( + private val store: DataStore, + initial: OtsSettings, ) { companion object { val KEY_CUSTOM_EXPLORER_URL = stringPreferencesKey("ots.customExplorerUrl") + + /** The stored settings, or [OtsSettings.DEFAULT] if unset or unreadable. */ + suspend fun load(store: DataStore): OtsSettings = + try { + val url = store.data.first()[KEY_CUSTOM_EXPLORER_URL]?.takeIf { it.isNotBlank() } + OtsSettings(customExplorerUrl = url) + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("OtsSettingsStore") { "Error reading DataStore: ${e.message}" } + OtsSettings.DEFAULT + } } - /** - * Current settings, loaded synchronously at init to avoid races. - */ - private val _settings = - MutableStateFlow( - runBlocking { loadFromDisk() ?: OtsSettings.DEFAULT }, - ) + private val _settings = MutableStateFlow(initial) val settings: StateFlow = _settings /** Synchronous snapshot — safe to call from resolver builder lambdas. */ val current: OtsSettings get() = _settings.value - // ── Mutators ─────────────────────────────────────────────────────── - suspend fun setCustomExplorerUrl(url: String?) { val normalized = url?.trim()?.takeIf { it.isNotBlank() } persist(current.copy(customExplorerUrl = normalized)) @@ -72,12 +83,10 @@ class OtsSharedPreferences( persist(OtsSettings.DEFAULT) } - // ── Internal ─────────────────────────────────────────────────────── - private suspend fun persist(settings: OtsSettings) { _settings.value = settings try { - context.sharedPreferencesDataStore.edit { prefs -> + store.edit { prefs -> val customExplorerUrl = settings.customExplorerUrl if (customExplorerUrl != null) { prefs[KEY_CUSTOM_EXPLORER_URL] = customExplorerUrl @@ -87,18 +96,7 @@ class OtsSharedPreferences( } } catch (e: Exception) { if (e is CancellationException) throw e - Log.e("OtsPrefs") { "Error writing DataStore: ${e.message}" } + Log.e("OtsSettingsStore") { "Error writing DataStore: ${e.message}" } } } - - private suspend fun loadFromDisk(): OtsSettings? = - try { - val prefs = context.sharedPreferencesDataStore.data.first() - val url = prefs[KEY_CUSTOM_EXPLORER_URL]?.takeIf { it.isNotBlank() } - OtsSettings(customExplorerUrl = url) - } catch (e: Exception) { - if (e is CancellationException) throw e - Log.e("OtsPrefs") { "Error reading DataStore: ${e.message}" } - null - } } diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AppPreferenceStoresTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AppPreferenceStoresTest.kt new file mode 100644 index 0000000000..3c8106602f --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AppPreferenceStoresTest.kt @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.preferences + +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import okio.Path.Companion.toOkioPath +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class AppPreferenceStoresTest { + @get:Rule + val folder = TemporaryFolder() + + private fun stores() = AppPreferenceStores(rootFilesDir = { folder.root.toOkioPath() }) + + /** + * The load-bearing assumption of the whole move. + * + * Android's `Context.preferencesDataStore(name = "x")` resolves to + * `filesDir/datastore/x.preferences_pb`. A store taken off that delegate + * and put on this holder has to land on the same file, or every existing + * install silently starts from empty settings. This pins the shape. + */ + @Test + fun theFilePathMatchesTheAndroidDelegate() { + val root = folder.root.toOkioPath() + + assertEquals(root / "datastore" / "shared_settings.preferences_pb", stores().file("shared_settings")) + assertEquals(root / "datastore" / "favorite_apps.preferences_pb", stores().file("favorite_apps")) + } + + /** DataStore refuses a second live store on one path, so this must dedupe. */ + @Test + fun oneStorePerFile() { + val subject = stores() + + assertSame(subject.getDataStore("shared_settings"), subject.getDataStore("shared_settings")) + assertSame(subject.sharedSettings(), subject.getDataStore("shared_settings")) + } + + @Test + fun differentNamesAreDifferentStores() = + runTest { + val subject = stores() + val key = stringPreferencesKey("k") + + subject.getDataStore("one").edit { it[key] = "first" } + subject.getDataStore("two").edit { it[key] = "second" } + + assertEquals("first", subject.getDataStore("one").data.first()[key]) + assertEquals("second", subject.getDataStore("two").data.first()[key]) + } + + @Test + fun writesLandInTheExpectedFile() = + runTest { + val subject = stores() + subject.sharedSettings().edit { it[stringPreferencesKey("ui.theme")] = "DARK" } + + val expected = java.io.File(folder.root, "datastore/shared_settings.preferences_pb") + assertTrue(expected.absolutePath, expected.exists()) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/preferences/BuzzAttestationRestoreTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/BuzzAttestationRestoreTest.kt similarity index 81% rename from amethyst/src/test/java/com/vitorpamplona/amethyst/model/preferences/BuzzAttestationRestoreTest.kt rename to commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/BuzzAttestationRestoreTest.kt index a14c8cdac8..77c9c06c99 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/preferences/BuzzAttestationRestoreTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/BuzzAttestationRestoreTest.kt @@ -18,17 +18,17 @@ * 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.model.preferences +package com.vitorpamplona.amethyst.commons.model.preferences import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Test /** - * The migration precedence in [BuzzAttestationPreferences.restoreFrom]: which of the two on-disk + * The migration precedence in [BuzzAttestationStore.restoreFrom]: which of the two on-disk * shapes wins when the held attestation moved from one device-global list to a per-account key. * - * The store itself needs a `Context`, so the decision is pulled out as a pure function — this is + * The store itself needs a DataStore, so the decision is pulled out as a pure function — this is * the part with the sharp edge, and it is the part the DataStore round-trip cannot express. */ class BuzzAttestationRestoreTest { @@ -46,12 +46,12 @@ class BuzzAttestationRestoreTest { @Test fun nothingSavedAnywhereRestoresNothing() { - assertNull(BuzzAttestationPreferences.restoreFrom(null, null, me)) + assertNull(BuzzAttestationStore.restoreFrom(null, null, me)) } @Test fun thisAccountsOwnKeyWins() { - val restored = BuzzAttestationPreferences.restoreFrom(saved(), legacyList(me), me) + val restored = BuzzAttestationStore.restoreFrom(saved(), legacyList(me), me) assertEquals(owner, restored?.ownerPubKey) } @@ -61,12 +61,12 @@ class BuzzAttestationRestoreTest { // per-account key, which is indistinguishable from "never migrated" — so the next launch // seeded it straight back out of the legacy list, which nothing ever clears. An explicit // tombstone is the only thing that can say "migrated, and holding nothing". - assertNull(BuzzAttestationPreferences.restoreFrom("", legacyList(me), me)) + assertNull(BuzzAttestationStore.restoreFrom("", legacyList(me), me)) } @Test fun aNeverMigratedAccountTakesItsOwnEntryFromTheLegacyList() { - val restored = BuzzAttestationPreferences.restoreFrom(null, legacyList(someoneElse, me), me) + val restored = BuzzAttestationStore.restoreFrom(null, legacyList(someoneElse, me), me) assertEquals(owner, restored?.ownerPubKey) } @@ -74,6 +74,6 @@ class BuzzAttestationRestoreTest { fun anotherAgentsLegacyEntryIsNeverPickedUp() { // The legacy list was already agent-keyed, so the migration is exact rather than // best-effort: there is no shared blob to accidentally inherit. - assertNull(BuzzAttestationPreferences.restoreFrom(null, legacyList(someoneElse), me)) + assertNull(BuzzAttestationStore.restoreFrom(null, legacyList(someoneElse), me)) } } From 0387ef33066cbf9782df7cc955045e97ea713c5e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 12:53:20 +0000 Subject: [PATCH 20/50] refactor: move the UI settings model and store into commons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UiSettings could not follow the other preference groups into commons because its eight enums each carried a `resourceId: StringResource` pointing at commonsUI's generated `Res` class. commonsUI depends on commons, not the reverse, so commons structurally cannot see it and verifyKmpPurity would reject it either way. The settings themselves are plain data any front end may read, so the data moves and the labels stay. - commons/model: UiSettings, UiSettingsFlow and the nine enums, without the resource ids. Pure data, CLI-safe, per commons/ARCHITECTURE.md §1. - commons/model/preferences/UiSettingsStore: the key table, the defaults and the one-shot copy out of the old shared_settings blob. The legacy reader is injected, because that file is Android's and this store is not. - commonsUI/ui/settings/UiSettingsLabels: the labels, as extension properties keeping the `resourceId` name so no call site changes spelling. Each `when` is exhaustive, so a new enum constant is still a compile error rather than a missing label at runtime. - amethyst keeps what is genuinely platform work: the per-application night mode override the launch splash reads, and AppCompat's locale list. Neither has a desktop equivalent. The shared_settings migration moves to the file's delegate rather than sitting inside the UI store: seven other stores share that file, and DataStore runs a file's migrations once, on whichever store opens it first. Hanging the copy off the UI store alone would have made it depend on construction order. Three of the nine labels went unused — ConnectivityType, FeatureSetType and FontFamilyType are picked through the narrower `shortLabelRes` helpers in AppSettingsScreen — and BooleanType's was misspelled `reourceId`, which is how it stayed unread. They are dropped rather than carried across; the strings they named are still used by those short labels. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AXvKXakvup4inNFfAhhr4L --- .../components/SelectNotificationProvider.kt | 2 +- .../com/vitorpamplona/amethyst/AppModules.kt | 2 +- .../amethyst/LocalPreferences.kt | 2 +- .../amethyst/favorites/FavoriteAppLauncher.kt | 2 +- .../model/preferences/UISharedPreferences.kt | 215 ++------------- .../amethyst/napplet/NappletLauncher.kt | 2 +- .../ui/broadcast/DisplayBroadcastProgress.kt | 2 +- .../note/elements/NoteHeaderMarkersPreview.kt | 2 +- .../amethyst/ui/screen/UiSettingsState.kt | 10 +- .../ui/screen/loggedIn/AccountViewModel.kt | 2 +- .../loggedIn/embed/EmbeddedTabFactory.kt | 2 +- .../loggedIn/embed/EmbeddedTabThemeWatcher.kt | 2 +- .../loggedIn/home/ShortNotePostScreen.kt | 2 +- .../loggedIn/home/ShortNotePostViewModel.kt | 2 +- .../notifications/NotificationScreen.kt | 2 +- .../loggedIn/settings/AppSettingsScreen.kt | 17 +- .../settings/ComposeSettingsScreen.kt | 4 +- .../settings/HomeTabsSettingsScreen.kt | 2 +- .../settings/ProfileUiSettingsScreen.kt | 5 +- .../settings/SecurityFiltersScreen.kt | 5 +- .../suggestion/DetectedWorkoutCarousel.kt | 2 +- .../vitorpamplona/amethyst/ui/theme/Theme.kt | 8 +- .../components/SelectNotificationProvider.kt | 2 +- .../amethyst/commons}/model/UiSettings.kt | 101 +++---- .../amethyst/commons}/model/UiSettingsFlow.kt | 2 +- .../model/preferences/UiSettingsStore.kt | 247 ++++++++++++++++++ .../commons/ui/settings/UiSettingsLabels.kt | 111 ++++++++ 27 files changed, 459 insertions(+), 298 deletions(-) rename {amethyst/src/main/java/com/vitorpamplona/amethyst => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons}/model/UiSettings.kt (66%) rename {amethyst/src/main/java/com/vitorpamplona/amethyst => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons}/model/UiSettingsFlow.kt (99%) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/UiSettingsStore.kt create mode 100644 commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/settings/UiSettingsLabels.kt diff --git a/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/SelectNotificationProvider.kt b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/SelectNotificationProvider.kt index 78a6fcd112..114e9c5ee7 100644 --- a/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/SelectNotificationProvider.kt +++ b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/SelectNotificationProvider.kt @@ -56,6 +56,7 @@ import com.halilibo.richtext.ui.material3.RichText import com.halilibo.richtext.ui.resolveDefaults import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.UiSettingsFlow import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.error_dialog_button_ok import com.vitorpamplona.amethyst.commons.resources.push_server_explainer @@ -71,7 +72,6 @@ import com.vitorpamplona.amethyst.commons.ui.components.SpinnerSelectionDialog import com.vitorpamplona.amethyst.commons.ui.components.TextSpinner import com.vitorpamplona.amethyst.commons.ui.components.TitleExplainer import com.vitorpamplona.amethyst.commons.ui.stringRes -import com.vitorpamplona.amethyst.model.UiSettingsFlow import com.vitorpamplona.amethyst.service.notifications.PushDistributorHandler import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsBlockTile import com.vitorpamplona.quartz.utils.Log diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 80e1666fc1..781ac1b91d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -28,6 +28,7 @@ import androidx.security.crypto.EncryptedSharedPreferences import coil3.disk.DiskCache import coil3.memory.MemoryCache import com.vitorpamplona.amethyst.commons.model.NoteState +import com.vitorpamplona.amethyst.commons.model.UiSettings import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.model.nip03Timestamp.BitcoinExplorerEndpoint import com.vitorpamplona.amethyst.commons.model.nip03Timestamp.IncomingOtsEventVerifier @@ -65,7 +66,6 @@ import com.vitorpamplona.amethyst.commons.tor.TorSettings import com.vitorpamplona.amethyst.connectedApps.DataStoreNostrSignerPermissionStore import com.vitorpamplona.amethyst.connectedApps.nip46.DataStoreNip46ClientStore import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.model.UiSettings import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState import com.vitorpamplona.amethyst.model.nip11RelayInfo.Nip11CachedRetriever import com.vitorpamplona.amethyst.model.preferences.DrawerSectionCollapsePreferences diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 8c514f3774..430f20be37 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -26,6 +26,7 @@ import android.content.SharedPreferences import androidx.compose.runtime.Immutable import androidx.core.content.edit import com.vitorpamplona.amethyst.commons.model.HomeFeedType +import com.vitorpamplona.amethyst.commons.model.UiSettings import com.vitorpamplona.amethyst.commons.model.chats.ChatFeedType import com.vitorpamplona.amethyst.commons.model.clink.ClinkDebitWalletEntry import com.vitorpamplona.amethyst.commons.model.concord.ConcordViewMode @@ -59,7 +60,6 @@ import com.vitorpamplona.amethyst.commons.model.preferences.readLegacyAccountSec import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy import com.vitorpamplona.amethyst.model.AccountSettings -import com.vitorpamplona.amethyst.model.UiSettings import com.vitorpamplona.amethyst.model.backups.BackupConflictStorage import com.vitorpamplona.amethyst.model.nip60Cashu.CashuPreferences import com.vitorpamplona.amethyst.model.preferences.UiSharedPreferences diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppLauncher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppLauncher.kt index e4029c8627..cd6fb36b17 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppLauncher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppLauncher.kt @@ -27,8 +27,8 @@ import android.os.Bundle import android.widget.Toast import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp +import com.vitorpamplona.amethyst.commons.model.ThemeType import com.vitorpamplona.amethyst.commons.model.cache.LocalCache -import com.vitorpamplona.amethyst.model.ThemeType import com.vitorpamplona.amethyst.napplet.NappletLauncher import com.vitorpamplona.amethyst.napplet.NappletWebViewProfiles import com.vitorpamplona.amethyst.napplet.WebAppNetworkRegistry diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt index 4148227f82..fe4df20db7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt @@ -27,26 +27,14 @@ import androidx.appcompat.app.AppCompatDelegate import androidx.compose.runtime.Stable import androidx.core.content.getSystemService import androidx.core.os.LocaleListCompat -import androidx.datastore.core.DataMigration import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.MutablePreferences import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.core.booleanPreferencesKey -import androidx.datastore.preferences.core.edit -import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore import com.vitorpamplona.amethyst.LocalPreferences -import com.vitorpamplona.amethyst.commons.model.preferences.CopyOnceMigration -import com.vitorpamplona.amethyst.model.AccentColorType -import com.vitorpamplona.amethyst.model.BooleanType -import com.vitorpamplona.amethyst.model.ConnectivityType -import com.vitorpamplona.amethyst.model.FeatureSetType -import com.vitorpamplona.amethyst.model.FontFamilyType -import com.vitorpamplona.amethyst.model.FontSizeType -import com.vitorpamplona.amethyst.model.ProfileGalleryType -import com.vitorpamplona.amethyst.model.ThemeType -import com.vitorpamplona.amethyst.model.UiSettings -import com.vitorpamplona.amethyst.model.UiSettingsFlow +import com.vitorpamplona.amethyst.commons.model.ThemeType +import com.vitorpamplona.amethyst.commons.model.UiSettings +import com.vitorpamplona.amethyst.commons.model.UiSettingsFlow +import com.vitorpamplona.amethyst.commons.model.preferences.UiSettingsStore import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -54,25 +42,43 @@ import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.withContext import kotlin.coroutines.cancellation.CancellationException -/** The UI settings store. See [UiSharedPreferences.migrations] for the copy it carries. */ +/** + * The file UI, Tor, Namecoin, OTS and the Buzz stores all share, each under its + * own key prefix. + * + * The migration is attached here, at the file, rather than inside + * [UiSettingsStore]: whichever of those stores is constructed first is the one + * that opens the file, and DataStore runs a file's migrations once, on that + * first open. Hanging it off the UI store alone would make the copy depend on + * load order. + */ val Context.sharedPreferencesDataStore: DataStore by preferencesDataStore( name = "shared_settings", - produceMigrations = { UiSharedPreferences.migrations() }, + produceMigrations = { UiSettingsStore.migrations { LocalPreferences.loadSharedSettings() } }, ) +/** + * The Android half of the UI settings: the flows the app observes, and the two + * platform side effects that a theme or language change has to perform. + * + * Persistence is [UiSettingsStore] in `commons`, which every front end shares. + * What stays here is the part that has no desktop equivalent — the per-app night + * mode override that the launch splash reads, and AppCompat's locale list. + */ @Stable class UiSharedPreferences( prefs: UiSettings, val context: Context, val scope: CoroutineScope, ) { + private val store = UiSettingsStore(context.sharedPreferencesDataStore) { LocalPreferences.loadSharedSettings() } + // UI Preferences. Makes sure to wait for it to avoid blinking themes and language preferences val value = UiSettingsFlow.build(prefs) @@ -182,7 +188,7 @@ class UiSharedPreferences( .debounce(1000) .distinctUntilChanged() .onEach { - save(it, context) + store.save(it) }.flowOn(Dispatchers.IO) .stateIn( scope, @@ -191,173 +197,6 @@ class UiSharedPreferences( ) companion object { - // loads faster when individualized - val UI_THEME = stringPreferencesKey("ui.theme") - val UI_LANGUAGE = stringPreferencesKey("ui.language") - val UI_SHOW_IMAGES = stringPreferencesKey("ui.show_images") - val UI_START_PLAYBACK = stringPreferencesKey("ui.start_playback") - val UI_PLAY_VIDEOS = stringPreferencesKey("ui.play_videos") - val UI_SHOW_URL_PREVIEW = stringPreferencesKey("ui.show_url_preview") - val UI_HIDE_NAVIGATION_BARS = stringPreferencesKey("ui.hide_navigation_bars") - val UI_SHOW_PROFILE_PICTURES = stringPreferencesKey("ui.show_profile_pictures") - val UI_DONT_SHOW_PUSH_NOTIFICATION_SELECTOR = booleanPreferencesKey("ui.dont_show_push_notification_selector") - val UI_DONT_ASK_FOR_NOTIFICATION_PERMISSIONS = booleanPreferencesKey("ui.dont_ask_for_notification_permissions") - val UI_FEATURE_SET = stringPreferencesKey("ui.feature_set") - val UI_GALLERY_SET = stringPreferencesKey("ui.gallery_set") - val UI_PROPOSE_AI_IMPROVEMENTS = stringPreferencesKey("ui.propose_ai_improvements") - val UI_USE_TRACKED_BROADCASTS = stringPreferencesKey("ui.use_tracked_broadcasts") - val UI_AUTOMATICALLY_CREATE_DRAFTS = stringPreferencesKey("ui.automatically_create_drafts") - val UI_SHOW_HOME_NEW_THREADS_TAB = booleanPreferencesKey("ui.show_home_new_threads_tab") - val UI_SHOW_HOME_CONVERSATIONS_TAB = booleanPreferencesKey("ui.show_home_conversations_tab") - val UI_SHOW_HOME_EVERYTHING_TAB = booleanPreferencesKey("ui.show_home_everything_tab") - val UI_SHOW_PROFILE_BADGES = booleanPreferencesKey("ui.show_profile_badges") - val UI_SHOW_PROFILE_APP_RECOMMENDATIONS = booleanPreferencesKey("ui.show_profile_app_recommendations") - val UI_SHOW_PROFILE_ZAP_RECEIVED_FEED = booleanPreferencesKey("ui.show_profile_zap_received_feed") - val UI_SHOW_PROFILE_FOLLOWERS_FEED = booleanPreferencesKey("ui.show_profile_followers_feed") - val UI_DONT_SHOW_ONCHAIN_PUBLIC_WARNING = booleanPreferencesKey("ui.dont_show_onchain_public_warning") - val UI_SUGGEST_WORKOUTS_FROM_HEALTH_CONNECT = stringPreferencesKey("ui.suggest_workouts_from_health_connect") - val UI_ACCENT_COLOR = stringPreferencesKey("ui.accent_color") - val UI_FONT_FAMILY = stringPreferencesKey("ui.font_family") - val UI_FONT_SIZE = stringPreferencesKey("ui.font_size") - val UI_COMPOSE_SIGNATURE = stringPreferencesKey("ui.compose_signature") - val UI_SHOW_ONCHAIN_WALLET = booleanPreferencesKey("ui.show_onchain_wallet") - val UI_SHOW_PAYTO_ZAP_CHIP = booleanPreferencesKey("ui.show_payto_zap_chip") - - suspend fun uiPreferences(context: Context): UiSettings? = - try { - // Get the preference flow and take the first value. - val preferences = context.sharedPreferencesDataStore.data.first() - - val featureSet = preferences[UI_FEATURE_SET]?.let { FeatureSetType.valueOf(it) } ?: FeatureSetType.SIMPLIFIED - - UiSettings( - theme = preferences[UI_THEME]?.let { ThemeType.valueOf(it) } ?: ThemeType.SYSTEM, - preferredLanguage = preferences[UI_LANGUAGE]?.ifBlank { null }, - automaticallyShowImages = preferences[UI_SHOW_IMAGES]?.let { ConnectivityType.valueOf(it) } ?: ConnectivityType.ALWAYS, - automaticallyStartPlayback = preferences[UI_START_PLAYBACK]?.let { ConnectivityType.valueOf(it) } ?: ConnectivityType.ALWAYS, - automaticallyPlayVideos = preferences[UI_PLAY_VIDEOS]?.let { BooleanType.valueOf(it) } ?: BooleanType.ALWAYS, - automaticallyShowUrlPreview = preferences[UI_SHOW_URL_PREVIEW]?.let { ConnectivityType.valueOf(it) } ?: ConnectivityType.ALWAYS, - automaticallyHideNavigationBars = preferences[UI_HIDE_NAVIGATION_BARS]?.let { BooleanType.valueOf(it) } ?: BooleanType.ALWAYS, - automaticallyShowProfilePictures = preferences[UI_SHOW_PROFILE_PICTURES]?.let { ConnectivityType.valueOf(it) } ?: ConnectivityType.ALWAYS, - dontShowPushNotificationSelector = preferences[UI_DONT_SHOW_PUSH_NOTIFICATION_SELECTOR] ?: false, - dontAskForNotificationPermissions = preferences[UI_DONT_ASK_FOR_NOTIFICATION_PERMISSIONS] ?: false, - featureSet = featureSet, - gallerySet = preferences[UI_GALLERY_SET]?.let { ProfileGalleryType.valueOf(it) } ?: ProfileGalleryType.CLASSIC, - automaticallyProposeAiImprovements = preferences[UI_PROPOSE_AI_IMPROVEMENTS]?.let { BooleanType.valueOf(it) } ?: BooleanType.ALWAYS, - useTrackedBroadcasts = - preferences[UI_USE_TRACKED_BROADCASTS]?.let { BooleanType.valueOf(it) } - ?: if (featureSet == FeatureSetType.COMPLETE) BooleanType.ALWAYS else BooleanType.NEVER, - automaticallyCreateDrafts = preferences[UI_AUTOMATICALLY_CREATE_DRAFTS]?.let { BooleanType.valueOf(it) } ?: BooleanType.ALWAYS, - showHomeNewThreadsTab = preferences[UI_SHOW_HOME_NEW_THREADS_TAB] ?: true, - showHomeConversationsTab = preferences[UI_SHOW_HOME_CONVERSATIONS_TAB] ?: true, - showHomeEverythingTab = preferences[UI_SHOW_HOME_EVERYTHING_TAB] ?: false, - showProfileBadges = preferences[UI_SHOW_PROFILE_BADGES] ?: true, - showProfileAppRecommendations = preferences[UI_SHOW_PROFILE_APP_RECOMMENDATIONS] ?: true, - showProfileZapReceivedFeed = preferences[UI_SHOW_PROFILE_ZAP_RECEIVED_FEED] ?: true, - showProfileFollowersFeed = preferences[UI_SHOW_PROFILE_FOLLOWERS_FEED] ?: true, - dontShowOnchainPublicWarning = preferences[UI_DONT_SHOW_ONCHAIN_PUBLIC_WARNING] ?: false, - suggestWorkoutsFromHealthConnect = - preferences[UI_SUGGEST_WORKOUTS_FROM_HEALTH_CONNECT]?.let { BooleanType.valueOf(it) } ?: BooleanType.ALWAYS, - accentColor = preferences[UI_ACCENT_COLOR]?.let { AccentColorType.valueOf(it) } ?: AccentColorType.PURPLE, - fontFamily = preferences[UI_FONT_FAMILY]?.let { FontFamilyType.valueOf(it) } ?: FontFamilyType.SYSTEM, - fontSize = preferences[UI_FONT_SIZE]?.let { FontSizeType.valueOf(it) } ?: FontSizeType.NORMAL, - composeSignature = preferences[UI_COMPOSE_SIGNATURE] ?: "", - showOnchainWallet = preferences[UI_SHOW_ONCHAIN_WALLET] ?: true, - showPayToZapChip = preferences[UI_SHOW_PAYTO_ZAP_CHIP] ?: true, - ) - } catch (e: Exception) { - if (e is CancellationException) throw e - // Log any errors that occur while reading the DataStore. - Log.e("SharedPreferences") { "Error reading DataStore preferences: ${e.message}" } - - try { - val oldVersion = LocalPreferences.loadSharedSettings() - if (oldVersion != null) { - save(oldVersion, context) - } - oldVersion - } catch (e: Exception) { - if (e is CancellationException) throw e - null - } - } - - /** - * Writes every UI setting into [preferences]. - * - * Shared by [save] and by the one-shot copy out of the old - * `shared_settings` blob, so the two cannot come to disagree about - * which keys a complete set of UI settings has. - */ - internal fun MutablePreferences.write(sharedSettings: UiSettings) { - val preferences = this - preferences[UI_THEME] = sharedSettings.theme.name - preferences[UI_LANGUAGE] = sharedSettings.preferredLanguage ?: "" - preferences[UI_SHOW_IMAGES] = sharedSettings.automaticallyShowImages.name - preferences[UI_START_PLAYBACK] = sharedSettings.automaticallyStartPlayback.name - preferences[UI_PLAY_VIDEOS] = sharedSettings.automaticallyPlayVideos.name - preferences[UI_SHOW_URL_PREVIEW] = sharedSettings.automaticallyShowUrlPreview.name - preferences[UI_HIDE_NAVIGATION_BARS] = sharedSettings.automaticallyHideNavigationBars.name - preferences[UI_SHOW_PROFILE_PICTURES] = sharedSettings.automaticallyShowProfilePictures.name - preferences[UI_DONT_SHOW_PUSH_NOTIFICATION_SELECTOR] = sharedSettings.dontShowPushNotificationSelector - preferences[UI_DONT_ASK_FOR_NOTIFICATION_PERMISSIONS] = sharedSettings.dontAskForNotificationPermissions - preferences[UI_FEATURE_SET] = sharedSettings.featureSet.name - preferences[UI_GALLERY_SET] = sharedSettings.gallerySet.name - preferences[UI_PROPOSE_AI_IMPROVEMENTS] = sharedSettings.automaticallyProposeAiImprovements.name - preferences[UI_USE_TRACKED_BROADCASTS] = sharedSettings.useTrackedBroadcasts.name - preferences[UI_AUTOMATICALLY_CREATE_DRAFTS] = sharedSettings.automaticallyCreateDrafts.name - preferences[UI_SHOW_HOME_NEW_THREADS_TAB] = sharedSettings.showHomeNewThreadsTab - preferences[UI_SHOW_HOME_CONVERSATIONS_TAB] = sharedSettings.showHomeConversationsTab - preferences[UI_SHOW_HOME_EVERYTHING_TAB] = sharedSettings.showHomeEverythingTab - preferences[UI_SHOW_PROFILE_BADGES] = sharedSettings.showProfileBadges - preferences[UI_SHOW_PROFILE_APP_RECOMMENDATIONS] = sharedSettings.showProfileAppRecommendations - preferences[UI_SHOW_PROFILE_ZAP_RECEIVED_FEED] = sharedSettings.showProfileZapReceivedFeed - preferences[UI_SHOW_PROFILE_FOLLOWERS_FEED] = sharedSettings.showProfileFollowersFeed - preferences[UI_DONT_SHOW_ONCHAIN_PUBLIC_WARNING] = sharedSettings.dontShowOnchainPublicWarning - preferences[UI_SUGGEST_WORKOUTS_FROM_HEALTH_CONNECT] = sharedSettings.suggestWorkoutsFromHealthConnect.name - preferences[UI_ACCENT_COLOR] = sharedSettings.accentColor.name - preferences[UI_FONT_FAMILY] = sharedSettings.fontFamily.name - preferences[UI_FONT_SIZE] = sharedSettings.fontSize.name - preferences[UI_COMPOSE_SIGNATURE] = sharedSettings.composeSignature - preferences[UI_SHOW_ONCHAIN_WALLET] = sharedSettings.showOnchainWallet - preferences[UI_SHOW_PAYTO_ZAP_CHIP] = sharedSettings.showPayToZapChip - } - - /** - * The one-shot copy out of the single `shared_settings` JSON blob these - * settings used to be kept as, in the global encrypted file. - * - * Guarded, and it has to be. Unlike the per-account migrations, this - * store has been the real home of these settings for a while, so most - * installs already have a populated one — and copying an old blob over - * it would undo every UI change the user has made since. [UI_THEME] is - * the test: [save] writes every key unconditionally and is the only - * writer, so its absence means this store has never been saved, which - * is exactly the install whose settings are still only in the legacy - * file. - */ - internal fun migrations(): List> = - listOf( - CopyOnceMigration("migrated.sharedSettings") { out -> - if (out[UI_THEME] == null) { - withContext(Dispatchers.IO) { - LocalPreferences.loadSharedSettings()?.let { out.write(it) } - } - } - }, - ) - - suspend fun save( - sharedSettings: UiSettings, - context: Context, - ) { - try { - context.sharedPreferencesDataStore.edit { preferences -> preferences.write(sharedSettings) } - } catch (e: Exception) { - if (e is CancellationException) throw e - // Log any errors that occur while reading the DataStore. - Log.e("SharedPreferences") { "Error saving DataStore preferences: ${e.message}" } - } - } + suspend fun uiPreferences(context: Context): UiSettings? = UiSettingsStore(context.sharedPreferencesDataStore) { LocalPreferences.loadSharedSettings() }.load() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLauncher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLauncher.kt index 382242d133..e4619c6624 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLauncher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLauncher.kt @@ -25,10 +25,10 @@ import android.content.Intent import android.content.res.Configuration import android.os.Bundle import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.commons.model.ThemeType import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.napplet.NappletArtifactPolicy import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity -import com.vitorpamplona.amethyst.model.ThemeType import com.vitorpamplona.amethyst.napplethost.HostProfile import com.vitorpamplona.amethyst.napplethost.NappletHostActivity import com.vitorpamplona.amethyst.napplethost.NappletHostContract diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/DisplayBroadcastProgress.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/DisplayBroadcastProgress.kt index 44773b05a0..1c9c1f1772 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/DisplayBroadcastProgress.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/DisplayBroadcastProgress.kt @@ -38,9 +38,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.commons.model.BooleanType import com.vitorpamplona.amethyst.commons.service.broadcast.BroadcastEvent import com.vitorpamplona.amethyst.commons.service.pow.PoWJobState -import com.vitorpamplona.amethyst.model.BooleanType import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import kotlinx.collections.immutable.ImmutableList diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteHeaderMarkersPreview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteHeaderMarkersPreview.kt index fb12ae4b2d..00689c692d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteHeaderMarkersPreview.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteHeaderMarkersPreview.kt @@ -29,12 +29,12 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.model.FeatureSetType import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.ui.components.GenericLoadable import com.vitorpamplona.amethyst.commons.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn -import com.vitorpamplona.amethyst.model.FeatureSetType import com.vitorpamplona.amethyst.service.location.CachedReversedGeoLocations import com.vitorpamplona.amethyst.ui.note.FirstUserInfoRow import com.vitorpamplona.amethyst.ui.note.types.EditState diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UiSettingsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UiSettingsState.kt index ea6ed3fce7..a68e877ea5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UiSettingsState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UiSettingsState.kt @@ -21,11 +21,11 @@ package com.vitorpamplona.amethyst.ui.screen import androidx.compose.runtime.Stable -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.UiSettingsFlow +import com.vitorpamplona.amethyst.commons.model.BooleanType +import com.vitorpamplona.amethyst.commons.model.ConnectivityType +import com.vitorpamplona.amethyst.commons.model.FeatureSetType +import com.vitorpamplona.amethyst.commons.model.ProfileGalleryType +import com.vitorpamplona.amethyst.commons.model.UiSettingsFlow import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow 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 57381f76b9..23ac016ee0 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 @@ -46,6 +46,7 @@ import com.vitorpamplona.amethyst.commons.model.AddressableNote import com.vitorpamplona.amethyst.commons.model.Dao import com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.model.UiSettingsFlow import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.amethyst.commons.model.backups.ReplaceableBackupConflict import com.vitorpamplona.amethyst.commons.model.cache.LocalCache @@ -111,7 +112,6 @@ import com.vitorpamplona.amethyst.logTime import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.model.LatestKeyPackageOwner -import com.vitorpamplona.amethyst.model.UiSettingsFlow import com.vitorpamplona.amethyst.model.UrlCachedPreviewer import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilder import com.vitorpamplona.amethyst.service.ClinkDebitPayer diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabFactory.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabFactory.kt index fab2842f38..8422c85477 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabFactory.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabFactory.kt @@ -27,9 +27,9 @@ import android.os.Bundle import androidx.annotation.RequiresApi import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp +import com.vitorpamplona.amethyst.commons.model.ThemeType import com.vitorpamplona.amethyst.commons.tor.TorType import com.vitorpamplona.amethyst.favorites.FavoriteAppLauncher -import com.vitorpamplona.amethyst.model.ThemeType import com.vitorpamplona.amethyst.napplet.WebAppNetworkRegistry import com.vitorpamplona.amethyst.napplethost.NappletHostContract import com.vitorpamplona.amethyst.ui.screen.loggedIn.browser.EmbeddedWebAppController diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabThemeWatcher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabThemeWatcher.kt index 9c0ddcf11a..d3e8c770f6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabThemeWatcher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabThemeWatcher.kt @@ -30,7 +30,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.Amethyst -import com.vitorpamplona.amethyst.model.ThemeType +import com.vitorpamplona.amethyst.commons.model.ThemeType /** * Keeps the warm embedded tabs in sync with the app's DARK/LIGHT theme. An embed WebView resolves its diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt index 65832e94fb..8c5bd12fec 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt @@ -81,6 +81,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.BooleanType import com.vitorpamplona.amethyst.commons.model.composer.AudienceSelection import com.vitorpamplona.amethyst.commons.model.navigation.Route import com.vitorpamplona.amethyst.commons.nip30CustomEmojis.ui.ShowEmojiSuggestionList @@ -134,7 +135,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.SuggestionListDefaultHeightPa import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText import com.vitorpamplona.amethyst.commons.ui.theme.replyModifier -import com.vitorpamplona.amethyst.model.BooleanType import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog import com.vitorpamplona.amethyst.ui.actions.mediaServers.FileServerSelectionRow import com.vitorpamplona.amethyst.ui.actions.uploads.MAX_VOICE_RECORD_SECONDS diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index 72bc69b916..08df10a49e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -35,6 +35,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.model.AddressableNote +import com.vitorpamplona.amethyst.commons.model.BooleanType import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.amethyst.commons.model.cache.LocalCache @@ -69,7 +70,6 @@ import com.vitorpamplona.amethyst.commons.ui.text.onUiThread import com.vitorpamplona.amethyst.commons.ui.text.replaceCurrentWord import com.vitorpamplona.amethyst.commons.ui.text.setTextAndPlaceCursorAtBeginning import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.model.BooleanType import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState import com.vitorpamplona.amethyst.service.ai.WritingAssistantFactory import com.vitorpamplona.amethyst.service.location.LocationState 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 f7227a9362..cd772a90de 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 @@ -32,6 +32,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.model.UiSettingsFlow import com.vitorpamplona.amethyst.commons.model.navigation.Route import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.notification_tab_everyone @@ -44,7 +45,6 @@ import com.vitorpamplona.amethyst.commons.ui.feeds.rememberForeverPagerState import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.TabRowHeight -import com.vitorpamplona.amethyst.model.UiSettingsFlow import com.vitorpamplona.amethyst.ui.components.SelectNotificationProvider import com.vitorpamplona.amethyst.ui.feeds.WatchScrollToTop import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt index 4f580033c9..e3010ffd5c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt @@ -73,6 +73,14 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.AccentColorType +import com.vitorpamplona.amethyst.commons.model.BooleanType +import com.vitorpamplona.amethyst.commons.model.ConnectivityType +import com.vitorpamplona.amethyst.commons.model.FeatureSetType +import com.vitorpamplona.amethyst.commons.model.FontFamilyType +import com.vitorpamplona.amethyst.commons.model.FontSizeType +import com.vitorpamplona.amethyst.commons.model.ThemeType +import com.vitorpamplona.amethyst.commons.model.UiSettingsFlow import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.accent_color import com.vitorpamplona.amethyst.commons.resources.accent_color_description @@ -118,18 +126,11 @@ import com.vitorpamplona.amethyst.commons.ui.components.TitleExplainer import com.vitorpamplona.amethyst.commons.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.commons.ui.settings.resourceId import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonRow import com.vitorpamplona.amethyst.commons.ui.theme.contentColorOnAccent import com.vitorpamplona.amethyst.commons.ui.theme.isLight -import com.vitorpamplona.amethyst.model.AccentColorType -import com.vitorpamplona.amethyst.model.BooleanType -import com.vitorpamplona.amethyst.model.ConnectivityType -import com.vitorpamplona.amethyst.model.FeatureSetType -import com.vitorpamplona.amethyst.model.FontFamilyType -import com.vitorpamplona.amethyst.model.FontSizeType -import com.vitorpamplona.amethyst.model.ThemeType -import com.vitorpamplona.amethyst.model.UiSettingsFlow import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.previewColor import com.vitorpamplona.amethyst.ui.theme.toFontFamily diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ComposeSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ComposeSettingsScreen.kt index 933b81c1ee..3f0691f2b7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ComposeSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ComposeSettingsScreen.kt @@ -45,6 +45,8 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.BooleanType +import com.vitorpamplona.amethyst.commons.model.UiSettingsFlow import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.add_client_tag_explainer import com.vitorpamplona.amethyst.commons.resources.add_client_tag_title @@ -96,8 +98,6 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackBu import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn import com.vitorpamplona.amethyst.model.AccountPoWPreferences -import com.vitorpamplona.amethyst.model.BooleanType -import com.vitorpamplona.amethyst.model.UiSettingsFlow import com.vitorpamplona.amethyst.service.ai.WritingAssistantFactory import com.vitorpamplona.amethyst.service.pow.deviceHashesPerSecond import com.vitorpamplona.amethyst.service.pow.loadApproxDuration diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/HomeTabsSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/HomeTabsSettingsScreen.kt index 838ca8b799..ba83376d7a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/HomeTabsSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/HomeTabsSettingsScreen.kt @@ -36,6 +36,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.HomeFeedType +import com.vitorpamplona.amethyst.commons.model.UiSettingsFlow import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.conversations import com.vitorpamplona.amethyst.commons.resources.home_content_type_articles @@ -72,7 +73,6 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonRow -import com.vitorpamplona.amethyst.model.UiSettingsFlow import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import org.jetbrains.compose.resources.StringResource diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ProfileUiSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ProfileUiSettingsScreen.kt index e533d60817..3fa6edba83 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ProfileUiSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ProfileUiSettingsScreen.kt @@ -34,6 +34,8 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.ProfileGalleryType +import com.vitorpamplona.amethyst.commons.model.UiSettingsFlow import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.gallery_style import com.vitorpamplona.amethyst.commons.resources.gallery_style_description @@ -48,10 +50,9 @@ import com.vitorpamplona.amethyst.commons.resources.settings_section_profile_sec import com.vitorpamplona.amethyst.commons.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.commons.ui.settings.resourceId import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonRow -import com.vitorpamplona.amethyst.model.ProfileGalleryType -import com.vitorpamplona.amethyst.model.UiSettingsFlow import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel 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 0e38c36ff5..7a5d836238 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 @@ -38,7 +38,9 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.WarningType import com.vitorpamplona.amethyst.commons.model.navigation.Route +import com.vitorpamplona.amethyst.commons.model.parseWarningType import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.blocked_users import com.vitorpamplona.amethyst.commons.resources.filter_spam_from_strangers_explainer @@ -63,10 +65,9 @@ import com.vitorpamplona.amethyst.commons.resources.warn_when_posts_have_reports import com.vitorpamplona.amethyst.commons.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.commons.ui.settings.resourceId import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn -import com.vitorpamplona.amethyst.model.WarningType -import com.vitorpamplona.amethyst.model.parseWarningType import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/suggestion/DetectedWorkoutCarousel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/suggestion/DetectedWorkoutCarousel.kt index fb28dfd9fd..ffc23b3d25 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/suggestion/DetectedWorkoutCarousel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/suggestion/DetectedWorkoutCarousel.kt @@ -60,6 +60,7 @@ import com.vitorpamplona.amethyst.commons.fitness.TrainingLog import com.vitorpamplona.amethyst.commons.fitness.WorkoutOrigin import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.BooleanType import com.vitorpamplona.amethyst.commons.model.navigation.Route import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.workout_from_health_connect @@ -71,7 +72,6 @@ import com.vitorpamplona.amethyst.commons.resources.workout_suggestion_connect_t import com.vitorpamplona.amethyst.commons.resources.workout_suggestion_distance_km import com.vitorpamplona.amethyst.commons.ui.pluralStringRes import com.vitorpamplona.amethyst.commons.ui.stringRes -import com.vitorpamplona.amethyst.model.BooleanType import com.vitorpamplona.amethyst.service.workouts.health.HealthConnectManager import com.vitorpamplona.amethyst.service.workouts.health.publishedWorkoutsOf import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel 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 13a092c162..7db63b6a55 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 @@ -44,6 +44,10 @@ import com.patrykandpatrick.vico.compose.common.VicoTheme import com.patrykandpatrick.vico.compose.common.VicoTheme.CandlestickCartesianLayerColors import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.icons.symbols.ProvideAppIcons +import com.vitorpamplona.amethyst.commons.model.AccentColorType +import com.vitorpamplona.amethyst.commons.model.FontFamilyType +import com.vitorpamplona.amethyst.commons.model.FontSizeType +import com.vitorpamplona.amethyst.commons.model.ThemeType import com.vitorpamplona.amethyst.commons.ui.theme.AccentBlueDark import com.vitorpamplona.amethyst.commons.ui.theme.AccentBlueLight import com.vitorpamplona.amethyst.commons.ui.theme.AccentGreenDark @@ -64,10 +68,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.amethystLightColorScheme import com.vitorpamplona.amethyst.commons.ui.theme.isLight import com.vitorpamplona.amethyst.commons.ui.theme.transparentBackground import com.vitorpamplona.amethyst.commons.ui.theme.withFontFamily -import com.vitorpamplona.amethyst.model.AccentColorType -import com.vitorpamplona.amethyst.model.FontFamilyType -import com.vitorpamplona.amethyst.model.FontSizeType -import com.vitorpamplona.amethyst.model.ThemeType // The accent color (primary/secondary/tertiary) is user-selectable in Settings -> Accent Color. // Purple keeps the original Amethyst look (purple primary + teal secondary). Every other accent diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/SelectNotificationProvider.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/SelectNotificationProvider.kt index dc32830cc4..0915fcb2d2 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/SelectNotificationProvider.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/SelectNotificationProvider.kt @@ -29,7 +29,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberPermissionState -import com.vitorpamplona.amethyst.model.UiSettingsFlow +import com.vitorpamplona.amethyst.commons.model.UiSettingsFlow @OptIn(ExperimentalPermissionsApi::class) @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettings.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/UiSettings.kt similarity index 66% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettings.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/UiSettings.kt index 159afa0514..eb8ae128e1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettings.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/UiSettings.kt @@ -18,40 +18,10 @@ * 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.model +package com.vitorpamplona.amethyst.commons.model import androidx.compose.runtime.Stable -import com.vitorpamplona.amethyst.commons.resources.Res -import com.vitorpamplona.amethyst.commons.resources.accent_color_blue -import com.vitorpamplona.amethyst.commons.resources.accent_color_green -import com.vitorpamplona.amethyst.commons.resources.accent_color_orange -import com.vitorpamplona.amethyst.commons.resources.accent_color_pink -import com.vitorpamplona.amethyst.commons.resources.accent_color_purple -import com.vitorpamplona.amethyst.commons.resources.accent_color_red -import com.vitorpamplona.amethyst.commons.resources.connectivity_type_always -import com.vitorpamplona.amethyst.commons.resources.connectivity_type_never -import com.vitorpamplona.amethyst.commons.resources.connectivity_type_unmetered_wifi_only -import com.vitorpamplona.amethyst.commons.resources.content_warning_hide_all_sensitive_content_option -import com.vitorpamplona.amethyst.commons.resources.content_warning_see_warnings_option -import com.vitorpamplona.amethyst.commons.resources.content_warning_show_all_sensitive_content_option -import com.vitorpamplona.amethyst.commons.resources.dark -import com.vitorpamplona.amethyst.commons.resources.font_family_monospace -import com.vitorpamplona.amethyst.commons.resources.font_family_sans_serif -import com.vitorpamplona.amethyst.commons.resources.font_family_serif -import com.vitorpamplona.amethyst.commons.resources.font_family_system -import com.vitorpamplona.amethyst.commons.resources.font_size_huge -import com.vitorpamplona.amethyst.commons.resources.font_size_large -import com.vitorpamplona.amethyst.commons.resources.font_size_normal -import com.vitorpamplona.amethyst.commons.resources.font_size_small -import com.vitorpamplona.amethyst.commons.resources.gallery_type_classic -import com.vitorpamplona.amethyst.commons.resources.gallery_type_modern -import com.vitorpamplona.amethyst.commons.resources.light -import com.vitorpamplona.amethyst.commons.resources.system -import com.vitorpamplona.amethyst.commons.resources.ui_feature_set_type_complete -import com.vitorpamplona.amethyst.commons.resources.ui_feature_set_type_performance -import com.vitorpamplona.amethyst.commons.resources.ui_feature_set_type_simplified import kotlinx.serialization.Serializable -import org.jetbrains.compose.resources.StringResource @Stable @Serializable @@ -98,11 +68,10 @@ data class UiSettings( enum class ThemeType( val screenCode: Int, - val resourceId: StringResource, ) { - SYSTEM(0, Res.string.system), - LIGHT(1, Res.string.light), - DARK(2, Res.string.dark), + SYSTEM(0), + LIGHT(1), + DARK(2), } fun parseThemeType(code: Int?): ThemeType = @@ -115,14 +84,13 @@ fun parseThemeType(code: Int?): ThemeType = enum class AccentColorType( val screenCode: Int, - val resourceId: StringResource, ) { - PURPLE(0, Res.string.accent_color_purple), - BLUE(1, Res.string.accent_color_blue), - GREEN(2, Res.string.accent_color_green), - ORANGE(3, Res.string.accent_color_orange), - RED(4, Res.string.accent_color_red), - PINK(5, Res.string.accent_color_pink), + PURPLE(0), + BLUE(1), + GREEN(2), + ORANGE(3), + RED(4), + PINK(5), } fun parseAccentColorType(screenCode: Int): AccentColorType = @@ -138,12 +106,11 @@ fun parseAccentColorType(screenCode: Int): AccentColorType = enum class FontFamilyType( val screenCode: Int, - val resourceId: StringResource, ) { - SYSTEM(0, Res.string.font_family_system), - SANS_SERIF(1, Res.string.font_family_sans_serif), - SERIF(2, Res.string.font_family_serif), - MONOSPACE(3, Res.string.font_family_monospace), + SYSTEM(0), + SANS_SERIF(1), + SERIF(2), + MONOSPACE(3), } fun parseFontFamilyType(screenCode: Int): FontFamilyType = @@ -158,12 +125,11 @@ fun parseFontFamilyType(screenCode: Int): FontFamilyType = enum class FontSizeType( val scale: Float, val screenCode: Int, - val resourceId: StringResource, ) { - SMALL(0.85f, 0, Res.string.font_size_small), - NORMAL(1.0f, 1, Res.string.font_size_normal), - LARGE(1.15f, 2, Res.string.font_size_large), - HUGE(1.3f, 3, Res.string.font_size_huge), + SMALL(0.85f, 0), + NORMAL(1.0f, 1), + LARGE(1.15f, 2), + HUGE(1.3f, 3), } fun parseFontSizeType(screenCode: Int): FontSizeType = @@ -178,28 +144,25 @@ fun parseFontSizeType(screenCode: Int): FontSizeType = enum class ConnectivityType( val prefCode: Boolean?, val screenCode: Int, - val resourceId: StringResource, ) { - ALWAYS(null, 0, Res.string.connectivity_type_always), - WIFI_ONLY(true, 1, Res.string.connectivity_type_unmetered_wifi_only), - NEVER(false, 2, Res.string.connectivity_type_never), + ALWAYS(null, 0), + WIFI_ONLY(true, 1), + NEVER(false, 2), } enum class FeatureSetType( val screenCode: Int, - val resourceId: StringResource, ) { - COMPLETE(0, Res.string.ui_feature_set_type_complete), - SIMPLIFIED(1, Res.string.ui_feature_set_type_simplified), - PERFORMANCE(2, Res.string.ui_feature_set_type_performance), + COMPLETE(0), + SIMPLIFIED(1), + PERFORMANCE(2), } enum class ProfileGalleryType( val screenCode: Int, - val resourceId: StringResource, ) { - CLASSIC(0, Res.string.gallery_type_classic), - MODERN(1, Res.string.gallery_type_modern), + CLASSIC(0), + MODERN(1), } fun parseConnectivityType(code: Boolean?): ConnectivityType = @@ -236,10 +199,9 @@ fun parseGalleryType(screenCode: Int): ProfileGalleryType = enum class BooleanType( val prefCode: Boolean?, val screenCode: Int, - val reourceId: StringResource, ) { - ALWAYS(null, 0, Res.string.connectivity_type_always), - NEVER(false, 1, Res.string.connectivity_type_never), + ALWAYS(null, 0), + NEVER(false, 1), } fun parseBooleanType(code: Boolean?): BooleanType = @@ -259,11 +221,10 @@ fun parseBooleanType(screenCode: Int): BooleanType = enum class WarningType( val prefCode: Boolean?, val screenCode: Int, - val resourceId: StringResource, ) { - WARN(null, 0, Res.string.content_warning_see_warnings_option), - SHOW(true, 1, Res.string.content_warning_show_all_sensitive_content_option), - HIDE(false, 2, Res.string.content_warning_hide_all_sensitive_content_option), + WARN(null, 0), + SHOW(true, 1), + HIDE(false, 2), } fun parseWarningType(screenCode: Int): WarningType = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/UiSettingsFlow.kt similarity index 99% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/UiSettingsFlow.kt index d2528689c2..6a0870cfa8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/UiSettingsFlow.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.model +package com.vitorpamplona.amethyst.commons.model import androidx.compose.runtime.Stable import kotlinx.coroutines.flow.Flow diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/UiSettingsStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/UiSettingsStore.kt new file mode 100644 index 0000000000..c1606b0440 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/UiSettingsStore.kt @@ -0,0 +1,247 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.preferences + +import androidx.datastore.core.DataMigration +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.MutablePreferences +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import com.vitorpamplona.amethyst.commons.model.AccentColorType +import com.vitorpamplona.amethyst.commons.model.BooleanType +import com.vitorpamplona.amethyst.commons.model.ConnectivityType +import com.vitorpamplona.amethyst.commons.model.FeatureSetType +import com.vitorpamplona.amethyst.commons.model.FontFamilyType +import com.vitorpamplona.amethyst.commons.model.FontSizeType +import com.vitorpamplona.amethyst.commons.model.ProfileGalleryType +import com.vitorpamplona.amethyst.commons.model.ThemeType +import com.vitorpamplona.amethyst.commons.model.UiSettings +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext +import kotlin.coroutines.cancellation.CancellationException + +/** + * The app-wide UI settings — theme, language, what loads on cellular, which + * profile and home tabs are shown. + * + * Lives on the [AppPreferenceStores.SHARED_SETTINGS] file under the `ui.` key + * prefix, one key per setting rather than a single blob: a DataStore read is + * whole-file anyway, but individual keys mean a setting added later does not + * invalidate the ones already stored. + * + * Headless on purpose. Applying a theme or a locale is platform work — on + * Android it is `UiModeManager` and `AppCompatDelegate`, which have no desktop + * equivalent — so that half stays in each front end and only the persistence + * is shared. + * + * @param loadLegacy reads the single `shared_settings` JSON blob these settings + * used to live in. Injected because the legacy file is Android's, and this + * store is not. Front ends without one pass nothing. + */ +class UiSettingsStore( + private val store: DataStore, + private val loadLegacy: suspend () -> UiSettings? = { null }, +) { + /** + * Reads every setting, falling back to the legacy blob if the store itself + * cannot be read. + * + * Returns null when neither can be read, which the caller shows as "still + * loading" rather than as defaults — writing defaults over an unreadable + * store would make a transient failure permanent on the next save. + */ + suspend fun load(): UiSettings? = + try { + read(store.data.first()) + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("UiSettingsStore") { "Error reading DataStore preferences: ${e.message}" } + + try { + loadLegacy()?.also { save(it) } + } catch (e: Exception) { + if (e is CancellationException) throw e + null + } + } + + suspend fun save(settings: UiSettings) { + try { + store.edit { it.write(settings) } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("UiSettingsStore") { "Error saving DataStore preferences: ${e.message}" } + } + } + + companion object { + // loads faster when individualized + val UI_THEME = stringPreferencesKey("ui.theme") + val UI_LANGUAGE = stringPreferencesKey("ui.language") + val UI_SHOW_IMAGES = stringPreferencesKey("ui.show_images") + val UI_START_PLAYBACK = stringPreferencesKey("ui.start_playback") + val UI_PLAY_VIDEOS = stringPreferencesKey("ui.play_videos") + val UI_SHOW_URL_PREVIEW = stringPreferencesKey("ui.show_url_preview") + val UI_HIDE_NAVIGATION_BARS = stringPreferencesKey("ui.hide_navigation_bars") + val UI_SHOW_PROFILE_PICTURES = stringPreferencesKey("ui.show_profile_pictures") + val UI_DONT_SHOW_PUSH_NOTIFICATION_SELECTOR = booleanPreferencesKey("ui.dont_show_push_notification_selector") + val UI_DONT_ASK_FOR_NOTIFICATION_PERMISSIONS = booleanPreferencesKey("ui.dont_ask_for_notification_permissions") + val UI_FEATURE_SET = stringPreferencesKey("ui.feature_set") + val UI_GALLERY_SET = stringPreferencesKey("ui.gallery_set") + val UI_PROPOSE_AI_IMPROVEMENTS = stringPreferencesKey("ui.propose_ai_improvements") + val UI_USE_TRACKED_BROADCASTS = stringPreferencesKey("ui.use_tracked_broadcasts") + val UI_AUTOMATICALLY_CREATE_DRAFTS = stringPreferencesKey("ui.automatically_create_drafts") + val UI_SHOW_HOME_NEW_THREADS_TAB = booleanPreferencesKey("ui.show_home_new_threads_tab") + val UI_SHOW_HOME_CONVERSATIONS_TAB = booleanPreferencesKey("ui.show_home_conversations_tab") + val UI_SHOW_HOME_EVERYTHING_TAB = booleanPreferencesKey("ui.show_home_everything_tab") + val UI_SHOW_PROFILE_BADGES = booleanPreferencesKey("ui.show_profile_badges") + val UI_SHOW_PROFILE_APP_RECOMMENDATIONS = booleanPreferencesKey("ui.show_profile_app_recommendations") + val UI_SHOW_PROFILE_ZAP_RECEIVED_FEED = booleanPreferencesKey("ui.show_profile_zap_received_feed") + val UI_SHOW_PROFILE_FOLLOWERS_FEED = booleanPreferencesKey("ui.show_profile_followers_feed") + val UI_DONT_SHOW_ONCHAIN_PUBLIC_WARNING = booleanPreferencesKey("ui.dont_show_onchain_public_warning") + val UI_SUGGEST_WORKOUTS_FROM_HEALTH_CONNECT = stringPreferencesKey("ui.suggest_workouts_from_health_connect") + val UI_ACCENT_COLOR = stringPreferencesKey("ui.accent_color") + val UI_FONT_FAMILY = stringPreferencesKey("ui.font_family") + val UI_FONT_SIZE = stringPreferencesKey("ui.font_size") + val UI_COMPOSE_SIGNATURE = stringPreferencesKey("ui.compose_signature") + val UI_SHOW_ONCHAIN_WALLET = booleanPreferencesKey("ui.show_onchain_wallet") + val UI_SHOW_PAYTO_ZAP_CHIP = booleanPreferencesKey("ui.show_payto_zap_chip") + + /** + * Every setting's default matches what the old `getBoolean(key, default)` + * call returned for a missing key. Several of them are `true`, so reading + * a default of `false` here would silently turn features off for every + * install that never touched them. + */ + fun read(preferences: Preferences): UiSettings { + val featureSet = preferences[UI_FEATURE_SET]?.let { FeatureSetType.valueOf(it) } ?: FeatureSetType.SIMPLIFIED + + return UiSettings( + theme = preferences[UI_THEME]?.let { ThemeType.valueOf(it) } ?: ThemeType.SYSTEM, + preferredLanguage = preferences[UI_LANGUAGE]?.ifBlank { null }, + automaticallyShowImages = preferences[UI_SHOW_IMAGES]?.let { ConnectivityType.valueOf(it) } ?: ConnectivityType.ALWAYS, + automaticallyStartPlayback = preferences[UI_START_PLAYBACK]?.let { ConnectivityType.valueOf(it) } ?: ConnectivityType.ALWAYS, + automaticallyPlayVideos = preferences[UI_PLAY_VIDEOS]?.let { BooleanType.valueOf(it) } ?: BooleanType.ALWAYS, + automaticallyShowUrlPreview = preferences[UI_SHOW_URL_PREVIEW]?.let { ConnectivityType.valueOf(it) } ?: ConnectivityType.ALWAYS, + automaticallyHideNavigationBars = preferences[UI_HIDE_NAVIGATION_BARS]?.let { BooleanType.valueOf(it) } ?: BooleanType.ALWAYS, + automaticallyShowProfilePictures = preferences[UI_SHOW_PROFILE_PICTURES]?.let { ConnectivityType.valueOf(it) } ?: ConnectivityType.ALWAYS, + dontShowPushNotificationSelector = preferences[UI_DONT_SHOW_PUSH_NOTIFICATION_SELECTOR] ?: false, + dontAskForNotificationPermissions = preferences[UI_DONT_ASK_FOR_NOTIFICATION_PERMISSIONS] ?: false, + featureSet = featureSet, + gallerySet = preferences[UI_GALLERY_SET]?.let { ProfileGalleryType.valueOf(it) } ?: ProfileGalleryType.CLASSIC, + automaticallyProposeAiImprovements = preferences[UI_PROPOSE_AI_IMPROVEMENTS]?.let { BooleanType.valueOf(it) } ?: BooleanType.ALWAYS, + useTrackedBroadcasts = + preferences[UI_USE_TRACKED_BROADCASTS]?.let { BooleanType.valueOf(it) } + ?: if (featureSet == FeatureSetType.COMPLETE) BooleanType.ALWAYS else BooleanType.NEVER, + automaticallyCreateDrafts = preferences[UI_AUTOMATICALLY_CREATE_DRAFTS]?.let { BooleanType.valueOf(it) } ?: BooleanType.ALWAYS, + showHomeNewThreadsTab = preferences[UI_SHOW_HOME_NEW_THREADS_TAB] ?: true, + showHomeConversationsTab = preferences[UI_SHOW_HOME_CONVERSATIONS_TAB] ?: true, + showHomeEverythingTab = preferences[UI_SHOW_HOME_EVERYTHING_TAB] ?: false, + showProfileBadges = preferences[UI_SHOW_PROFILE_BADGES] ?: true, + showProfileAppRecommendations = preferences[UI_SHOW_PROFILE_APP_RECOMMENDATIONS] ?: true, + showProfileZapReceivedFeed = preferences[UI_SHOW_PROFILE_ZAP_RECEIVED_FEED] ?: true, + showProfileFollowersFeed = preferences[UI_SHOW_PROFILE_FOLLOWERS_FEED] ?: true, + dontShowOnchainPublicWarning = preferences[UI_DONT_SHOW_ONCHAIN_PUBLIC_WARNING] ?: false, + suggestWorkoutsFromHealthConnect = + preferences[UI_SUGGEST_WORKOUTS_FROM_HEALTH_CONNECT]?.let { BooleanType.valueOf(it) } ?: BooleanType.ALWAYS, + accentColor = preferences[UI_ACCENT_COLOR]?.let { AccentColorType.valueOf(it) } ?: AccentColorType.PURPLE, + fontFamily = preferences[UI_FONT_FAMILY]?.let { FontFamilyType.valueOf(it) } ?: FontFamilyType.SYSTEM, + fontSize = preferences[UI_FONT_SIZE]?.let { FontSizeType.valueOf(it) } ?: FontSizeType.NORMAL, + composeSignature = preferences[UI_COMPOSE_SIGNATURE] ?: "", + showOnchainWallet = preferences[UI_SHOW_ONCHAIN_WALLET] ?: true, + showPayToZapChip = preferences[UI_SHOW_PAYTO_ZAP_CHIP] ?: true, + ) + } + + /** + * Writes every UI setting into [this]. + * + * Shared by [save] and by the one-shot copy out of the old + * `shared_settings` blob, so the two cannot come to disagree about + * which keys a complete set of UI settings has. + */ + fun MutablePreferences.write(sharedSettings: UiSettings) { + val preferences = this + preferences[UI_THEME] = sharedSettings.theme.name + preferences[UI_LANGUAGE] = sharedSettings.preferredLanguage ?: "" + preferences[UI_SHOW_IMAGES] = sharedSettings.automaticallyShowImages.name + preferences[UI_START_PLAYBACK] = sharedSettings.automaticallyStartPlayback.name + preferences[UI_PLAY_VIDEOS] = sharedSettings.automaticallyPlayVideos.name + preferences[UI_SHOW_URL_PREVIEW] = sharedSettings.automaticallyShowUrlPreview.name + preferences[UI_HIDE_NAVIGATION_BARS] = sharedSettings.automaticallyHideNavigationBars.name + preferences[UI_SHOW_PROFILE_PICTURES] = sharedSettings.automaticallyShowProfilePictures.name + preferences[UI_DONT_SHOW_PUSH_NOTIFICATION_SELECTOR] = sharedSettings.dontShowPushNotificationSelector + preferences[UI_DONT_ASK_FOR_NOTIFICATION_PERMISSIONS] = sharedSettings.dontAskForNotificationPermissions + preferences[UI_FEATURE_SET] = sharedSettings.featureSet.name + preferences[UI_GALLERY_SET] = sharedSettings.gallerySet.name + preferences[UI_PROPOSE_AI_IMPROVEMENTS] = sharedSettings.automaticallyProposeAiImprovements.name + preferences[UI_USE_TRACKED_BROADCASTS] = sharedSettings.useTrackedBroadcasts.name + preferences[UI_AUTOMATICALLY_CREATE_DRAFTS] = sharedSettings.automaticallyCreateDrafts.name + preferences[UI_SHOW_HOME_NEW_THREADS_TAB] = sharedSettings.showHomeNewThreadsTab + preferences[UI_SHOW_HOME_CONVERSATIONS_TAB] = sharedSettings.showHomeConversationsTab + preferences[UI_SHOW_HOME_EVERYTHING_TAB] = sharedSettings.showHomeEverythingTab + preferences[UI_SHOW_PROFILE_BADGES] = sharedSettings.showProfileBadges + preferences[UI_SHOW_PROFILE_APP_RECOMMENDATIONS] = sharedSettings.showProfileAppRecommendations + preferences[UI_SHOW_PROFILE_ZAP_RECEIVED_FEED] = sharedSettings.showProfileZapReceivedFeed + preferences[UI_SHOW_PROFILE_FOLLOWERS_FEED] = sharedSettings.showProfileFollowersFeed + preferences[UI_DONT_SHOW_ONCHAIN_PUBLIC_WARNING] = sharedSettings.dontShowOnchainPublicWarning + preferences[UI_SUGGEST_WORKOUTS_FROM_HEALTH_CONNECT] = sharedSettings.suggestWorkoutsFromHealthConnect.name + preferences[UI_ACCENT_COLOR] = sharedSettings.accentColor.name + preferences[UI_FONT_FAMILY] = sharedSettings.fontFamily.name + preferences[UI_FONT_SIZE] = sharedSettings.fontSize.name + preferences[UI_COMPOSE_SIGNATURE] = sharedSettings.composeSignature + preferences[UI_SHOW_ONCHAIN_WALLET] = sharedSettings.showOnchainWallet + preferences[UI_SHOW_PAYTO_ZAP_CHIP] = sharedSettings.showPayToZapChip + } + + /** + * The one-shot copy out of the single `shared_settings` JSON blob these + * settings used to be kept as, in the global encrypted file. + * + * Guarded, and it has to be. Unlike the per-account migrations, this + * store has been the real home of these settings for a while, so most + * installs already have a populated one — and copying an old blob over + * it would undo every UI change the user has made since. [UI_THEME] is + * the test: [write] sets every key unconditionally and is the only + * writer, so its absence means this store has never been saved, which + * is exactly the install whose settings are still only in the legacy + * file. + * + * Must be handed to the store at construction, so every consumer of the + * shared file gets it no matter which one opens the file first. + */ + fun migrations(loadLegacy: suspend () -> UiSettings?): List> = + listOf( + CopyOnceMigration("migrated.sharedSettings") { out -> + if (out[UI_THEME] == null) { + withContext(Dispatchers.IO) { + loadLegacy()?.let { out.write(it) } + } + } + }, + ) + } +} diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/settings/UiSettingsLabels.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/settings/UiSettingsLabels.kt new file mode 100644 index 0000000000..247c0c8ebc --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/settings/UiSettingsLabels.kt @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.settings + +import com.vitorpamplona.amethyst.commons.model.AccentColorType +import com.vitorpamplona.amethyst.commons.model.FontSizeType +import com.vitorpamplona.amethyst.commons.model.ProfileGalleryType +import com.vitorpamplona.amethyst.commons.model.ThemeType +import com.vitorpamplona.amethyst.commons.model.WarningType +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.accent_color_blue +import com.vitorpamplona.amethyst.commons.resources.accent_color_green +import com.vitorpamplona.amethyst.commons.resources.accent_color_orange +import com.vitorpamplona.amethyst.commons.resources.accent_color_pink +import com.vitorpamplona.amethyst.commons.resources.accent_color_purple +import com.vitorpamplona.amethyst.commons.resources.accent_color_red +import com.vitorpamplona.amethyst.commons.resources.content_warning_hide_all_sensitive_content_option +import com.vitorpamplona.amethyst.commons.resources.content_warning_see_warnings_option +import com.vitorpamplona.amethyst.commons.resources.content_warning_show_all_sensitive_content_option +import com.vitorpamplona.amethyst.commons.resources.dark +import com.vitorpamplona.amethyst.commons.resources.font_size_huge +import com.vitorpamplona.amethyst.commons.resources.font_size_large +import com.vitorpamplona.amethyst.commons.resources.font_size_normal +import com.vitorpamplona.amethyst.commons.resources.font_size_small +import com.vitorpamplona.amethyst.commons.resources.gallery_type_classic +import com.vitorpamplona.amethyst.commons.resources.gallery_type_modern +import com.vitorpamplona.amethyst.commons.resources.light +import com.vitorpamplona.amethyst.commons.resources.system +import org.jetbrains.compose.resources.StringResource + +/** + * The display label for each UI-settings enum. + * + * These used to be a constructor argument on the enums themselves, which pinned + * `UiSettings` to `commonsUI` — a [StringResource] comes from the generated `Res` + * class, and `commons` cannot see it. The settings are plain data that the CLI and + * any headless front end may read, so the data moved to `commons/model` and the + * labels stayed here, following the same extension-property shape the Tor settings + * already use (`ui.tor.resourceId`). + * + * Each `when` is exhaustive over its enum, so a new constant is a compile error + * here rather than a missing label at runtime — the same guarantee the constructor + * argument gave. + * + * Only the enums something actually labels are here. `ConnectivityType`, + * `FeatureSetType` and `FontFamilyType` carried a `resourceId` that nothing read — + * their pickers are segmented rows and use the narrower `shortLabelRes` helpers in + * `AppSettingsScreen` instead — and `BooleanType`'s was misspelled `reourceId`, + * which is how it went unnoticed. They are dropped rather than carried over; the + * strings they pointed at are still used by those short labels. + */ +val ThemeType.resourceId: StringResource + get() = + when (this) { + ThemeType.SYSTEM -> Res.string.system + ThemeType.LIGHT -> Res.string.light + ThemeType.DARK -> Res.string.dark + } + +val AccentColorType.resourceId: StringResource + get() = + when (this) { + AccentColorType.PURPLE -> Res.string.accent_color_purple + AccentColorType.BLUE -> Res.string.accent_color_blue + AccentColorType.GREEN -> Res.string.accent_color_green + AccentColorType.ORANGE -> Res.string.accent_color_orange + AccentColorType.RED -> Res.string.accent_color_red + AccentColorType.PINK -> Res.string.accent_color_pink + } + +val FontSizeType.resourceId: StringResource + get() = + when (this) { + FontSizeType.SMALL -> Res.string.font_size_small + FontSizeType.NORMAL -> Res.string.font_size_normal + FontSizeType.LARGE -> Res.string.font_size_large + FontSizeType.HUGE -> Res.string.font_size_huge + } + +val ProfileGalleryType.resourceId: StringResource + get() = + when (this) { + ProfileGalleryType.CLASSIC -> Res.string.gallery_type_classic + ProfileGalleryType.MODERN -> Res.string.gallery_type_modern + } + +val WarningType.resourceId: StringResource + get() = + when (this) { + WarningType.WARN -> Res.string.content_warning_see_warnings_option + WarningType.SHOW -> Res.string.content_warning_show_all_sensitive_content_option + WarningType.HIDE -> Res.string.content_warning_hide_all_sensitive_content_option + } From 5c4a345dd0deaed99725122f43f51c9d828694d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 13:08:05 +0000 Subject: [PATCH 21/50] refactor: move the shared_settings file off the Context delegate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last Context-bound piece of the preference layer. Eight stores share `shared_settings` — UI, Tor, Namecoin, OTS, the three Buzz stores and the drawer collapse state — and all eight reached it through `Context.preferencesDataStore`, which exists only on Android and only from a Context. They now go through AppPreferenceStores, which takes the root directory as a parameter, so the file is addressable from a desktop or CLI front end too. The paths are unchanged: AppPreferenceStores.file reproduces `filesDir/datastore/.preferences_pb`, which is exactly what the delegate resolved to, so nothing migrates and a rollback finds its data where it left it. AppPreferenceStoresTest already pins that shape. Done in one commit on purpose. DataStore keeps a process-wide registry keyed by file path and throws on a second live store for the same path, so a half-migrated state is a crash rather than a degradation — there is no safe intermediate commit here. AppPreferenceStores gains a per-file `migrations` parameter, matching AccountPreferenceStores. The shared_settings copy has to hang off the file rather than off the UI store: DataStore runs a file's migrations once, when that file is first opened, and which of the eight stores opens it first is a race. TorSharedPreferences takes a DataStore instead of a Context. It keeps its TorSettingsFlow/TorPreferencesPort dependencies in amethyst.ui.tor, so the class itself cannot move to commons yet — this is only the file provider. UISharedPreferences.kt is renamed UiSharedPreferences.kt: with the delegate gone the file holds a single class, and ktlint's filename rule wants the match. Tests: UiSettingsStoreTest covers the defaults an untouched install reads (several are `true`, and reading `false` would turn those features off for everyone who never opened Settings), the round trip, the blank-language to null mapping, and the migration guard. The guard is driven against the DataMigration directly rather than through two DataStores over one file, because that second store is the crash above rather than a test. Two things the tests pinned down that were easy to get wrong: reading an empty store is not the same as constructing UiSettings(), because useTrackedBroadcasts follows the feature set and an unset feature set reads SIMPLIFIED, which means NEVER while the data class default says ALWAYS. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AXvKXakvup4inNFfAhhr4L --- .../com/vitorpamplona/amethyst/AppModules.kt | 57 ++++- .../model/preferences/TorSharedPreferences.kt | 27 +-- ...dPreferences.kt => UiSharedPreferences.kt} | 22 +- .../model/preferences/AppPreferenceStores.kt | 10 + .../model/preferences/UiSettingsStoreTest.kt | 207 ++++++++++++++++++ 5 files changed, 279 insertions(+), 44 deletions(-) rename amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/{UISharedPreferences.kt => UiSharedPreferences.kt} (89%) create mode 100644 commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/UiSettingsStoreTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 781ac1b91d..cef5a1c1d0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -33,12 +33,14 @@ import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.model.nip03Timestamp.BitcoinExplorerEndpoint import com.vitorpamplona.amethyst.commons.model.nip03Timestamp.IncomingOtsEventVerifier import com.vitorpamplona.amethyst.commons.model.nip03Timestamp.TorAwareOkHttpOtsResolverBuilder +import com.vitorpamplona.amethyst.commons.model.preferences.AppPreferenceStores import com.vitorpamplona.amethyst.commons.model.preferences.BuzzAttestationStore import com.vitorpamplona.amethyst.commons.model.preferences.BuzzChannelStarStore import com.vitorpamplona.amethyst.commons.model.preferences.BuzzWorkspaceStore import com.vitorpamplona.amethyst.commons.model.preferences.NamecoinSettingsStore import com.vitorpamplona.amethyst.commons.model.preferences.OtsSettingsStore import com.vitorpamplona.amethyst.commons.model.preferences.RelayGroupDeletionStore +import com.vitorpamplona.amethyst.commons.model.preferences.UiSettingsStore import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger import com.vitorpamplona.amethyst.commons.relayClient.BlockedRelayFilteringClient import com.vitorpamplona.amethyst.commons.relayClient.diagnostics.BootRelayDiagnostics @@ -71,7 +73,6 @@ import com.vitorpamplona.amethyst.model.nip11RelayInfo.Nip11CachedRetriever import com.vitorpamplona.amethyst.model.preferences.DrawerSectionCollapsePreferences import com.vitorpamplona.amethyst.model.preferences.TorSharedPreferences import com.vitorpamplona.amethyst.model.preferences.UiSharedPreferences -import com.vitorpamplona.amethyst.model.preferences.sharedPreferencesDataStore import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilder import com.vitorpamplona.amethyst.model.torState.AccountsTorStateConnector import com.vitorpamplona.amethyst.model.torState.TorRelayState @@ -198,6 +199,7 @@ import kotlinx.coroutines.flow.transform import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +import okio.Path.Companion.toOkioPath import java.io.File class AppModules( @@ -229,19 +231,50 @@ class AppModules( private val _trimLevelEvents = MutableSharedFlow(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) val trimLevelEvents = _trimLevelEvents.asSharedFlow() + /** + * The app-wide DataStore files — the ones that belong to the install rather + * than to an account. [AccountPreferenceStores] is the same idea keyed by + * npub. + * + * This replaces the `Context.preferencesDataStore` delegate these stores + * used to share. Same paths — `AppPreferenceStores.file` reproduces + * `filesDir/datastore/.preferences_pb` exactly — so nothing migrates + * and a rollback finds its data where it left it. What it buys is that the + * stores themselves live in `commonMain`, where a desktop or CLI front end + * can say where its data lives instead of needing a `Context`. + * + * The shared_settings migration is attached here, to the file, because + * eight stores share it and DataStore runs a file's migrations once, on + * whichever store opens it first. + */ + val appStores by lazy { + AppPreferenceStores( + rootFilesDir = { appContext.filesDir.toOkioPath() }, + migrations = { name -> + when (name) { + AppPreferenceStores.SHARED_SETTINGS -> UiSettingsStore.migrations { LocalPreferences.loadSharedSettings() } + else -> emptyList() + } + }, + ) + } + + /** The file UI, Tor, Namecoin, OTS and the Buzz stores all share. */ + val sharedSettingsStore get() = appStores.sharedSettings() + // Pre-load both preference DataStores in parallel on IO threads. // Both constructors use runBlocking internally, so starting them concurrently // reduces total blocking time from (torPrefs + uiPrefs) to ~max(torPrefs, uiPrefs). private val uiPrefsDeferred = applicationIOScope.async { - val prefs = UiSharedPreferences.uiPreferences(appContext) ?: UiSettings() - UiSharedPreferences(prefs, appContext, applicationIOScope) + val prefs = UiSharedPreferences.uiPreferences(sharedSettingsStore) ?: UiSettings() + UiSharedPreferences(prefs, sharedSettingsStore, appContext, applicationIOScope) } private val torPrefsDeferred = applicationIOScope.async { - val prefs = TorSharedPreferences.torPreferences(appContext) ?: TorSettings() - TorSharedPreferences(prefs, appContext, applicationIOScope) + val prefs = TorSharedPreferences.torPreferences(sharedSettingsStore) ?: TorSettings() + TorSharedPreferences(prefs, sharedSettingsStore, applicationIOScope) } // Blocking load of UI Preferences to avoid theme/language blinking @@ -259,7 +292,7 @@ class AppModules( // Namecoin ElectrumX server preferences (global, like Tor settings) val namecoinPrefs by lazy { Log.d("AppModules", "NamecoinSettingsStore Init") - NamecoinSettingsStore(appContext.sharedPreferencesDataStore, applicationIOScope) + NamecoinSettingsStore(sharedSettingsStore, applicationIOScope) } // OTS blockchain explorer preferences (global, like Tor settings) @@ -270,7 +303,7 @@ class AppModules( // in commonMain, which has no runBlocking to hide it behind. val otsPrefs by lazy { Log.d("AppModules", "OtsSettingsStore Init") - val store = appContext.sharedPreferencesDataStore + val store = sharedSettingsStore OtsSettingsStore(store, runBlocking { OtsSettingsStore.load(store) }) } @@ -315,12 +348,12 @@ class AppModules( // deleted channel stays hidden across a restart even if the host relay re-announces a stale // kind-44100 for it (device-global; a delete is authoritative and terminal for everyone). val relayGroupDeletionPrefs = - RelayGroupDeletionStore(appContext.sharedPreferencesDataStore, applicationIOScope) + RelayGroupDeletionStore(sharedSettingsStore, applicationIOScope) // Restore + persist which drawer section headings the user has folded away, so the side menu // opens the way they left it (device-global: a collapsed heading is a per-device view choice, // not an account setting worth syncing, unlike the hidden rows beside it in the drawer). - val drawerSectionCollapsePrefs = DrawerSectionCollapsePreferences(appContext.sharedPreferencesDataStore, applicationIOScope) + val drawerSectionCollapsePrefs = DrawerSectionCollapsePreferences(sharedSettingsStore, applicationIOScope) // Service that will run at all times to receive events from Pokey val pokeyReceiver = PokeyReceiver() @@ -976,11 +1009,11 @@ class AppModules( // start — Buzz membership is server-side) and the starred channels. Per account: the // joined set makes a relay first-party for NIP-42, and a star is personal. startBuzzPersistence = { account -> - BuzzWorkspaceStore(appContext.sharedPreferencesDataStore, account.scope, account.pubKey, account.buzzWorkspaces) - BuzzChannelStarStore(appContext.sharedPreferencesDataStore, account.scope, account.pubKey, account.buzzChannelStars) + BuzzWorkspaceStore(sharedSettingsStore, account.scope, account.pubKey, account.buzzWorkspaces) + BuzzChannelStarStore(sharedSettingsStore, account.scope, account.pubKey, account.buzzChannelStars) // Eager like the rest, so a held NIP-OA attestation is loaded before this account's // first Buzz-relay AUTH rather than after it. - BuzzAttestationStore(appContext.sharedPreferencesDataStore, account.scope, account.pubKey, account.buzzAttestation) + BuzzAttestationStore(sharedSettingsStore, account.scope, account.pubKey, account.buzzAttestation) }, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/TorSharedPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/TorSharedPreferences.kt index 900cc3e78f..5c4b054edd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/TorSharedPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/TorSharedPreferences.kt @@ -20,8 +20,9 @@ */ package com.vitorpamplona.amethyst.model.preferences -import android.content.Context import androidx.compose.runtime.Stable +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.intPreferencesKey @@ -48,7 +49,7 @@ import kotlin.coroutines.cancellation.CancellationException @Stable class TorSharedPreferences( prefs: TorSettings, - val context: Context, + val store: DataStore, val scope: CoroutineScope, ) : TorPreferencesPort { // Tor Preferences. Makes sure to wait for it to avoid connecting with random IPs @@ -63,7 +64,7 @@ class TorSharedPreferences( .debounce(1000) .distinctUntilChanged() .onEach { - save(it, context) + save(it, store) }.flowOn(Dispatchers.IO) .stateIn( scope, @@ -71,9 +72,9 @@ class TorSharedPreferences( value.toSettings(), ) - override suspend fun loadLastBypassApprovalMs(): Long = TorSharedPreferences.loadLastBypassApprovalMs(context) + override suspend fun loadLastBypassApprovalMs(): Long = TorSharedPreferences.loadLastBypassApprovalMs(store) - override suspend fun saveLastBypassApprovalMs(value: Long) = TorSharedPreferences.saveLastBypassApprovalMs(value, context) + override suspend fun saveLastBypassApprovalMs(value: Long) = TorSharedPreferences.saveLastBypassApprovalMs(value, store) companion object { // loads faster when individualized @@ -92,10 +93,10 @@ class TorSharedPreferences( val NIP05_VERIFICATIONS_VIA_TOR_KEY = booleanPreferencesKey("tor.nip05VerificationsViaTor") val MEDIA_UPLOADS_VIA_TOR_KEY = booleanPreferencesKey("tor.mediaUploadsViaTor") - suspend fun torPreferences(context: Context): TorSettings? = + suspend fun torPreferences(store: DataStore): TorSettings? = try { // Get the preference flow and take the first value. - val preferences = context.sharedPreferencesDataStore.data.first() + val preferences = store.data.first() TorSettings( torType = preferences[TOR_TYPE_KEY]?.let { TorType.valueOf(it) } ?: TorType.INTERNAL, externalSocksPort = preferences[EXTERNAL_SOCKS_PORT_KEY] ?: 9050, @@ -120,10 +121,10 @@ class TorSharedPreferences( suspend fun save( torSettings: TorSettings, - context: Context, + store: DataStore, ) { try { - context.sharedPreferencesDataStore.edit { preferences -> + store.edit { preferences -> preferences[TOR_TYPE_KEY] = torSettings.torType.name preferences[EXTERNAL_SOCKS_PORT_KEY] = torSettings.externalSocksPort preferences[ONION_RELAYS_VIA_TOR_KEY] = torSettings.onionRelaysViaTor @@ -145,9 +146,9 @@ class TorSharedPreferences( } } - suspend fun loadLastBypassApprovalMs(context: Context): Long = + suspend fun loadLastBypassApprovalMs(store: DataStore): Long = try { - context.sharedPreferencesDataStore.data.first()[LAST_BYPASS_APPROVAL_MS_KEY] ?: 0L + store.data.first()[LAST_BYPASS_APPROVAL_MS_KEY] ?: 0L } catch (e: Exception) { if (e is CancellationException) throw e Log.e("SharedPreferences") { "Error reading lastBypassApprovalMs: ${e.message}" } @@ -156,10 +157,10 @@ class TorSharedPreferences( suspend fun saveLastBypassApprovalMs( value: Long, - context: Context, + store: DataStore, ) { try { - context.sharedPreferencesDataStore.edit { prefs -> + store.edit { prefs -> prefs[LAST_BYPASS_APPROVAL_MS_KEY] = value } } catch (e: Exception) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UiSharedPreferences.kt similarity index 89% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UiSharedPreferences.kt index fe4df20db7..273445a3fb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UiSharedPreferences.kt @@ -29,8 +29,6 @@ import androidx.core.content.getSystemService import androidx.core.os.LocaleListCompat import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.preferencesDataStore -import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.commons.model.ThemeType import com.vitorpamplona.amethyst.commons.model.UiSettings import com.vitorpamplona.amethyst.commons.model.UiSettingsFlow @@ -48,21 +46,6 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.withContext import kotlin.coroutines.cancellation.CancellationException -/** - * The file UI, Tor, Namecoin, OTS and the Buzz stores all share, each under its - * own key prefix. - * - * The migration is attached here, at the file, rather than inside - * [UiSettingsStore]: whichever of those stores is constructed first is the one - * that opens the file, and DataStore runs a file's migrations once, on that - * first open. Hanging it off the UI store alone would make the copy depend on - * load order. - */ -val Context.sharedPreferencesDataStore: DataStore by preferencesDataStore( - name = "shared_settings", - produceMigrations = { UiSettingsStore.migrations { LocalPreferences.loadSharedSettings() } }, -) - /** * The Android half of the UI settings: the flows the app observes, and the two * platform side effects that a theme or language change has to perform. @@ -74,10 +57,11 @@ val Context.sharedPreferencesDataStore: DataStore by preferencesDat @Stable class UiSharedPreferences( prefs: UiSettings, + dataStore: DataStore, val context: Context, val scope: CoroutineScope, ) { - private val store = UiSettingsStore(context.sharedPreferencesDataStore) { LocalPreferences.loadSharedSettings() } + private val store = UiSettingsStore(dataStore) // UI Preferences. Makes sure to wait for it to avoid blinking themes and language preferences val value = UiSettingsFlow.build(prefs) @@ -197,6 +181,6 @@ class UiSharedPreferences( ) companion object { - suspend fun uiPreferences(context: Context): UiSettings? = UiSettingsStore(context.sharedPreferencesDataStore) { LocalPreferences.loadSharedSettings() }.load() + suspend fun uiPreferences(dataStore: DataStore): UiSettings? = UiSettingsStore(dataStore).load() } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AppPreferenceStores.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AppPreferenceStores.kt index c1f7bd5c5b..a49447d3cb 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AppPreferenceStores.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AppPreferenceStores.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.commons.model.preferences +import androidx.datastore.core.DataMigration import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.PreferenceDataStoreFactory import androidx.datastore.preferences.core.Preferences @@ -54,9 +55,17 @@ import okio.Path * holder opens the file it was already using, so nothing has to be migrated * and a rollback finds its data where it left it. Changing [file]'s shape * would silently orphan every existing install's settings. + * + * @param migrations the migrations to attach to a file, by name. Taken here + * rather than at [getDataStore] because DataStore runs a file's migrations + * once, when that file is first opened — and several stores share + * [SHARED_SETTINGS], so which one opens it is a race. Wiring the migrations + * to the file rather than to a caller is what makes the copy happen no + * matter who wins. */ class AppPreferenceStores( val rootFilesDir: () -> Path, + private val migrations: (String) -> List> = { emptyList() }, ) { companion object { /** @@ -96,6 +105,7 @@ class AppPreferenceStores( scope, PreferenceDataStoreFactory.createWithPath( scope = scope, + migrations = migrations(name), produceFile = { file(name) }, ), ) diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/UiSettingsStoreTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/UiSettingsStoreTest.kt new file mode 100644 index 0000000000..efefb1eecb --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/UiSettingsStoreTest.kt @@ -0,0 +1,207 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.preferences + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.mutablePreferencesOf +import com.vitorpamplona.amethyst.commons.model.AccentColorType +import com.vitorpamplona.amethyst.commons.model.BooleanType +import com.vitorpamplona.amethyst.commons.model.ConnectivityType +import com.vitorpamplona.amethyst.commons.model.FeatureSetType +import com.vitorpamplona.amethyst.commons.model.FontSizeType +import com.vitorpamplona.amethyst.commons.model.ThemeType +import com.vitorpamplona.amethyst.commons.model.UiSettings +import kotlinx.coroutines.test.runTest +import okio.Path.Companion.toOkioPath +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class UiSettingsStoreTest { + @get:Rule + val folder = TemporaryFolder() + + private fun stores(migrations: (String) -> List> = { emptyList() }) = AppPreferenceStores(rootFilesDir = { folder.root.toOkioPath() }, migrations = migrations) + + private fun rawStore(name: String): DataStore = + PreferenceDataStoreFactory.createWithPath( + produceFile = { folder.root.toOkioPath() / "$name.preferences_pb" }, + ) + + /** + * An account that never opened Settings has no stored value for anything, + * so every default here is what that install gets forever. Several of them + * are `true` — reading `false` would quietly turn those features off for + * everyone who never touched them. + */ + @Test + fun anEmptyStoreReadsTheSameDefaultsSharedPreferencesDid() = + runTest { + val settings = UiSettingsStore(rawStore("empty")).load()!! + + assertEquals(ThemeType.SYSTEM, settings.theme) + assertNull(settings.preferredLanguage) + assertEquals(ConnectivityType.ALWAYS, settings.automaticallyShowImages) + assertEquals(FeatureSetType.SIMPLIFIED, settings.featureSet) + assertEquals(AccentColorType.PURPLE, settings.accentColor) + assertEquals(FontSizeType.NORMAL, settings.fontSize) + assertEquals("", settings.composeSignature) + + // Not a constant, and not the data class default either: UiSettings() + // says ALWAYS, but a store with no feature set reads SIMPLIFIED, and + // SIMPLIFIED means NEVER. Reading an empty store is therefore not the + // same as constructing UiSettings() — pinned here because the two look + // interchangeable at a call site. + assertEquals(BooleanType.NEVER, settings.useTrackedBroadcasts) + + // the ones that default on + assertTrue(settings.showHomeNewThreadsTab) + assertTrue(settings.showHomeConversationsTab) + assertTrue(settings.showProfileBadges) + assertTrue(settings.showProfileAppRecommendations) + assertTrue(settings.showProfileZapReceivedFeed) + assertTrue(settings.showProfileFollowersFeed) + assertTrue(settings.showOnchainWallet) + assertTrue(settings.showPayToZapChip) + + // and the ones that default off + assertFalse(settings.showHomeEverythingTab) + assertFalse(settings.dontShowPushNotificationSelector) + assertFalse(settings.dontAskForNotificationPermissions) + assertFalse(settings.dontShowOnchainPublicWarning) + } + + /** + * `useTrackedBroadcasts` is the one default that is not a constant: it + * follows the feature set when it has never been set explicitly, and an + * explicit value wins over that. + */ + @Test + fun trackedBroadcastsFollowsTheFeatureSetUntilItIsSetExplicitly() = + runTest { + val complete = rawStore("complete") + complete.edit { it[UiSettingsStore.UI_FEATURE_SET] = FeatureSetType.COMPLETE.name } + assertEquals(BooleanType.ALWAYS, UiSettingsStore(complete).load()!!.useTrackedBroadcasts) + + val simplified = rawStore("simplified") + simplified.edit { it[UiSettingsStore.UI_FEATURE_SET] = FeatureSetType.SIMPLIFIED.name } + assertEquals(BooleanType.NEVER, UiSettingsStore(simplified).load()!!.useTrackedBroadcasts) + + val explicit = rawStore("explicit") + explicit.edit { + it[UiSettingsStore.UI_FEATURE_SET] = FeatureSetType.SIMPLIFIED.name + it[UiSettingsStore.UI_USE_TRACKED_BROADCASTS] = BooleanType.ALWAYS.name + } + assertEquals(BooleanType.ALWAYS, UiSettingsStore(explicit).load()!!.useTrackedBroadcasts) + } + + @Test + fun everySettingSurvivesARoundTrip() = + runTest { + val store = UiSettingsStore(rawStore("roundtrip")) + val settings = + UiSettings( + theme = ThemeType.DARK, + preferredLanguage = "pt-BR", + automaticallyShowImages = ConnectivityType.WIFI_ONLY, + automaticallyPlayVideos = BooleanType.NEVER, + featureSet = FeatureSetType.COMPLETE, + accentColor = AccentColorType.GREEN, + fontSize = FontSizeType.HUGE, + composeSignature = "— sent from Amethyst", + showHomeEverythingTab = true, + showProfileBadges = false, + showOnchainWallet = false, + showPayToZapChip = false, + ) + + store.save(settings) + + assertEquals(settings, store.load()) + } + + /** + * A blank language is stored as "" (the key is written unconditionally) and + * has to read back as null, or "no preference" turns into a locale tag the + * `LocaleListCompat` call cannot parse. + */ + @Test + fun aBlankLanguageReadsBackAsNoPreference() = + runTest { + val store = UiSettingsStore(rawStore("blank")) + store.save(UiSettings(preferredLanguage = null)) + + assertNull(store.load()!!.preferredLanguage) + } + + /** + * The copy out of the old blob must not run on an install that already has + * these settings here — that would undo every change made since this store + * took over. `ui.theme` is the test, because [UiSettingsStore.write] sets + * every key and is the only writer. + * + * Driven against the [androidx.datastore.core.DataMigration] directly rather + * than through two DataStores over one file: DataStore keeps a process-wide + * registry keyed by path and refuses the second one, so "reopen it and look" + * is a crash, not a test. + */ + @Test + fun theLegacyCopyRunsOnlyWhenTheStoreHasNeverBeenSaved() = + runTest { + val legacy = UiSettings(theme = ThemeType.LIGHT, composeSignature = "from the old blob") + val migration = UiSettingsStore.migrations { legacy }.single() + + // A store nothing has written: the copy lands. + val fresh = emptyPreferences() + assertTrue(migration.shouldMigrate(fresh)) + assertEquals(legacy, UiSettingsStore.read(migration.migrate(fresh))) + + // A store this app has already saved to: the copy must leave it alone. + val mine = UiSettings(theme = ThemeType.DARK, composeSignature = "mine") + val used = mutablePreferencesOf().apply { with(UiSettingsStore) { write(mine) } }.toPreferences() + + val after = migration.migrate(used) + assertEquals(mine, UiSettingsStore.read(after)) + + // ...and having run once, it never runs again. + assertFalse(migration.shouldMigrate(after)) + } + + /** No legacy reader (desktop, CLI) is not an error — it just means no copy. */ + @Test + fun aFrontEndWithoutALegacyFileGetsDefaults() = + runTest { + val subject = stores { UiSettingsStore.migrations { null } } + + assertEquals( + UiSettings(useTrackedBroadcasts = BooleanType.NEVER), + UiSettingsStore(subject.sharedSettings()).load(), + ) + } +} From 14aa3334066b7f3ec56b47600b7f75ff78636852 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 13:35:59 +0000 Subject: [PATCH 22/50] refactor: move the Tor settings store and flow into commons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same split as the UI settings, and most of it was already done: the Tor model (TorSettings, TorType, TorPresetType, the presets) has lived in commons/tor for a while, with the StringResource labels left behind in amethyst. What was still Android-only was everything around it. - commons/tor: TorSettingsFlow and TorPreferencesPort, both of which only ever needed kotlinx flows and the commons model. The port moves because the store that satisfies it is now shared, and a desktop or CLI manager needs the same slice. - commons/model/preferences/TorSettingsStore: the former TorSharedPreferences, renamed now that it is neither Android-specific nor SharedPreferences. It already took a DataStore rather than a Context after the shared_settings move, so nothing about its persistence changed. - commonsUI/ui/settings/TorSettingsLabels: TorType.resourceId, TorPresetType.resourceId and .explainerId, beside the UI settings labels. This empties amethyst/ui/tor/TorSettings.kt, which was by then a file of re-export comments and a `private const val RE_EXPORTS = 0` placeholder, so it goes. amethyst keeps the genuinely platform-bound half: TorService, ArtiNative, TorBackend, the manager and the dialogs. Tests: TorSettingsStoreTest pins the defaults an untouched install reads (onion, DM and new relays on; everything else off), the round trip, the key names, and that the bypass-approval timestamp survives a settings save — it is dialog bookkeeping rather than a user setting, so it has its own key and must not be cleared by one. It also pins that an unreadable torType makes the whole read return null rather than throw, which the caller turns into defaults. That is existing behaviour, not new, but it means one bad enum value costs every Tor setting rather than one, and it was worth writing down. amethyst's TorSettingsTest is deleted: it was 305 lines testing commons functions from the Android module, duplicating commons/tor/TorSettingsTest. The six cases it had that the commons copy lacked (preset monotonicity, whichPreset ignoring torType/port, equality for distinctUntilChanged) are ported across, so coverage goes up while 34 tests move off the Android job onto the multiplatform one. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AXvKXakvup4inNFfAhhr4L --- .../com/vitorpamplona/amethyst/AppModules.kt | 8 +- .../RoleBasedHttpClientBuilder.kt | 2 +- .../amethyst/model/torState/TorRelayState.kt | 2 +- .../ui/screen/loggedIn/AccountViewModel.kt | 2 +- .../loggedIn/privacy/PrivacyOptionsScreen.kt | 8 +- .../loggedIn/settings/OtsSettingsScreen.kt | 2 +- .../ui/screen/loggedOff/TorSettingsSetup.kt | 2 +- .../ui/screen/loggedOff/login/LoginScreen.kt | 2 +- .../screen/loggedOff/login/LoginViewModel.kt | 2 +- .../screen/loggedOff/signup/SignUpScreen.kt | 2 +- .../loggedOff/signup/SignUpViewModel.kt | 2 +- .../amethyst/ui/tor/TorManager.kt | 1 + .../amethyst/ui/tor/TorSettingsDialog.kt | 2 + .../amethyst/ui/tor/TorManagerTest.kt | 1 + .../amethyst/ui/tor/TorSettingsTest.kt | 305 ------------------ .../model/preferences/TorSettingsStore.kt | 21 +- .../commons}/tor/TorPreferencesPort.kt | 12 +- .../amethyst/commons}/tor/TorSettingsFlow.kt | 4 +- .../amethyst/commons/tor/TorSettingsTest.kt | 56 ++++ .../model/preferences/TorSettingsStoreTest.kt | 156 +++++++++ .../commons/ui/settings/TorSettingsLabels.kt | 18 +- 21 files changed, 262 insertions(+), 348 deletions(-) delete mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/ui/tor/TorSettingsTest.kt rename amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/TorSharedPreferences.kt => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/TorSettingsStore.kt (92%) rename {amethyst/src/main/java/com/vitorpamplona/amethyst/ui => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons}/tor/TorPreferencesPort.kt (75%) rename {amethyst/src/main/java/com/vitorpamplona/amethyst/ui => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons}/tor/TorSettingsFlow.kt (97%) create mode 100644 commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/TorSettingsStoreTest.kt rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorSettings.kt => commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/settings/TorSettingsLabels.kt (88%) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index cef5a1c1d0..6c3fde8ac3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -40,6 +40,7 @@ import com.vitorpamplona.amethyst.commons.model.preferences.BuzzWorkspaceStore import com.vitorpamplona.amethyst.commons.model.preferences.NamecoinSettingsStore import com.vitorpamplona.amethyst.commons.model.preferences.OtsSettingsStore import com.vitorpamplona.amethyst.commons.model.preferences.RelayGroupDeletionStore +import com.vitorpamplona.amethyst.commons.model.preferences.TorSettingsStore import com.vitorpamplona.amethyst.commons.model.preferences.UiSettingsStore import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger import com.vitorpamplona.amethyst.commons.relayClient.BlockedRelayFilteringClient @@ -71,7 +72,6 @@ import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState import com.vitorpamplona.amethyst.model.nip11RelayInfo.Nip11CachedRetriever import com.vitorpamplona.amethyst.model.preferences.DrawerSectionCollapsePreferences -import com.vitorpamplona.amethyst.model.preferences.TorSharedPreferences import com.vitorpamplona.amethyst.model.preferences.UiSharedPreferences import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilder import com.vitorpamplona.amethyst.model.torState.AccountsTorStateConnector @@ -273,8 +273,8 @@ class AppModules( private val torPrefsDeferred = applicationIOScope.async { - val prefs = TorSharedPreferences.torPreferences(sharedSettingsStore) ?: TorSettings() - TorSharedPreferences(prefs, sharedSettingsStore, applicationIOScope) + val prefs = TorSettingsStore.torPreferences(sharedSettingsStore) ?: TorSettings() + TorSettingsStore(prefs, sharedSettingsStore, applicationIOScope) } // Blocking load of UI Preferences to avoid theme/language blinking @@ -285,7 +285,7 @@ class AppModules( // Blocking load of Tor Settings to avoid connection leaks val torPrefs by lazy { - Log.d("AppModules", "TorSharedPreferences Init") + Log.d("AppModules", "TorSettingsStore Init") runBlocking { torPrefsDeferred.await() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/RoleBasedHttpClientBuilder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/RoleBasedHttpClientBuilder.kt index e2cdd4c805..278443ed5e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/RoleBasedHttpClientBuilder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/RoleBasedHttpClientBuilder.kt @@ -23,10 +23,10 @@ package com.vitorpamplona.amethyst.model.privacyOptions import com.vitorpamplona.amethyst.commons.service.http.DualHttpClientManager import com.vitorpamplona.amethyst.commons.service.http.IRoleBasedHttpClientBuilder import com.vitorpamplona.amethyst.commons.service.http.ProxiedSocketFactory +import com.vitorpamplona.amethyst.commons.tor.TorSettingsFlow import com.vitorpamplona.amethyst.commons.tor.TorType import com.vitorpamplona.amethyst.service.resourceusage.HttpUsageMeter import com.vitorpamplona.amethyst.service.resourceusage.UsageKeys -import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import okhttp3.OkHttpClient import java.net.InetSocketAddress diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/TorRelayState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/TorRelayState.kt index b2d4c32286..589fe2ebf7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/TorRelayState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/TorRelayState.kt @@ -25,8 +25,8 @@ import com.vitorpamplona.amethyst.commons.service.http.DualHttpClientManager import com.vitorpamplona.amethyst.commons.tor.RelayClassification import com.vitorpamplona.amethyst.commons.tor.TorRelayEvaluation import com.vitorpamplona.amethyst.commons.tor.TorRelaySettings +import com.vitorpamplona.amethyst.commons.tor.TorSettingsFlow import com.vitorpamplona.amethyst.commons.tor.TorType -import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers 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 23ac016ee0..34f8d28ea3 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 @@ -102,6 +102,7 @@ import com.vitorpamplona.amethyst.commons.service.broadcast.BroadcastTracker import com.vitorpamplona.amethyst.commons.service.http.EmptyRoleBasedHttpClientBuilder import com.vitorpamplona.amethyst.commons.service.http.IRoleBasedHttpClientBuilder import com.vitorpamplona.amethyst.commons.service.pow.PoWCategory +import com.vitorpamplona.amethyst.commons.tor.TorSettingsFlow import com.vitorpamplona.amethyst.commons.tor.TorType import com.vitorpamplona.amethyst.commons.ui.components.UrlPreviewState import com.vitorpamplona.amethyst.commons.ui.loadStringRes @@ -143,7 +144,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.CombinedZap import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.NOTIFICATION_LAST_READ_KEY import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync.EventSync import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.ReloadMintRequest -import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow import com.vitorpamplona.quartz.experimental.clink.debits.DebitResponse import com.vitorpamplona.quartz.experimental.clink.pointers.NDebit import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/privacy/PrivacyOptionsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/privacy/PrivacyOptionsScreen.kt index da8585ea2e..8b580ee3d3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/privacy/PrivacyOptionsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/privacy/PrivacyOptionsScreen.kt @@ -89,6 +89,7 @@ import com.vitorpamplona.amethyst.commons.resources.tor_use_videos_explainer import com.vitorpamplona.amethyst.commons.resources.use_internal_tor import com.vitorpamplona.amethyst.commons.resources.use_internal_tor_explainer import com.vitorpamplona.amethyst.commons.tor.TorPresetType +import com.vitorpamplona.amethyst.commons.tor.TorSettingsFlow import com.vitorpamplona.amethyst.commons.tor.TorType import com.vitorpamplona.amethyst.commons.tor.torDefaultPreset import com.vitorpamplona.amethyst.commons.tor.torFullyPrivate @@ -100,6 +101,8 @@ import com.vitorpamplona.amethyst.commons.ui.components.TitleExplainer import com.vitorpamplona.amethyst.commons.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.commons.ui.settings.explainerId +import com.vitorpamplona.amethyst.commons.ui.settings.resourceId import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonRow import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SegmentedChoiceTile @@ -109,9 +112,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsDivider import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsSection import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsSwitchTile import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow -import com.vitorpamplona.amethyst.ui.tor.explainerId -import com.vitorpamplona.amethyst.ui.tor.resourceId import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.MutableStateFlow import org.jetbrains.compose.resources.StringResource @@ -136,7 +136,7 @@ fun PrivacyOptionsScreen( } // Every control writes straight to [TorSettingsFlow] via `tryEmit`; a debounced collector in -// TorSharedPreferences persists the change automatically, so this screen has no Save/Cancel — the +// TorSettingsStore persists the change automatically, so this screen has no Save/Cancel — the // back arrow is the only chrome and the state is already saved by the time the user leaves. @Composable fun PrivacyOptionsContent( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/OtsSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/OtsSettingsScreen.kt index 97ee6ae701..3b452871b4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/OtsSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/OtsSettingsScreen.kt @@ -37,11 +37,11 @@ import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.model.preferences.OtsSettingsStore import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.ots_explorer_settings +import com.vitorpamplona.amethyst.commons.tor.TorSettingsFlow import com.vitorpamplona.amethyst.commons.tor.TorType import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.commons.ui.stringRes -import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) 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 a455d3881d..aab022ad56 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 @@ -34,10 +34,10 @@ import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.connect_via_tor1 import com.vitorpamplona.amethyst.commons.resources.connect_via_tor2 +import com.vitorpamplona.amethyst.commons.tor.TorSettingsFlow import com.vitorpamplona.amethyst.commons.ui.components.appendLink import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.ui.tor.ConnectTorDialog -import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow @Composable fun TorSettingsSetup( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginScreen.kt index 79f0338488..24ecb4cd05 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginScreen.kt @@ -79,6 +79,7 @@ import com.vitorpamplona.amethyst.commons.resources.hide_password import com.vitorpamplona.amethyst.commons.resources.ncryptsec_password import com.vitorpamplona.amethyst.commons.resources.show_password import com.vitorpamplona.amethyst.commons.resources.temporary_account +import com.vitorpamplona.amethyst.commons.tor.TorSettingsFlow import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.Size10dp import com.vitorpamplona.amethyst.commons.ui.theme.Size20dp @@ -89,7 +90,6 @@ import com.vitorpamplona.amethyst.ui.insets.imePaddingSafe import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager import com.vitorpamplona.amethyst.ui.screen.loggedOff.TorSettingsSetup import com.vitorpamplona.amethyst.ui.screen.loggedOff.legal.TermsGate -import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow import com.vitorpamplona.quartz.nip55AndroidSigner.client.isExternalSignerInstalled import kotlinx.coroutines.delay import kotlinx.coroutines.launch diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginViewModel.kt index 5b3af89c9a..38e6c7ea99 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginViewModel.kt @@ -36,8 +36,8 @@ import com.vitorpamplona.amethyst.commons.resources.login_bunker_not_supported import com.vitorpamplona.amethyst.commons.resources.login_nostrconnect_not_supported import com.vitorpamplona.amethyst.commons.resources.password_is_required import com.vitorpamplona.amethyst.commons.resources.sign_request_rejected_description +import com.vitorpamplona.amethyst.commons.tor.TorSettingsFlow import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager -import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow @Stable class LoginViewModel : ViewModel() { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/signup/SignUpScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/signup/SignUpScreen.kt index b579310fd6..86bcbb03aa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/signup/SignUpScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/signup/SignUpScreen.kt @@ -59,6 +59,7 @@ import com.vitorpamplona.amethyst.commons.resources.app_logo import com.vitorpamplona.amethyst.commons.resources.how_should_we_call_you import com.vitorpamplona.amethyst.commons.resources.my_awesome_name import com.vitorpamplona.amethyst.commons.resources.welcome +import com.vitorpamplona.amethyst.commons.tor.TorSettingsFlow import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.Size10dp import com.vitorpamplona.amethyst.commons.ui.theme.Size20dp @@ -70,7 +71,6 @@ import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager import com.vitorpamplona.amethyst.ui.screen.loggedOff.TorSettingsSetup import com.vitorpamplona.amethyst.ui.screen.loggedOff.legal.TermsGate import com.vitorpamplona.amethyst.ui.screen.loggedOff.login.LoginErrorManager -import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow import kotlinx.coroutines.launch @Preview(device = "spec:width=2160px,height=2340px,dpi=440") diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/signup/SignUpViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/signup/SignUpViewModel.kt index c076422ea3..42307cb17b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/signup/SignUpViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/signup/SignUpViewModel.kt @@ -29,9 +29,9 @@ import androidx.lifecycle.ViewModel import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.name_is_required +import com.vitorpamplona.amethyst.commons.tor.TorSettingsFlow import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager import com.vitorpamplona.amethyst.ui.screen.loggedOff.login.LoginErrorManager -import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow @Stable class SignUpViewModel : ViewModel() { 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 fd6449d309..a0b41a4aca 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,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.tor +import com.vitorpamplona.amethyst.commons.tor.TorPreferencesPort import com.vitorpamplona.amethyst.commons.tor.TorType import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineDispatcher diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorSettingsDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorSettingsDialog.kt index 8214177c36..f3ccdd3c6f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorSettingsDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorSettingsDialog.kt @@ -84,6 +84,8 @@ import com.vitorpamplona.amethyst.commons.tor.parseTorPresetType import com.vitorpamplona.amethyst.commons.tor.parseTorType import com.vitorpamplona.amethyst.commons.ui.components.TitleExplainer import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.SavingTopBar +import com.vitorpamplona.amethyst.commons.ui.settings.explainerId +import com.vitorpamplona.amethyst.commons.ui.settings.resourceId import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.Size10dp import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/tor/TorManagerTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/tor/TorManagerTest.kt index a6ea38384c..5b472c33fe 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/tor/TorManagerTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/tor/TorManagerTest.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.tor +import com.vitorpamplona.amethyst.commons.tor.TorPreferencesPort import com.vitorpamplona.amethyst.commons.tor.TorType import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.awaitCancellation diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/tor/TorSettingsTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/tor/TorSettingsTest.kt deleted file mode 100644 index d7361f9602..0000000000 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/tor/TorSettingsTest.kt +++ /dev/null @@ -1,305 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.ui.tor - -import com.vitorpamplona.amethyst.commons.tor.TorPresetType -import com.vitorpamplona.amethyst.commons.tor.TorSettings -import com.vitorpamplona.amethyst.commons.tor.TorType -import com.vitorpamplona.amethyst.commons.tor.isPreset -import com.vitorpamplona.amethyst.commons.tor.parseTorPresetType -import com.vitorpamplona.amethyst.commons.tor.parseTorType -import com.vitorpamplona.amethyst.commons.tor.torDefaultPreset -import com.vitorpamplona.amethyst.commons.tor.torFullyPrivate -import com.vitorpamplona.amethyst.commons.tor.torOnlyWhenNeededPreset -import com.vitorpamplona.amethyst.commons.tor.torSmallPayloadsPreset -import com.vitorpamplona.amethyst.commons.tor.whichPreset -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNotEquals -import org.junit.Assert.assertTrue -import org.junit.Test - -class TorSettingsTest { - // --- parseTorType --- - - @Test - fun parseTorType_code0_returnsOff() { - assertEquals(TorType.OFF, parseTorType(0)) - } - - @Test - fun parseTorType_code1_returnsInternal() { - assertEquals(TorType.INTERNAL, parseTorType(1)) - } - - @Test - fun parseTorType_code2_returnsExternal() { - assertEquals(TorType.EXTERNAL, parseTorType(2)) - } - - @Test - fun parseTorType_null_defaultsToInternal() { - assertEquals(TorType.INTERNAL, parseTorType(null)) - } - - @Test - fun parseTorType_unknownCode_defaultsToInternal() { - assertEquals(TorType.INTERNAL, parseTorType(99)) - } - - @Test - fun parseTorType_negativeCode_defaultsToInternal() { - assertEquals(TorType.INTERNAL, parseTorType(-1)) - } - - // --- TorType screenCode consistency --- - - @Test - fun torType_screenCodes_areUnique() { - val codes = TorType.entries.map { it.screenCode } - assertEquals(codes.size, codes.toSet().size) - } - - @Test - fun torType_allValues_roundTripViaParse() { - TorType.entries.forEach { type -> - assertEquals(type, parseTorType(type.screenCode)) - } - } - - // --- parseTorPresetType --- - - @Test - fun parseTorPresetType_code0_returnsOnlyWhenNeeded() { - assertEquals(TorPresetType.ONLY_WHEN_NEEDED, parseTorPresetType(0)) - } - - @Test - fun parseTorPresetType_code1_returnsDefault() { - assertEquals(TorPresetType.DEFAULT, parseTorPresetType(1)) - } - - @Test - fun parseTorPresetType_code2_returnsSmallPayloads() { - assertEquals(TorPresetType.SMALL_PAYLOADS, parseTorPresetType(2)) - } - - @Test - fun parseTorPresetType_code3_returnsFullPrivacy() { - assertEquals(TorPresetType.FULL_PRIVACY, parseTorPresetType(3)) - } - - @Test - fun parseTorPresetType_unknownCode_defaultsToCustom() { - assertEquals(TorPresetType.CUSTOM, parseTorPresetType(99)) - } - - @Test - fun parseTorPresetType_null_defaultsToCustom() { - assertEquals(TorPresetType.CUSTOM, parseTorPresetType(null)) - } - - @Test - fun torPresetType_screenCodes_areUnique() { - val codes = TorPresetType.entries.map { it.screenCode } - assertEquals(codes.size, codes.toSet().size) - } - - // --- Preset definitions --- - - @Test - fun onlyWhenNeededPreset_onlyOnionEnabled() { - assertTrue(torOnlyWhenNeededPreset.onionRelaysViaTor) - assertFalse(torOnlyWhenNeededPreset.dmRelaysViaTor) - assertFalse(torOnlyWhenNeededPreset.newRelaysViaTor) - assertFalse(torOnlyWhenNeededPreset.trustedRelaysViaTor) - assertFalse(torOnlyWhenNeededPreset.urlPreviewsViaTor) - assertFalse(torOnlyWhenNeededPreset.profilePicsViaTor) - assertFalse(torOnlyWhenNeededPreset.imagesViaTor) - assertFalse(torOnlyWhenNeededPreset.videosViaTor) - assertFalse(torOnlyWhenNeededPreset.moneyOperationsViaTor) - assertFalse(torOnlyWhenNeededPreset.nip05VerificationsViaTor) - assertFalse(torOnlyWhenNeededPreset.mediaUploadsViaTor) - } - - @Test - fun defaultPreset_onionDmNewEnabled() { - assertTrue(torDefaultPreset.onionRelaysViaTor) - assertTrue(torDefaultPreset.dmRelaysViaTor) - assertTrue(torDefaultPreset.newRelaysViaTor) - assertFalse(torDefaultPreset.trustedRelaysViaTor) - assertFalse(torDefaultPreset.urlPreviewsViaTor) - assertFalse(torDefaultPreset.imagesViaTor) - assertFalse(torDefaultPreset.videosViaTor) - assertFalse(torDefaultPreset.moneyOperationsViaTor) - assertFalse(torDefaultPreset.nip05VerificationsViaTor) - assertFalse(torDefaultPreset.mediaUploadsViaTor) - } - - @Test - fun smallPayloadsPreset_addsPreviewsNip05Money() { - assertTrue(torSmallPayloadsPreset.onionRelaysViaTor) - assertTrue(torSmallPayloadsPreset.dmRelaysViaTor) - assertTrue(torSmallPayloadsPreset.newRelaysViaTor) - assertTrue(torSmallPayloadsPreset.trustedRelaysViaTor) - assertTrue(torSmallPayloadsPreset.urlPreviewsViaTor) - assertTrue(torSmallPayloadsPreset.profilePicsViaTor) - assertFalse(torSmallPayloadsPreset.imagesViaTor) - assertFalse(torSmallPayloadsPreset.videosViaTor) - assertTrue(torSmallPayloadsPreset.moneyOperationsViaTor) - assertTrue(torSmallPayloadsPreset.nip05VerificationsViaTor) - assertFalse(torSmallPayloadsPreset.mediaUploadsViaTor) - } - - @Test - fun fullPrivacyPreset_allEnabled() { - assertTrue(torFullyPrivate.onionRelaysViaTor) - assertTrue(torFullyPrivate.dmRelaysViaTor) - assertTrue(torFullyPrivate.newRelaysViaTor) - assertTrue(torFullyPrivate.trustedRelaysViaTor) - assertTrue(torFullyPrivate.urlPreviewsViaTor) - assertTrue(torFullyPrivate.profilePicsViaTor) - assertTrue(torFullyPrivate.imagesViaTor) - assertTrue(torFullyPrivate.videosViaTor) - assertTrue(torFullyPrivate.moneyOperationsViaTor) - assertTrue(torFullyPrivate.nip05VerificationsViaTor) - assertTrue(torFullyPrivate.mediaUploadsViaTor) - } - - // --- Preset hierarchy: each level is a superset of the previous --- - - @Test - fun presets_areIncreasing_defaultSupersetOfOnlyWhenNeeded() { - // Default enables DM + new relays on top of onlyWhenNeeded - assertTrue(torDefaultPreset.dmRelaysViaTor) - assertTrue(torDefaultPreset.newRelaysViaTor) - assertFalse(torOnlyWhenNeededPreset.dmRelaysViaTor) - assertFalse(torOnlyWhenNeededPreset.newRelaysViaTor) - } - - @Test - fun presets_areIncreasing_fullPrivacySupersetOfSmallPayloads() { - // Full privacy adds images, videos, media uploads - assertTrue(torFullyPrivate.imagesViaTor) - assertTrue(torFullyPrivate.videosViaTor) - assertTrue(torFullyPrivate.mediaUploadsViaTor) - assertFalse(torSmallPayloadsPreset.imagesViaTor) - assertFalse(torSmallPayloadsPreset.videosViaTor) - assertFalse(torSmallPayloadsPreset.mediaUploadsViaTor) - } - - // --- whichPreset --- - - @Test - fun whichPreset_matchesOnlyWhenNeeded() { - assertEquals(TorPresetType.ONLY_WHEN_NEEDED, whichPreset(torOnlyWhenNeededPreset)) - } - - @Test - fun whichPreset_matchesDefault() { - assertEquals(TorPresetType.DEFAULT, whichPreset(torDefaultPreset)) - } - - @Test - fun whichPreset_matchesSmallPayloads() { - assertEquals(TorPresetType.SMALL_PAYLOADS, whichPreset(torSmallPayloadsPreset)) - } - - @Test - fun whichPreset_matchesFullPrivacy() { - assertEquals(TorPresetType.FULL_PRIVACY, whichPreset(torFullyPrivate)) - } - - @Test - fun whichPreset_returnsCustomForMixedSettings() { - val mixed = - TorSettings( - onionRelaysViaTor = true, - dmRelaysViaTor = true, - newRelaysViaTor = false, // differs from DEFAULT - trustedRelaysViaTor = true, // differs from DEFAULT - ) - assertEquals(TorPresetType.CUSTOM, whichPreset(mixed)) - } - - @Test - fun whichPreset_ignoresProfilePicsInComparison() { - // profilePicsViaTor is commented out in isPreset() - val withProfilePics = torDefaultPreset.copy(profilePicsViaTor = true) - assertEquals(TorPresetType.DEFAULT, whichPreset(withProfilePics)) - } - - @Test - fun whichPreset_ignoresTorTypeAndPort() { - // whichPreset only compares boolean flags, not torType/port - val withExternal = torDefaultPreset.copy(torType = TorType.EXTERNAL, externalSocksPort = 1234) - assertEquals(TorPresetType.DEFAULT, whichPreset(withExternal)) - } - - // --- isPreset --- - - @Test - fun isPreset_exactMatch_returnsTrue() { - assertTrue(isPreset(torFullyPrivate, torFullyPrivate)) - } - - @Test - fun isPreset_differentFlag_returnsFalse() { - val modified = torFullyPrivate.copy(imagesViaTor = false) - assertFalse(isPreset(modified, torFullyPrivate)) - } - - @Test - fun isPreset_torTypeDifference_ignored() { - val withOff = torDefaultPreset.copy(torType = TorType.OFF) - assertTrue(isPreset(withOff, torDefaultPreset)) - } - - // --- TorSettings data class --- - - @Test - fun torSettings_defaultValues() { - val defaults = TorSettings() - assertEquals(TorType.INTERNAL, defaults.torType) - assertEquals(9050, defaults.externalSocksPort) - assertTrue(defaults.onionRelaysViaTor) - assertTrue(defaults.dmRelaysViaTor) - assertTrue(defaults.newRelaysViaTor) - assertFalse(defaults.trustedRelaysViaTor) - } - - @Test - fun torSettings_equality_worksForDistinctUntilChanged() { - val a = TorSettings(torType = TorType.INTERNAL, externalSocksPort = 9050) - val b = TorSettings(torType = TorType.INTERNAL, externalSocksPort = 9050) - assertEquals(a, b) - assertEquals(a.hashCode(), b.hashCode()) - } - - @Test - fun torSettings_copy_changesOneField() { - val original = TorSettings() - val modified = original.copy(torType = TorType.OFF) - assertEquals(TorType.OFF, modified.torType) - assertEquals(original.externalSocksPort, modified.externalSocksPort) - assertNotEquals(original, modified) - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/TorSharedPreferences.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/TorSettingsStore.kt similarity index 92% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/TorSharedPreferences.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/TorSettingsStore.kt index 5c4b054edd..45c654835e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/TorSharedPreferences.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/TorSettingsStore.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.model.preferences +package com.vitorpamplona.amethyst.commons.model.preferences import androidx.compose.runtime.Stable import androidx.datastore.core.DataStore @@ -28,14 +28,15 @@ import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey +import com.vitorpamplona.amethyst.commons.tor.TorPreferencesPort import com.vitorpamplona.amethyst.commons.tor.TorSettings +import com.vitorpamplona.amethyst.commons.tor.TorSettingsFlow import com.vitorpamplona.amethyst.commons.tor.TorType -import com.vitorpamplona.amethyst.ui.tor.TorPreferencesPort -import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.IO import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.debounce @@ -47,7 +48,7 @@ import kotlinx.coroutines.flow.stateIn import kotlin.coroutines.cancellation.CancellationException @Stable -class TorSharedPreferences( +class TorSettingsStore( prefs: TorSettings, val store: DataStore, val scope: CoroutineScope, @@ -72,9 +73,9 @@ class TorSharedPreferences( value.toSettings(), ) - override suspend fun loadLastBypassApprovalMs(): Long = TorSharedPreferences.loadLastBypassApprovalMs(store) + override suspend fun loadLastBypassApprovalMs(): Long = loadLastBypassApprovalMs(store) - override suspend fun saveLastBypassApprovalMs(value: Long) = TorSharedPreferences.saveLastBypassApprovalMs(value, store) + override suspend fun saveLastBypassApprovalMs(value: Long) = saveLastBypassApprovalMs(value, store) companion object { // loads faster when individualized @@ -115,7 +116,7 @@ class TorSharedPreferences( } catch (e: Exception) { if (e is CancellationException) throw e // Log any errors that occur while reading the DataStore. - Log.e("SharedPreferences") { "Error reading DataStore preferences: ${e.message}" } + Log.e("TorSettingsStore") { "Error reading DataStore preferences: ${e.message}" } null } @@ -142,7 +143,7 @@ class TorSharedPreferences( } catch (e: Exception) { if (e is CancellationException) throw e // Log any errors that occur while reading the DataStore. - Log.e("SharedPreferences") { "Error saving DataStore preferences: ${e.message}" } + Log.e("TorSettingsStore") { "Error saving DataStore preferences: ${e.message}" } } } @@ -151,7 +152,7 @@ class TorSharedPreferences( store.data.first()[LAST_BYPASS_APPROVAL_MS_KEY] ?: 0L } catch (e: Exception) { if (e is CancellationException) throw e - Log.e("SharedPreferences") { "Error reading lastBypassApprovalMs: ${e.message}" } + Log.e("TorSettingsStore") { "Error reading lastBypassApprovalMs: ${e.message}" } 0L } @@ -165,7 +166,7 @@ class TorSharedPreferences( } } catch (e: Exception) { if (e is CancellationException) throw e - Log.e("SharedPreferences") { "Error saving lastBypassApprovalMs: ${e.message}" } + Log.e("TorSettingsStore") { "Error saving lastBypassApprovalMs: ${e.message}" } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorPreferencesPort.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/tor/TorPreferencesPort.kt similarity index 75% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorPreferencesPort.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/tor/TorPreferencesPort.kt index 3188f15f81..3710aec24e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorPreferencesPort.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/tor/TorPreferencesPort.kt @@ -18,15 +18,17 @@ * 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 +package com.vitorpamplona.amethyst.commons.tor -import com.vitorpamplona.amethyst.commons.tor.TorType import kotlinx.coroutines.flow.StateFlow /** - * The slice of `TorSharedPreferences` that [TorManager] depends on. Extracted so the - * manager can be unit-tested without an Android `Context` (and without DataStore). - * Production wires `TorSharedPreferences`; tests wire an in-memory fake. + * The slice of `TorSettingsStore` that each front end's Tor manager depends on. + * + * Extracted so a manager can be unit-tested without a real store behind it: + * production wires `TorSettingsStore`, tests wire an in-memory fake. It lives + * here rather than beside Android's `TorManager` because the store that + * satisfies it is shared, and a desktop or CLI manager needs the same slice. */ interface TorPreferencesPort { val torType: StateFlow diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorSettingsFlow.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/tor/TorSettingsFlow.kt similarity index 97% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorSettingsFlow.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/tor/TorSettingsFlow.kt index 89f297fedc..e7d3e43769 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorSettingsFlow.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/tor/TorSettingsFlow.kt @@ -18,11 +18,9 @@ * 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 +package com.vitorpamplona.amethyst.commons.tor import androidx.compose.runtime.Stable -import com.vitorpamplona.amethyst.commons.tor.TorSettings -import com.vitorpamplona.amethyst.commons.tor.TorType import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/tor/TorSettingsTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/tor/TorSettingsTest.kt index 1dfb214a3b..a96731c21f 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/tor/TorSettingsTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/tor/TorSettingsTest.kt @@ -179,4 +179,60 @@ class TorSettingsTest { assertEquals(TorType.OFF, modified.torType) assertNotEquals(original, modified) } + + @Test + fun isPreset_torTypeDifference_ignored() { + val withOff = torDefaultPreset.copy(torType = TorType.OFF) + assertTrue(isPreset(withOff, torDefaultPreset)) + } + + @Test + fun presets_areIncreasing_defaultSupersetOfOnlyWhenNeeded() { + // Default enables DM + new relays on top of onlyWhenNeeded + assertTrue(torDefaultPreset.dmRelaysViaTor) + assertTrue(torDefaultPreset.newRelaysViaTor) + assertFalse(torOnlyWhenNeededPreset.dmRelaysViaTor) + assertFalse(torOnlyWhenNeededPreset.newRelaysViaTor) + } + + @Test + fun presets_areIncreasing_fullPrivacySupersetOfSmallPayloads() { + // Full privacy adds images, videos, media uploads + assertTrue(torFullyPrivate.imagesViaTor) + assertTrue(torFullyPrivate.videosViaTor) + assertTrue(torFullyPrivate.mediaUploadsViaTor) + assertFalse(torSmallPayloadsPreset.imagesViaTor) + assertFalse(torSmallPayloadsPreset.videosViaTor) + assertFalse(torSmallPayloadsPreset.mediaUploadsViaTor) + } + + @Test + fun smallPayloadsPreset_addsPreviewsNip05Money() { + assertTrue(torSmallPayloadsPreset.onionRelaysViaTor) + assertTrue(torSmallPayloadsPreset.dmRelaysViaTor) + assertTrue(torSmallPayloadsPreset.newRelaysViaTor) + assertTrue(torSmallPayloadsPreset.trustedRelaysViaTor) + assertTrue(torSmallPayloadsPreset.urlPreviewsViaTor) + assertTrue(torSmallPayloadsPreset.profilePicsViaTor) + assertFalse(torSmallPayloadsPreset.imagesViaTor) + assertFalse(torSmallPayloadsPreset.videosViaTor) + assertTrue(torSmallPayloadsPreset.moneyOperationsViaTor) + assertTrue(torSmallPayloadsPreset.nip05VerificationsViaTor) + assertFalse(torSmallPayloadsPreset.mediaUploadsViaTor) + } + + @Test + fun torSettings_equality_worksForDistinctUntilChanged() { + val a = TorSettings(torType = TorType.INTERNAL, externalSocksPort = 9050) + val b = TorSettings(torType = TorType.INTERNAL, externalSocksPort = 9050) + assertEquals(a, b) + assertEquals(a.hashCode(), b.hashCode()) + } + + @Test + fun whichPreset_ignoresTorTypeAndPort() { + // whichPreset only compares boolean flags, not torType/port + val withExternal = torDefaultPreset.copy(torType = TorType.EXTERNAL, externalSocksPort = 1234) + assertEquals(TorPresetType.DEFAULT, whichPreset(withExternal)) + } } diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/TorSettingsStoreTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/TorSettingsStoreTest.kt new file mode 100644 index 0000000000..ecf403972d --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/TorSettingsStoreTest.kt @@ -0,0 +1,156 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.preferences + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import com.vitorpamplona.amethyst.commons.tor.TorSettings +import com.vitorpamplona.amethyst.commons.tor.TorType +import kotlinx.coroutines.test.runTest +import okio.Path.Companion.toOkioPath +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class TorSettingsStoreTest { + @get:Rule + val folder = TemporaryFolder() + + private fun rawStore(name: String): DataStore = + PreferenceDataStoreFactory.createWithPath( + produceFile = { folder.root.toOkioPath() / "$name.preferences_pb" }, + ) + + /** + * These defaults decide what an install that never opened the privacy screen + * sends over Tor, so getting one wrong is a privacy regression in one + * direction and a connectivity regression in the other. + * + * Three are on — onion, DM and new relays — and the rest are off. Note that + * the desktop front end's own `DesktopTorPreferences` reads most of these as + * `true` instead; the two have never agreed, and this pins the Android + * behaviour that the shared store inherits. + */ + @Test + fun anEmptyStoreReadsTheDefaultsTheAndroidStoreAlwaysHad() = + runTest { + val settings = TorSettingsStore.torPreferences(rawStore("empty"))!! + + assertEquals(TorType.INTERNAL, settings.torType) + assertEquals(9050, settings.externalSocksPort) + + assertTrue(settings.onionRelaysViaTor) + assertTrue(settings.dmRelaysViaTor) + assertTrue(settings.newRelaysViaTor) + + assertFalse(settings.trustedRelaysViaTor) + assertFalse(settings.urlPreviewsViaTor) + assertFalse(settings.profilePicsViaTor) + assertFalse(settings.imagesViaTor) + assertFalse(settings.videosViaTor) + assertFalse(settings.moneyOperationsViaTor) + assertFalse(settings.nip05VerificationsViaTor) + assertFalse(settings.mediaUploadsViaTor) + } + + /** The data class and the store must agree, or "unset" and "default" drift apart. */ + @Test + fun theEmptyStoreMatchesTheDataClassDefaults() = + runTest { + assertEquals(TorSettings(), TorSettingsStore.torPreferences(rawStore("match"))) + } + + @Test + fun everySettingSurvivesARoundTrip() = + runTest { + val store = rawStore("roundtrip") + val settings = + TorSettings( + torType = TorType.EXTERNAL, + externalSocksPort = 9150, + onionRelaysViaTor = false, + dmRelaysViaTor = false, + newRelaysViaTor = false, + trustedRelaysViaTor = true, + urlPreviewsViaTor = true, + profilePicsViaTor = true, + imagesViaTor = true, + videosViaTor = true, + moneyOperationsViaTor = true, + nip05VerificationsViaTor = true, + mediaUploadsViaTor = true, + ) + + TorSettingsStore.save(settings, store) + + assertEquals(settings, TorSettingsStore.torPreferences(store)) + } + + /** + * The bypass timestamp is deliberately not part of [TorSettings] — it is + * bookkeeping for the connection-failure dialog, not a user setting — so it + * has its own pair of accessors and its own key. + */ + @Test + fun theBypassApprovalTimestampIsStoredApartFromTheSettings() = + runTest { + val store = rawStore("bypass") + + assertEquals(0L, TorSettingsStore.loadLastBypassApprovalMs(store)) + + TorSettingsStore.saveLastBypassApprovalMs(1_700_000_000_000L, store) + assertEquals(1_700_000_000_000L, TorSettingsStore.loadLastBypassApprovalMs(store)) + + // and a settings save must not clear it + TorSettingsStore.save(TorSettings(torType = TorType.OFF), store) + assertEquals(1_700_000_000_000L, TorSettingsStore.loadLastBypassApprovalMs(store)) + } + + /** + * An unreadable enum name must not take the whole settings object down with + * it — a store written by a newer build that added a TorType would otherwise + * strand the user with no Tor settings at all. + */ + @Test + fun anUnknownTorTypeFallsBackRatherThanThrowing() = + runTest { + val store = rawStore("garbage") + store.edit { it[TorSettingsStore.TOR_TYPE_KEY] = "SOMETHING_NEWER" } + + assertNull(TorSettingsStore.torPreferences(store)) + } + + /** The keys are the ones the Android store has always written. */ + @Test + fun theKeyNamesAreUnchanged() { + assertEquals("tor.torType", TorSettingsStore.TOR_TYPE_KEY.name) + assertEquals("tor.externalSocksPort", TorSettingsStore.EXTERNAL_SOCKS_PORT_KEY.name) + assertEquals("tor.lastBypassApprovalMs", TorSettingsStore.LAST_BYPASS_APPROVAL_MS_KEY.name) + assertEquals("tor.onionRelaysViaTor", TorSettingsStore.ONION_RELAYS_VIA_TOR_KEY.name) + assertEquals("tor.mediaUploadsViaTor", TorSettingsStore.MEDIA_UPLOADS_VIA_TOR_KEY.name) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorSettings.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/settings/TorSettingsLabels.kt similarity index 88% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorSettings.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/settings/TorSettingsLabels.kt index 4443d4bbdc..ce050893d7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorSettings.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/settings/TorSettingsLabels.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.ui.tor +package com.vitorpamplona.amethyst.commons.ui.settings import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.tor_custom @@ -38,12 +38,15 @@ import com.vitorpamplona.amethyst.commons.tor.TorPresetType import com.vitorpamplona.amethyst.commons.tor.TorType import org.jetbrains.compose.resources.StringResource -// Re-export shared types so existing Android imports continue to work -// The canonical types now live in commons/commonMain -@Suppress("unused") -private const val RE_EXPORTS = 0 - -// Catalog keys for TorType (shared types live in commons/commonMain) +/** + * The display labels for the Tor settings enums. + * + * Same split as the UI settings labels beside this file: the data ([TorType], [TorPresetType], + * [com.vitorpamplona.amethyst.commons.tor.TorSettings]) is in `commons`, where + * the CLI and any headless front end can read it, and the [StringResource]s are + * here because they come from the generated `Res` class that only `commonsUI` + * has. + */ val TorType.resourceId: StringResource get() = when (this) { @@ -52,7 +55,6 @@ val TorType.resourceId: StringResource TorType.EXTERNAL -> Res.string.tor_external } -// Catalog keys for TorPresetType val TorPresetType.resourceId: StringResource get() = when (this) { From edc12df63a21c6caa18baf3b89aa0c6c53438238 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 14:24:16 +0000 Subject: [PATCH 23/50] refactor: move the search history and NIP-46 client stores into commons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both were already DataStore-backed and already implemented a commons interface; what kept them in amethyst was that each kept its own `ConcurrentHashMap>` registry, keyed by absolute path, to satisfy DataStore's one-instance-per-file rule. That is a third registry alongside AppPreferenceStores and AccountPreferenceStores, and `java.util.concurrent` does not belong in commonMain either. They now take the DataStore instead of a directory, so the caller's holder is the single registry. Paths are unchanged — AppPreferenceStores.file produces the same `datastore/.preferences_pb` these built by hand. DataStoreNip46ClientStore also dropped `java.security.MessageDigest` for quartz's KMP sha256. That digest feeds a stored key, so getting it wrong would not fail loudly — it would orphan every NIP-46 client a user has authorized. Hex.encode masks to 0xFF and emits lower case, which is what `"%02x".format(byte)` did, and DataStoreNip46ClientStoreTest pins three coordinates against SHA-256 prefixes computed outside this codebase so the equivalence is checked rather than argued. Renamed its `store` property to `dataStore` while moving it: the class had both a `store` property and a `store(coordinate, info)` method, and `store.edit { }` inside `fun store()` reads as a recursive call at a glance. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AXvKXakvup4inNFfAhhr4L --- .../com/vitorpamplona/amethyst/AppModules.kt | 4 +- .../ui/screen/loggedIn/search/SearchScreen.kt | 5 +- .../nip46/DataStoreNip46ClientStore.kt | 47 ++++------ .../search}/DataStoreSearchHistoryStorage.kt | 36 ++----- .../nip46/DataStoreNip46ClientStoreTest.kt | 93 +++++++++++++++++++ 5 files changed, 127 insertions(+), 58 deletions(-) rename {amethyst/src/main/java/com/vitorpamplona/amethyst => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons}/connectedApps/nip46/DataStoreNip46ClientStore.kt (77%) rename {amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search}/DataStoreSearchHistoryStorage.kt (54%) create mode 100644 commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/DataStoreNip46ClientStoreTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 6c3fde8ac3..fdcc03588f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -27,6 +27,7 @@ import android.os.SystemClock import androidx.security.crypto.EncryptedSharedPreferences import coil3.disk.DiskCache import coil3.memory.MemoryCache +import com.vitorpamplona.amethyst.commons.connectedApps.nip46.DataStoreNip46ClientStore import com.vitorpamplona.amethyst.commons.model.NoteState import com.vitorpamplona.amethyst.commons.model.UiSettings import com.vitorpamplona.amethyst.commons.model.cache.LocalCache @@ -67,7 +68,6 @@ import com.vitorpamplona.amethyst.commons.service.pow.PoWPolicy import com.vitorpamplona.amethyst.commons.service.pow.PoWPublishQueue import com.vitorpamplona.amethyst.commons.tor.TorSettings import com.vitorpamplona.amethyst.connectedApps.DataStoreNostrSignerPermissionStore -import com.vitorpamplona.amethyst.connectedApps.nip46.DataStoreNip46ClientStore import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState import com.vitorpamplona.amethyst.model.nip11RelayInfo.Nip11CachedRetriever @@ -873,7 +873,7 @@ class AppModules( val signerPermissionStore by lazy { DataStoreNostrSignerPermissionStore(appContext) } // Display + relay info for connected NIP-46 remote-signer clients. - val nip46ClientStore by lazy { DataStoreNip46ClientStore(appContext) } + val nip46ClientStore by lazy { DataStoreNip46ClientStore(appStores.getDataStore(DataStoreNip46ClientStore.FILE_NAME)) } // Authenticates with relays. val authCoordinator = AuthCoordinator(client, applicationIOScope) 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 46072a6f17..24082d100f 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 @@ -73,7 +73,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextAlign @@ -111,6 +110,7 @@ import com.vitorpamplona.amethyst.commons.resources.search_source_relays import com.vitorpamplona.amethyst.commons.resources.search_type_to_begin import com.vitorpamplona.amethyst.commons.resources.search_type_to_begin_explainer import com.vitorpamplona.amethyst.commons.resources.search_waiting_on_relays +import com.vitorpamplona.amethyst.commons.search.DataStoreSearchHistoryStorage import com.vitorpamplona.amethyst.commons.search.QuerySerializer import com.vitorpamplona.amethyst.commons.search.SearchScope import com.vitorpamplona.amethyst.commons.search.SearchSortOrder @@ -133,7 +133,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.Size5dp import com.vitorpamplona.amethyst.commons.ui.theme.StdTopPadding import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo -import com.vitorpamplona.amethyst.model.preferences.DataStoreSearchHistoryStorage import com.vitorpamplona.amethyst.service.location.CachedReversedGeoLocations import com.vitorpamplona.amethyst.service.relayClient.searchCommand.TextSearchDataSourceSubscription import com.vitorpamplona.amethyst.ui.components.namecoin.NamecoinResolutionRow @@ -167,7 +166,7 @@ fun SearchScreen( accountViewModel: AccountViewModel, nav: INav, ) { - val historyStorage = LocalContext.current.let { context -> remember(context) { DataStoreSearchHistoryStorage(context) } } + val historyStorage = remember { DataStoreSearchHistoryStorage(Amethyst.instance.appStores.getDataStore(DataStoreSearchHistoryStorage.FILE_NAME)) } val searchBarViewModel: SearchBarViewModel = viewModel( // Keyed on the seed: navigating from one screen's search button to another's has to diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/nip46/DataStoreNip46ClientStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/DataStoreNip46ClientStore.kt similarity index 77% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/nip46/DataStoreNip46ClientStore.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/DataStoreNip46ClientStore.kt index eaa68c4031..c3ffec1b38 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/nip46/DataStoreNip46ClientStore.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/DataStoreNip46ClientStore.kt @@ -18,20 +18,15 @@ * 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.connectedApps.nip46 +package com.vitorpamplona.amethyst.commons.connectedApps.nip46 -import android.content.Context import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.PreferenceDataStoreFactory import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey -import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46ClientInfo -import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46ClientStore +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.utils.sha256.sha256 import kotlinx.coroutines.flow.first -import java.io.File -import java.security.MessageDigest -import java.util.concurrent.ConcurrentHashMap /** * Single-file DataStore-backed [Nip46ClientStore]. Every connected client's @@ -41,14 +36,10 @@ import java.util.concurrent.ConcurrentHashMap * individually so no serialization library is needed; [relays] is newline-joined. */ class DataStoreNip46ClientStore( - private val filesDir: File, + private val dataStore: DataStore, ) : Nip46ClientStore { - constructor(context: Context) : this(context.applicationContext.filesDir) - - private val store: DataStore get() = dataStoreFor(File(filesDir, "datastore/nip46_clients.preferences_pb")) - override suspend fun load(coordinate: String): Nip46ClientInfo? { - val prefs = store.data.first() + val prefs = dataStore.data.first() if (prefs[coordKey(coordinate)] == null) return null return Nip46ClientInfo( name = prefs[nameKey(coordinate)], @@ -62,7 +53,7 @@ class DataStoreNip46ClientStore( coordinate: String, info: Nip46ClientInfo, ) { - store.edit { prefs -> + dataStore.edit { prefs -> prefs[coordKey(coordinate)] = coordinate info.name?.let { prefs[nameKey(coordinate)] = it } ?: prefs.remove(nameKey(coordinate)) info.url?.let { prefs[urlKey(coordinate)] = it } ?: prefs.remove(urlKey(coordinate)) @@ -72,7 +63,7 @@ class DataStoreNip46ClientStore( } override suspend fun remove(coordinate: String) { - store.edit { prefs -> + dataStore.edit { prefs -> prefs.remove(coordKey(coordinate)) prefs.remove(nameKey(coordinate)) prefs.remove(urlKey(coordinate)) @@ -82,7 +73,7 @@ class DataStoreNip46ClientStore( } override suspend fun all(): Map { - val prefs = store.data.first() + val prefs = dataStore.data.first() val result = mutableMapOf() for ((key, value) in prefs.asMap()) { if (!key.name.startsWith(COORD_PREFIX)) continue @@ -111,18 +102,20 @@ class DataStoreNip46ClientStore( private fun relaysKey(coordinate: String) = stringPreferencesKey("relays:${hash(coordinate)}") companion object { - private val stores = ConcurrentHashMap>() - - private fun dataStoreFor(file: File): DataStore = - stores.computeIfAbsent(file.absolutePath) { - PreferenceDataStoreFactory.create(produceFile = { file }) - } + const val FILE_NAME = "nip46_clients" private const val COORD_PREFIX = "coord:" - private fun hash(coordinate: String): String { - val digest = MessageDigest.getInstance("SHA-256").digest(coordinate.toByteArray()) - return digest.take(8).joinToString("") { "%02x".format(it) } - } + /** + * The first 8 bytes of the coordinate's SHA-256, lower-case hex. + * + * This is a stored key, so it must keep producing exactly what + * `MessageDigest.getInstance("SHA-256")` plus `"%02x".format(byte)` did + * on Android — a different digest here would orphan every client a user + * has already authorized rather than fail loudly. + * `DataStoreNip46ClientStoreTest` pins three coordinates against hashes + * computed outside this codebase. + */ + internal fun hash(coordinate: String): String = sha256(coordinate.encodeToByteArray()).copyOfRange(0, 8).toHexKey() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/DataStoreSearchHistoryStorage.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/DataStoreSearchHistoryStorage.kt similarity index 54% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/DataStoreSearchHistoryStorage.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/DataStoreSearchHistoryStorage.kt index 6820899ad4..80f9de7353 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/DataStoreSearchHistoryStorage.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/DataStoreSearchHistoryStorage.kt @@ -18,38 +18,27 @@ * 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.model.preferences +package com.vitorpamplona.amethyst.commons.search -import android.content.Context import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.PreferenceDataStoreFactory import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey -import com.vitorpamplona.amethyst.commons.search.SearchHistoryStorage import kotlinx.coroutines.flow.first -import java.io.File -import java.util.concurrent.ConcurrentHashMap /** - * Where Android keeps the search history: one `datastore/search_history.preferences_pb` file. + * [SearchHistoryStorage] on a DataStore of its own — `search_history` under the + * front end's datastore directory. * - * The history itself — what it holds, how much of it, in what order — is - * [com.vitorpamplona.amethyst.commons.search.SearchHistory] in commons, shared with Desktop. This - * is only the two strings and the file they live in. - * - * Device-global rather than per-account, like the drawer's collapse state beside it: what you - * searched for is a property of this phone, and it is never published to a relay. + * Takes the store rather than a directory: the search screen is rebuilt per + * seeded query, and DataStore refuses a second live instance on a path that + * already has one, so who owns the instance matters. Handing it in means the + * caller's store holder is the single registry rather than this class keeping a + * private one of its own. */ class DataStoreSearchHistoryStorage( - private val filesDir: File, + private val store: DataStore, ) : SearchHistoryStorage { - constructor(context: Context) : this(context.applicationContext.filesDir) - - // DataStore v1 throws if two instances are ever active on the same file, and the search screen - // is rebuilt per seeded query, so the store is shared per absolute path across the process. - private val store: DataStore get() = dataStoreFor(File(filesDir, "datastore/search_history.preferences_pb")) - override suspend fun read(key: String): String? = store.data.first()[stringPreferencesKey(key)] override suspend fun write( @@ -62,11 +51,6 @@ class DataStoreSearchHistoryStorage( } companion object { - private val stores = ConcurrentHashMap>() - - private fun dataStoreFor(file: File): DataStore = - stores.computeIfAbsent(file.absolutePath) { - PreferenceDataStoreFactory.create(produceFile = { file }) - } + const val FILE_NAME = "search_history" } } diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/DataStoreNip46ClientStoreTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/DataStoreNip46ClientStoreTest.kt new file mode 100644 index 0000000000..c98604a1cc --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/DataStoreNip46ClientStoreTest.kt @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.connectedApps.nip46 + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import kotlinx.coroutines.test.runTest +import okio.Path.Companion.toOkioPath +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class DataStoreNip46ClientStoreTest { + @get:Rule + val folder = TemporaryFolder() + + private fun store(name: String): DataStore = + PreferenceDataStoreFactory.createWithPath( + produceFile = { folder.root.toOkioPath() / "$name.preferences_pb" }, + ) + + /** + * The coordinate hash is a stored key, so it has to keep producing exactly + * what the Android implementation did — `MessageDigest.getInstance("SHA-256")` + * truncated to 8 bytes and formatted with `"%02x"`. A different digest would + * not fail; it would quietly orphan every client the user has authorized. + * + * The expected values are SHA-256 prefixes computed outside this codebase, so + * this is a cross-check rather than a restatement of the implementation. + */ + @Test + fun theCoordinateHashMatchesTheAndroidImplementation() { + assertEquals("12bafbcaa8bb08b6", DataStoreNip46ClientStore.hash("31990:abc:def")) + assertEquals("12f134c5dae480dc", DataStoreNip46ClientStore.hash("wss://relay.example.com")) + assertEquals("e3b0c44298fc1c14", DataStoreNip46ClientStore.hash("")) + } + + /** 16 lower-case hex characters, always — it is half a key name. */ + @Test + fun theHashIsAlwaysSixteenLowerCaseHexChars() { + listOf("a", "a longer coordinate with spaces", "ünïcödé", "31990:".repeat(50)).forEach { + val h = DataStoreNip46ClientStore.hash(it) + assertEquals("wrong length for '$it'", 16, h.length) + assertEquals("not lower-case hex for '$it'", h, h.lowercase().filter { c -> c in "0123456789abcdef" }) + } + } + + @Test + fun aStoredClientComesBack() = + runTest { + val subject = DataStoreNip46ClientStore(store("clients")) + val coordinate = "31990:pubkeyhex:handler" + + assertNull(subject.load(coordinate)) + + subject.store( + coordinate, + Nip46ClientInfo(name = "Test App", url = "https://x", image = "https://x/y.png", relays = setOf("wss://a", "wss://b")), + ) + + val loaded = subject.load(coordinate)!! + assertEquals("Test App", loaded.name) + assertEquals("https://x", loaded.url) + assertEquals("https://x/y.png", loaded.image) + assertEquals(setOf("wss://a", "wss://b"), loaded.relays) + + assertEquals(mapOf(coordinate to loaded), subject.all()) + + subject.remove(coordinate) + assertNull(subject.load(coordinate)) + } +} From ffc574d9e66f36d00ef613bb0ba99ba6efefcc4f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 14:31:20 +0000 Subject: [PATCH 24/50] refactor: move the signer permission store into commons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last of the app-wide DataStore stores that still needed a Context. Unlike the others it is one file per app rather than one file, `nsp_`, and `allPolicies` enumerated that directory with `File.listFiles` — which is why it could not simply take a DataStore. AppPreferenceStores grows a `names(prefix)` built on the existing `platformFileSystem` expect/actual, so the enumeration is shared rather than JVM-only, and the store takes the holder instead of a directory. That also retires its private LargeCache of stores by path: the holder is now the only registry, which is the point, since DataStore refuses a second live instance on a path and a second registry is exactly how that happens. `java.security.MessageDigest` gives way to quartz's KMP sha256 here too, and the stakes are higher than for the NIP-46 store: this hash is half the *file name*, so a mismatch does not throw, it just never opens the old file again and every permission the user granted that app is silently gone. DataStoreNostrSignerPermissionStoreTest pins three coordinates against SHA-256 prefixes computed outside this codebase, and separately pins that `names("nsp_")` sees only the signer files — shared_settings and search_history live in that same directory — and returns empty rather than throwing when nothing has been written yet. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AXvKXakvup4inNFfAhhr4L --- .../com/vitorpamplona/amethyst/AppModules.kt | 4 +- .../DataStoreNostrSignerPermissionStore.kt | 50 +++++------- .../model/preferences/AppPreferenceStores.kt | 28 ++++++- ...DataStoreNostrSignerPermissionStoreTest.kt | 78 +++++++++++++++++++ 4 files changed, 128 insertions(+), 32 deletions(-) rename {amethyst/src/main/java/com/vitorpamplona/amethyst => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons}/connectedApps/DataStoreNostrSignerPermissionStore.kt (82%) create mode 100644 commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/DataStoreNostrSignerPermissionStoreTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index fdcc03588f..7f388ce450 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -27,6 +27,7 @@ import android.os.SystemClock import androidx.security.crypto.EncryptedSharedPreferences import coil3.disk.DiskCache import coil3.memory.MemoryCache +import com.vitorpamplona.amethyst.commons.connectedApps.DataStoreNostrSignerPermissionStore import com.vitorpamplona.amethyst.commons.connectedApps.nip46.DataStoreNip46ClientStore import com.vitorpamplona.amethyst.commons.model.NoteState import com.vitorpamplona.amethyst.commons.model.UiSettings @@ -67,7 +68,6 @@ import com.vitorpamplona.amethyst.commons.service.lnurl.OkHttpLnurlEndpointResol import com.vitorpamplona.amethyst.commons.service.pow.PoWPolicy import com.vitorpamplona.amethyst.commons.service.pow.PoWPublishQueue import com.vitorpamplona.amethyst.commons.tor.TorSettings -import com.vitorpamplona.amethyst.connectedApps.DataStoreNostrSignerPermissionStore import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState import com.vitorpamplona.amethyst.model.nip11RelayInfo.Nip11CachedRetriever @@ -870,7 +870,7 @@ class AppModules( // carry their owning account (`nip46::`) and whose sessions run for a specific // account rather than the active one. The napplet path namespaces its own coordinate the same way // (see NappletBroker.signerCoordinateFor) instead. - val signerPermissionStore by lazy { DataStoreNostrSignerPermissionStore(appContext) } + val signerPermissionStore by lazy { DataStoreNostrSignerPermissionStore(appStores) } // Display + relay info for connected NIP-46 remote-signer clients. val nip46ClientStore by lazy { DataStoreNip46ClientStore(appStores.getDataStore(DataStoreNip46ClientStore.FILE_NAME)) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/DataStoreNostrSignerPermissionStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/DataStoreNostrSignerPermissionStore.kt similarity index 82% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/DataStoreNostrSignerPermissionStore.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/DataStoreNostrSignerPermissionStore.kt index ec38b703b8..60bf4f7ffe 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/DataStoreNostrSignerPermissionStore.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/DataStoreNostrSignerPermissionStore.kt @@ -18,11 +18,9 @@ * 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.connectedApps +package com.vitorpamplona.amethyst.commons.connectedApps -import android.content.Context import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.PreferenceDataStoreFactory import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey @@ -30,12 +28,12 @@ import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppSignerPolicy import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrOpDecision import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionStore -import com.vitorpamplona.quartz.utils.cache.LargeCache +import com.vitorpamplona.amethyst.commons.model.preferences.AppPreferenceStores +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.utils.sha256.sha256 import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first import kotlinx.coroutines.withContext -import java.io.File -import java.security.MessageDigest /** * Per-coordinate DataStore-backed [NostrSignerPermissionStore]. One small `.preferences_pb` @@ -46,18 +44,9 @@ import java.security.MessageDigest * reverse-map file → coordinate without scanning the filesystem. */ class DataStoreNostrSignerPermissionStore( - private val filesDir: File, + private val stores: AppPreferenceStores, ) : NostrSignerPermissionStore { - constructor(context: Context) : this(context.applicationContext.filesDir) - - private val cache = LargeCache>() - - private fun storeFor(coordinate: String): DataStore { - val file = File(filesDir, "datastore/nsp_${hash(coordinate)}.preferences_pb") - return cache.getOrCreate(file.absolutePath) { - PreferenceDataStoreFactory.create(produceFile = { file }) - } - } + private fun storeFor(coordinate: String): DataStore = stores.getDataStore(nameFor(coordinate)) override suspend fun loadPolicy(coordinate: String): AppSignerPolicy? { val raw = storeFor(coordinate).data.first()[KEY_POLICY] ?: return null @@ -108,15 +97,9 @@ class DataStoreNostrSignerPermissionStore( // Enumerates the datastore directory + reads each file — blocking disk IO, so keep it off the // caller's thread (callers invoke this from Compose LaunchedEffects on the main dispatcher). withContext(Dispatchers.IO) { - val dir = File(filesDir, "datastore") - if (!dir.exists()) return@withContext emptyMap() val result = mutableMapOf() - for (file in dir.listFiles { f -> f.name.startsWith("nsp_") } ?: emptyArray()) { - val ds = - cache.getOrCreate(file.absolutePath) { - PreferenceDataStoreFactory.create(produceFile = { file }) - } - val coordinate = ds.data.first()[KEY_COORDINATE] ?: continue + for (name in stores.names(NAME_PREFIX)) { + val coordinate = stores.getDataStore(name).data.first()[KEY_COORDINATE] ?: continue val policy = loadPolicy(coordinate) ?: continue result[coordinate] = policy } @@ -188,9 +171,18 @@ class DataStoreNostrSignerPermissionStore( private const val OP_PREFIX = "op:" private const val OP_EXPIRY_SUFFIX = ":exp" - private fun hash(coordinate: String): String { - val digest = MessageDigest.getInstance("SHA-256").digest(coordinate.toByteArray()) - return digest.take(8).joinToString("") { "%02x".format(it) } - } + internal const val NAME_PREFIX = "nsp_" + + /** + * The per-app store's file name. + * + * The hash is part of the file name, so it must keep producing exactly + * what `MessageDigest.getInstance("SHA-256")` plus `"%02x".format(byte)` + * did on Android — a different digest orphans the file rather than + * failing, and with it every permission the user has granted that app. + * Pinned in DataStoreNostrSignerPermissionStoreTest against hashes + * computed outside this codebase. + */ + internal fun nameFor(coordinate: String): String = NAME_PREFIX + sha256(coordinate.encodeToByteArray()).copyOfRange(0, 8).toHexKey() } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AppPreferenceStores.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AppPreferenceStores.kt index a49447d3cb..edf30e73e8 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AppPreferenceStores.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AppPreferenceStores.kt @@ -24,6 +24,7 @@ import androidx.datastore.core.DataMigration import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.PreferenceDataStoreFactory import androidx.datastore.preferences.core.Preferences +import com.vitorpamplona.amethyst.commons.util.platformFileSystem import com.vitorpamplona.quartz.utils.cache.LargeCache import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -77,6 +78,8 @@ class AppPreferenceStores( * a whole-file read. */ const val SHARED_SETTINGS = "shared_settings" + + private const val SUFFIX = ".preferences_pb" } /** @@ -95,7 +98,7 @@ class AppPreferenceStores( private val storeCache = LargeCache() - fun file(name: String): Path = rootFilesDir() / "datastore" / "$name.preferences_pb" + fun file(name: String): Path = rootFilesDir() / "datastore" / "$name$SUFFIX" fun getDataStore(name: String): DataStore = storeCache @@ -113,4 +116,27 @@ class AppPreferenceStores( /** The file UI, Tor, OTS, Namecoin and friends share. */ fun sharedSettings(): DataStore = getDataStore(SHARED_SETTINGS) + + /** + * The names of stores already on disk whose name starts with [prefix]. + * + * For the store families that are one file per key rather than one file — + * the signer permissions keep an `nsp_` file per app — where the only + * way to enumerate what exists is to look. Reads the directory, so callers + * keep it off the main thread. + * + * Returns names in the form [getDataStore] takes, with the directory and + * the `.preferences_pb` suffix stripped, and an empty list when nothing has + * been written yet. + */ + fun names(prefix: String): List { + val dir = rootFilesDir() / "datastore" + if (!platformFileSystem.exists(dir)) return emptyList() + + return platformFileSystem + .list(dir) + .map { it.name } + .filter { it.startsWith(prefix) && it.endsWith(SUFFIX) } + .map { it.removeSuffix(SUFFIX) } + } } diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/DataStoreNostrSignerPermissionStoreTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/DataStoreNostrSignerPermissionStoreTest.kt new file mode 100644 index 0000000000..bb6135bf88 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/DataStoreNostrSignerPermissionStoreTest.kt @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.connectedApps + +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import com.vitorpamplona.amethyst.commons.model.preferences.AppPreferenceStores +import kotlinx.coroutines.test.runTest +import okio.Path.Companion.toOkioPath +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class DataStoreNostrSignerPermissionStoreTest { + @get:Rule + val folder = TemporaryFolder() + + private fun stores() = AppPreferenceStores(rootFilesDir = { folder.root.toOkioPath() }) + + /** + * The hash is half the file name, so it has to keep producing exactly what + * `MessageDigest.getInstance("SHA-256")` truncated to 8 bytes and formatted + * with `"%02x"` produced on Android. Get it wrong and the old file is simply + * never opened again — every permission the user granted that app is gone, + * silently. The expected values are SHA-256 prefixes computed outside this + * codebase. + */ + @Test + fun theFileNameMatchesTheAndroidImplementation() { + assertEquals("nsp_12bafbcaa8bb08b6", DataStoreNostrSignerPermissionStore.nameFor("31990:abc:def")) + assertEquals("nsp_12f134c5dae480dc", DataStoreNostrSignerPermissionStore.nameFor("wss://relay.example.com")) + assertEquals("nsp_e3b0c44298fc1c14", DataStoreNostrSignerPermissionStore.nameFor("")) + } + + /** + * allPolicies enumerates the datastore directory, which used to be + * `File.listFiles` and is now AppPreferenceStores.names. It must see only + * the signer files — the shared settings and every other store live in the + * same directory. + */ + @Test + fun namesSeesOnlyTheSignerFilesAndSurvivesAnEmptyDirectory() = + runTest { + val subject = stores() + + assertTrue("nothing written yet", subject.names("nsp_").isEmpty()) + + // a read does not create the file, only a write does + listOf("shared_settings", "search_history", "nsp_deadbeefdeadbeef", "nsp_0011223344556677").forEach { + subject.getDataStore(it).edit { prefs -> prefs[booleanPreferencesKey("touch")] = true } + } + + assertEquals( + listOf("nsp_0011223344556677", "nsp_deadbeefdeadbeef"), + subject.names("nsp_").sorted(), + ) + } +} From aeb1e483c8a55fa82974108fa850aa3cfce491f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 14:42:14 +0000 Subject: [PATCH 25/50] refactor: move the relay-auth permission store into commons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-account rather than app-wide: its file lives in that account's own directory, `accounts//datastore/relay_auth.preferences_pb`, so a DENY for one account cannot leak into another. That shape is why this one could not just take a directory and be done. buildAccount runs again for the same account on re-login and on cache races, and DataStore throws if a second instance is ever live on a file that already has one — the store used to guard that itself with a private ConcurrentHashMap keyed by absolute path. AccountCacheState now keeps one AppPreferenceStores per account directory instead, so the guarantee holds for any per-account store added there later rather than only this one, and commons keeps a single registry type instead of three ad-hoc ones. Its `java.security.MessageDigest` goes the same way as the other two, and carries the same risk: the hash is the stored key for every ALLOW/DENY, so a mismatch would not throw, it would quietly drop every decision the user has made. Pinned against SHA-256 prefixes computed outside this codebase. The store's 10 round-trip tests move to commons with it, off the Android unit-test job and onto the multiplatform one. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AXvKXakvup4inNFfAhhr4L --- .../model/accountsCache/AccountCacheState.kt | 27 ++++++++- .../DataStoreRelayAuthPermissionStore.kt | 58 +++++++++---------- .../DataStoreRelayAuthPermissionStoreTest.kt | 31 +++++++--- 3 files changed, 75 insertions(+), 41 deletions(-) rename {amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayauth}/DataStoreRelayAuthPermissionStore.kt (80%) rename {amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model => commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relayauth}/DataStoreRelayAuthPermissionStoreTest.kt (83%) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt index 62672c0ce9..2c347c81c7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt @@ -30,7 +30,9 @@ import com.vitorpamplona.amethyst.commons.marmot.InMemoryMlsGroupStateStore import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.model.marmot.AndroidIngestDedupStore import com.vitorpamplona.amethyst.commons.model.marmot.AndroidPushStateStore +import com.vitorpamplona.amethyst.commons.model.preferences.AppPreferenceStores import com.vitorpamplona.amethyst.commons.relayClient.nip47WalletConnect.NWCPaymentFilterAssembler +import com.vitorpamplona.amethyst.commons.relayauth.DataStoreRelayAuthPermissionStore import com.vitorpamplona.amethyst.commons.service.pow.PoWPublishQueue import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AccountSettings @@ -39,7 +41,6 @@ import com.vitorpamplona.amethyst.model.marmot.AndroidMarmotMessageStore import com.vitorpamplona.amethyst.model.marmot.AndroidMlsGroupStateStore import com.vitorpamplona.amethyst.model.marmot.AndroidPublishObligationStore import com.vitorpamplona.amethyst.service.location.LocationState -import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.DataStoreRelayAuthPermissionStore import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient @@ -49,6 +50,7 @@ import com.vitorpamplona.quartz.nip03Timestamp.OtsResolver import com.vitorpamplona.quartz.nip55AndroidSigner.client.NostrSignerExternal import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.NostrSignerWithClientTag import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.cache.LargeCache import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -57,6 +59,7 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update +import okio.Path.Companion.toOkioPath import java.io.File class AccountCacheState( @@ -89,6 +92,23 @@ class AccountCacheState( /** Guards [loadAccount]'s check-then-create so concurrent callers can't build twin Accounts. */ private val loadLock = Any() + /** + * One [AppPreferenceStores] per account directory, kept for the life of the + * process. + * + * [buildAccount] runs again for the same account on re-login and on cache + * races, and DataStore throws if a second instance is ever live on a file + * that already has one. Caching the holder — rather than the store — keeps + * that guarantee for every per-account store that gets added here later, + * not just the relay-auth one. + */ + private val accountStoreHolders = LargeCache() + + private fun storesFor(accountDir: File): AppPreferenceStores = + accountStoreHolders.getOrCreate(accountDir.absolutePath) { + AppPreferenceStores(rootFilesDir = { accountDir.toOkioPath() }) + } + fun removeAccount(pubkey: HexKey) { accounts.update { existingAccounts -> val oldValue = existingAccounts[pubkey] @@ -310,7 +330,10 @@ class AccountCacheState( // Per-account NIP-42 ALLOW/DENY overrides live in this account's own dir, so a DENY for one // account never leaks into another (the store used to be a single app-wide file). - val relayAuthPermissionStore = DataStoreRelayAuthPermissionStore(accountDir) + val relayAuthPermissionStore = + DataStoreRelayAuthPermissionStore( + storesFor(accountDir).getDataStore(DataStoreRelayAuthPermissionStore.FILE_NAME), + ) return Account( settings = accountSettings, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/DataStoreRelayAuthPermissionStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayauth/DataStoreRelayAuthPermissionStore.kt similarity index 80% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/DataStoreRelayAuthPermissionStore.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayauth/DataStoreRelayAuthPermissionStore.kt index fe2e79fffd..20cd1aaca0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/DataStoreRelayAuthPermissionStore.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayauth/DataStoreRelayAuthPermissionStore.kt @@ -18,39 +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.service.relayClient.authCommand.model +package com.vitorpamplona.amethyst.commons.relayauth -import android.content.Context import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.MutablePreferences -import androidx.datastore.preferences.core.PreferenceDataStoreFactory import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey -import com.vitorpamplona.amethyst.commons.relayauth.AuthPurposeKind -import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision -import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPermissionStore +import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.utils.TimeUtils +import com.vitorpamplona.quartz.utils.sha256.sha256 import kotlinx.coroutines.flow.first -import java.io.File -import java.security.MessageDigest -import java.util.concurrent.ConcurrentHashMap /** - * Single-file DataStore-backed [RelayAuthPermissionStore]. All per-relay ALLOW/DENY overrides - * live in one `datastore/relay_auth.preferences_pb` file; a SHA-256 prefix of the URL is the - * key so the URL itself is safe in the file (stored separately for reverse-lookup in [allDecisions]). + * Per-account NIP-42 ALLOW/DENY overrides, in one `relay_auth` store inside that + * account's own directory so a DENY for one account never leaks into another. + * + * A SHA-256 prefix of the relay URL is the key, so the URL itself is not a key + * in the file; it is stored separately under its own prefix for the reverse + * lookup [allDecisions] needs. + * + * Takes the store rather than the directory. DataStore throws if two instances + * are ever live on one file, and an account can be built more than once in a + * process (re-login, cache races), so the caller has to hand in a store it + * keeps — see AccountCacheState, which caches one holder per account. */ class DataStoreRelayAuthPermissionStore( - private val filesDir: File, + private val store: DataStore, ) : RelayAuthPermissionStore { - constructor(context: Context) : this(context.applicationContext.filesDir) - - // DataStore v1 throws if two instances are ever active on the same file. loadAccount can build - // this store more than once for the same account (re-login, cache races), so the underlying - // DataStore is shared per absolute file path across the process instead of created per instance. - private val store: DataStore get() = dataStoreFor(File(filesDir, "datastore/relay_auth.preferences_pb")) - override suspend fun loadDecision(relayUrl: String): RelayAuthDecision? { val raw = store.data.first()[decisionKey(relayUrl)] ?: return null return runCatching { RelayAuthDecision.valueOf(raw) }.getOrNull() @@ -198,14 +193,7 @@ class DataStoreRelayAuthPermissionStore( private fun lastUsedKey(relayUrl: String) = stringPreferencesKey("$LAST_USED_PREFIX${hash(relayUrl)}") companion object { - // One DataStore per file path, process-wide. computeIfAbsent runs the factory at most once - // per path, so concurrent constructions for the same account share a single active DataStore. - private val stores = ConcurrentHashMap>() - - private fun dataStoreFor(file: File): DataStore = - stores.computeIfAbsent(file.absolutePath) { - PreferenceDataStoreFactory.create(produceFile = { file }) - } + const val FILE_NAME = "relay_auth" private const val DECISION_PREFIX = "allow:" private const val URL_PREFIX = "url:" @@ -220,9 +208,15 @@ class DataStoreRelayAuthPermissionStore( /** Minimum seconds between last-used refreshes when no new counterparty appears. */ private const val LAST_USED_REFRESH_SECS = 300L - private fun hash(relayUrl: String): String { - val digest = MessageDigest.getInstance("SHA-256").digest(relayUrl.toByteArray()) - return digest.take(8).joinToString("") { "%02x".format(it) } - } + /** + * The first 8 bytes of the URL's SHA-256, lower-case hex. + * + * A stored key, so it has to keep producing exactly what + * `MessageDigest.getInstance("SHA-256")` plus `"%02x".format(byte)` did + * on Android: a different digest silently drops every decision the user + * has made rather than failing. Pinned in + * DataStoreRelayAuthPermissionStoreTest. + */ + internal fun hash(relayUrl: String): String = sha256(relayUrl.encodeToByteArray()).copyOfRange(0, 8).toHexKey() } } diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/DataStoreRelayAuthPermissionStoreTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relayauth/DataStoreRelayAuthPermissionStoreTest.kt similarity index 83% rename from amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/DataStoreRelayAuthPermissionStoreTest.kt rename to commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relayauth/DataStoreRelayAuthPermissionStoreTest.kt index a67cc661a2..9cdb366b08 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/DataStoreRelayAuthPermissionStoreTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relayauth/DataStoreRelayAuthPermissionStoreTest.kt @@ -18,11 +18,11 @@ * 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.relayClient.authCommand.model +package com.vitorpamplona.amethyst.commons.relayauth -import com.vitorpamplona.amethyst.commons.relayauth.AuthPurposeKind -import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision +import com.vitorpamplona.amethyst.commons.model.preferences.AppPreferenceStores import kotlinx.coroutines.runBlocking +import okio.Path.Companion.toOkioPath import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull @@ -33,14 +33,31 @@ import org.junit.rules.TemporaryFolder /** * Round-trip tests for the DataStore-backed relay-auth permission store. Backed by a real - * PreferenceDataStore on a per-test temp directory (the store takes a plain filesDir), so it runs - * on the JVM without Robolectric. A fresh directory per test dodges DataStore's per-file - * single-instance guard. + * PreferenceDataStore on a per-test temp directory, so it runs on the JVM without Robolectric. + * A fresh directory per test dodges DataStore's per-file single-instance guard. */ class DataStoreRelayAuthPermissionStoreTest { @get:Rule val tmp = TemporaryFolder() - private fun newStore() = DataStoreRelayAuthPermissionStore(tmp.newFolder()) + private fun newStore() = + DataStoreRelayAuthPermissionStore( + AppPreferenceStores(rootFilesDir = { tmp.newFolder().toOkioPath() }) + .getDataStore(DataStoreRelayAuthPermissionStore.FILE_NAME), + ) + + /** + * The relay-URL hash is a stored key, so it must keep producing exactly what + * `MessageDigest.getInstance("SHA-256")` truncated to 8 bytes and formatted + * with `"%02x"` produced on Android. A different digest would not throw — it + * would silently drop every ALLOW/DENY the user has ever set. Expected values + * are SHA-256 prefixes computed outside this codebase. + */ + @Test + fun theRelayHashMatchesTheAndroidImplementation() { + assertEquals("88b21471340e72df", DataStoreRelayAuthPermissionStore.hash("wss://auth.relay.test")) + assertEquals("2535089fcc9a2cf1", DataStoreRelayAuthPermissionStore.hash("wss://other.relay.test")) + assertEquals("e3b0c44298fc1c14", DataStoreRelayAuthPermissionStore.hash("")) + } private val relay = "wss://auth.relay.test" private val other = "wss://other.relay.test" From e1fa25ca72528d74c4889b5db1bed8e525a0d15f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 14:52:25 +0000 Subject: [PATCH 26/50] refactor: route the roster and Cashu counter stores through AppPreferenceStores MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neither needed a Context — both already built a DataStore directly on a path under `filesDir/datastore`, which is the same directory the holder owns. Building one by hand there is how a second live instance on a file happens, so they now ask the holder for it. The Cashu store is one file per account, so its `cashu_prefs_` copy could not stay attached to the store: DataStore runs a file's migrations when that file is first opened, and the holder is what opens it. AppModules selects the migration by file name instead — shared_settings takes the UI copy, `cashu_` takes that account's counter copy. That makes per-name selection load-bearing, so AppPreferenceStoresTest now pins it: the name reaches the chooser, two accounts get their own migration, and a file with no migration is left alone. A holder that ignored the name would copy one account's NUT-13 counters into another's, and a counter that moves backwards makes the mint reply `outputs already signed` — real ecash, stranded. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AXvKXakvup4inNFfAhhr4L --- .../vitorpamplona/amethyst/AccountRoster.kt | 14 ++++---- .../com/vitorpamplona/amethyst/AppModules.kt | 8 +++-- .../model/nip60Cashu/CashuPreferences.kt | 26 ++++++++------ .../preferences/AppPreferenceStoresTest.kt | 36 +++++++++++++++++++ 4 files changed, 63 insertions(+), 21 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AccountRoster.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AccountRoster.kt index e0a79dcd66..012bd535b9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AccountRoster.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AccountRoster.kt @@ -20,12 +20,9 @@ */ package com.vitorpamplona.amethyst -import androidx.datastore.preferences.core.PreferenceDataStoreFactory import com.vitorpamplona.amethyst.commons.model.preferences.AccountRosterStore import com.vitorpamplona.amethyst.commons.model.preferences.SecretEncryption import com.vitorpamplona.quartz.utils.Log -import okio.Path.Companion.toOkioPath -import java.io.File /** * The slice of the roster store this needs. @@ -169,17 +166,18 @@ class AccountRoster( } val accountRoster: AccountRoster by lazy { - val context = Amethyst.instance.appContext AccountRoster( EncryptedRosterStorage( AccountRosterStore( - PreferenceDataStoreFactory.createWithPath( - scope = Amethyst.instance.applicationIOScope, - produceFile = { File(context.filesDir, "datastore/roster.preferences_pb").toOkioPath() }, - ), + // Through the holder rather than a DataStore built here: it is the + // one registry that knows which files already have a live store, + // and `roster` sits in the same directory as every other one. + Amethyst.instance.appStores.getDataStore(ROSTER_FILE_NAME), SecretEncryption(), Amethyst.instance.applicationIOScope, ), ), ) } + +private const val ROSTER_FILE_NAME = "roster" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 7f388ce450..4ef11c6fbd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -71,6 +71,7 @@ import com.vitorpamplona.amethyst.commons.tor.TorSettings import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState import com.vitorpamplona.amethyst.model.nip11RelayInfo.Nip11CachedRetriever +import com.vitorpamplona.amethyst.model.nip60Cashu.CashuPreferences import com.vitorpamplona.amethyst.model.preferences.DrawerSectionCollapsePreferences import com.vitorpamplona.amethyst.model.preferences.UiSharedPreferences import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilder @@ -251,8 +252,11 @@ class AppModules( AppPreferenceStores( rootFilesDir = { appContext.filesDir.toOkioPath() }, migrations = { name -> - when (name) { - AppPreferenceStores.SHARED_SETTINGS -> UiSettingsStore.migrations { LocalPreferences.loadSharedSettings() } + when { + name == AppPreferenceStores.SHARED_SETTINGS -> UiSettingsStore.migrations { LocalPreferences.loadSharedSettings() } + // One file per account, so the migration is per name rather than a constant. + name.startsWith(CashuPreferences.FILE_PREFIX) -> + listOf(CashuPreferences.legacyMigration(appContext, name.removePrefix(CashuPreferences.FILE_PREFIX))) else -> emptyList() } }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuPreferences.kt index 08f3b96ada..e856c3fe07 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuPreferences.kt @@ -21,15 +21,12 @@ package com.vitorpamplona.amethyst.model.nip60Cashu import android.content.Context -import androidx.datastore.preferences.core.PreferenceDataStoreFactory import androidx.datastore.preferences.core.longPreferencesKey import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.cashu.CashuKeysetCounterStore import com.vitorpamplona.amethyst.commons.cashu.DataStoreCashuCounterStore import com.vitorpamplona.amethyst.commons.model.preferences.CopyOnceMigration import com.vitorpamplona.quartz.utils.cache.LargeCache -import okio.Path.Companion.toOkioPath -import java.io.File /** * Android's per-account NUT-13 counter store: the shared @@ -51,6 +48,11 @@ import java.io.File object CashuPreferences { private const val LEGACY_FILE_PREFIX = "cashu_prefs_" + /** The store file name for [npub], as AppPreferenceStores takes it. */ + const val FILE_PREFIX = "cashu_" + + fun fileName(npub: String) = FILE_PREFIX + npub + private val stores = LargeCache() /** @@ -60,16 +62,18 @@ object CashuPreferences { */ fun forAccount(npub: String): CashuKeysetCounterStore = stores.getOrCreate(npub) { - val context = Amethyst.instance.appContext - DataStoreCashuCounterStore( - PreferenceDataStoreFactory.createWithPath( - migrations = listOf(legacyMigration(context, npub)), - produceFile = { File(context.filesDir, "datastore/cashu_$npub.preferences_pb").toOkioPath() }, - ), - ) + DataStoreCashuCounterStore(Amethyst.instance.appStores.getDataStore(fileName(npub))) } - private fun legacyMigration( + /** + * The copy out of `cashu_prefs_`, wired to the file by AppModules + * rather than attached here. + * + * DataStore runs a file's migrations when that file is first opened, and + * the holder is what opens it, so the migration has to be registered with + * the holder or it would never run. + */ + fun legacyMigration( context: Context, npub: String, ) = CopyOnceMigration("migrated.cashuCounters") { out -> diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AppPreferenceStoresTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AppPreferenceStoresTest.kt index 3c8106602f..a71c28cd60 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AppPreferenceStoresTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AppPreferenceStoresTest.kt @@ -26,6 +26,7 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest import okio.Path.Companion.toOkioPath import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Assert.assertSame import org.junit.Assert.assertTrue import org.junit.Rule @@ -85,4 +86,39 @@ class AppPreferenceStoresTest { val expected = java.io.File(folder.root, "datastore/shared_settings.preferences_pb") assertTrue(expected.absolutePath, expected.exists()) } + + /** + * Migrations are chosen per file name, and the name reaches the chooser. + * + * Both users of this depend on it: shared_settings takes the UI copy, and + * the Cashu counters take a different migration per account because they + * are one file per npub. A holder that ignored the name, or applied one + * file's migration to another, would copy an account's counters into + * someone else's — and a counter that moves backwards costs real ecash. + */ + @Test + fun migrationsAreChosenPerFileNameAndTheNameIsPassedThrough() = + runTest { + val asked = mutableListOf() + val marker = stringPreferencesKey("from") + + val subject = + AppPreferenceStores( + rootFilesDir = { folder.root.toOkioPath() }, + migrations = { name -> + asked += name + if (name.startsWith("cashu_")) { + listOf(CopyOnceMigration("migrated.$name") { out -> out[marker] = name }) + } else { + emptyList() + } + }, + ) + + assertEquals("cashu_npubA", subject.getDataStore("cashu_npubA").data.first()[marker]) + assertEquals("cashu_npubB", subject.getDataStore("cashu_npubB").data.first()[marker]) + assertNull("a file with no migration must stay untouched", subject.sharedSettings().data.first()[marker]) + + assertTrue("cashu_npubA" in asked && "cashu_npubB" in asked && "shared_settings" in asked) + } } From deed14258668494db61dbd5251a7abeb3a88981a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 15:06:31 +0000 Subject: [PATCH 27/50] refactor: move the drawer collapse store into commons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last of the Context-bound preference stores. It already took a DataStore; what held it in amethyst was DrawerSectionId, which sat in DrawerSections.kt alongside the drawer's actual layout — `Res` strings, MaterialSymbols, android.os.Build. The enum and its name codec move to commons/model/navigation, next to NavBarItem, which the codec's own KDoc already said it mirrors. The layout stays where it is; only the 32 lines that were pure data moved. Its 9 tests move to commons with it. Also adds the `kotlinx.coroutines.IO` import the signer permission store needed. The JVM compile does not need it — Dispatchers.IO is a JVM property — so this only surfaced in compileCommonMainKotlinMetadata, which is the check that speaks for iOS. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AXvKXakvup4inNFfAhhr4L --- .../com/vitorpamplona/amethyst/AppModules.kt | 2 +- .../ui/navigation/drawer/DrawerContent.kt | 1 + .../ui/navigation/drawer/DrawerSections.kt | 34 +----------- .../loggedIn/settings/DrawerSettingsScreen.kt | 2 +- .../navigation/DrawerItemVisibilityTest.kt | 2 +- .../amethyst/navigation/DrawerSectionsTest.kt | 6 +-- .../DataStoreNostrSignerPermissionStore.kt | 1 + .../model/navigation/DrawerSectionId.kt | 54 +++++++++++++++++++ .../DrawerSectionCollapsePreferences.kt | 8 +-- .../DrawerSectionCollapsePreferencesTest.kt | 4 +- 10 files changed, 69 insertions(+), 45 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/navigation/DrawerSectionId.kt rename {amethyst/src/main/java/com/vitorpamplona/amethyst => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons}/model/preferences/DrawerSectionCollapsePreferences.kt (94%) rename {amethyst/src/test/java/com/vitorpamplona/amethyst => commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons}/model/preferences/DrawerSectionCollapsePreferencesTest.kt (97%) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 4ef11c6fbd..4b9342dc5f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -39,6 +39,7 @@ import com.vitorpamplona.amethyst.commons.model.preferences.AppPreferenceStores import com.vitorpamplona.amethyst.commons.model.preferences.BuzzAttestationStore import com.vitorpamplona.amethyst.commons.model.preferences.BuzzChannelStarStore import com.vitorpamplona.amethyst.commons.model.preferences.BuzzWorkspaceStore +import com.vitorpamplona.amethyst.commons.model.preferences.DrawerSectionCollapsePreferences import com.vitorpamplona.amethyst.commons.model.preferences.NamecoinSettingsStore import com.vitorpamplona.amethyst.commons.model.preferences.OtsSettingsStore import com.vitorpamplona.amethyst.commons.model.preferences.RelayGroupDeletionStore @@ -72,7 +73,6 @@ import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState import com.vitorpamplona.amethyst.model.nip11RelayInfo.Nip11CachedRetriever import com.vitorpamplona.amethyst.model.nip60Cashu.CashuPreferences -import com.vitorpamplona.amethyst.model.preferences.DrawerSectionCollapsePreferences import com.vitorpamplona.amethyst.model.preferences.UiSharedPreferences import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilder import com.vitorpamplona.amethyst.model.torState.AccountsTorStateConnector diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt index 77cf8b53a3..a48060d964 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt @@ -95,6 +95,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.model.navigation.DrawerSectionId import com.vitorpamplona.amethyst.commons.model.navigation.NavBarItem import com.vitorpamplona.amethyst.commons.model.navigation.Route import com.vitorpamplona.amethyst.commons.resources.Res diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerSections.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerSections.kt index 378e698010..34f372180d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerSections.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerSections.kt @@ -25,8 +25,8 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.navigation.DrawerItemVisibility +import com.vitorpamplona.amethyst.commons.model.navigation.DrawerSectionId import com.vitorpamplona.amethyst.commons.model.navigation.NavBarItem -import com.vitorpamplona.amethyst.commons.model.navigation.navBarItemsFromNames import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.drawer_section_create import com.vitorpamplona.amethyst.commons.resources.drawer_section_feeds @@ -69,38 +69,6 @@ data class DrawerSection( * copied — a `DrawerSections.map { it.copy(...) }` would silently defeat an `===` check, with no * compile error and nothing to fail a test. */ -enum class DrawerSectionId { - YOU, - NAVIGATE, - FEEDS, - - /** Composer entry points. Carries no catalog destinations, so nothing in it is configurable. */ - CREATE, - - /** Also renders the relay-status row, which isn't a catalog destination (it shows a live counter). */ - SYSTEM, -} - -private val DrawerSectionIdsByName = DrawerSectionId.entries.associateBy { it.name } - -/** - * Parses the persisted names of the headings the user has collapsed, silently dropping any this - * build doesn't know. Mirrors [com.vitorpamplona.amethyst.commons.model.navigation.navBarItemsFromNames]: - * names rather than ordinals, so reordering this enum renames nothing by accident, and a value left - * by a build with one more section costs that heading rather than the whole read. - * - * The stored set holds the **collapsed** headings rather than the expanded ones, for the same reason - * [DrawerItemVisibility] stores the hidden rows: a heading nobody has ever collapsed simply isn't in - * the set, so a section added in a later release opens expanded for everyone with no migration. - */ -fun drawerSectionIdsFromNames(names: Collection): Set = names.mapNotNullTo(mutableSetOf()) { DrawerSectionIdsByName[it] } - -/** - * The inverse of [drawerSectionIdsFromNames]. Unlike the NavBarItem codec this returns an unsorted - * Set rather than a sorted List: the destination is a DataStore string set, whose equality is - * already order-independent, so there is no serialized form to keep deterministic. - */ -fun Set.toNames(): Set = mapTo(mutableSetOf()) { it.name } private val DrawerNavigateItems: List = listOf( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/DrawerSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/DrawerSettingsScreen.kt index 10e0797d53..51d376c0bc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/DrawerSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/DrawerSettingsScreen.kt @@ -45,6 +45,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.navigation.DrawerSectionId import com.vitorpamplona.amethyst.commons.model.navigation.MandatoryDrawerItems import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.drawer_settings @@ -65,7 +66,6 @@ import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonRow import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarCatalog import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerSection -import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerSectionId import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerSectionVisibility import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerSections import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/navigation/DrawerItemVisibilityTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/navigation/DrawerItemVisibilityTest.kt index 693f3c0d26..11ecb7f0f8 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/navigation/DrawerItemVisibilityTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/navigation/DrawerItemVisibilityTest.kt @@ -21,8 +21,8 @@ package com.vitorpamplona.amethyst.navigation import com.vitorpamplona.amethyst.commons.model.navigation.DrawerItemVisibility +import com.vitorpamplona.amethyst.commons.model.navigation.DrawerSectionId import com.vitorpamplona.amethyst.commons.model.navigation.NavBarItem -import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerSectionId import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerSectionVisibility import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerSections import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.DrawerSettingsState diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/navigation/DrawerSectionsTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/navigation/DrawerSectionsTest.kt index b61e2d5e1f..49b0ed57b0 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/navigation/DrawerSectionsTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/navigation/DrawerSectionsTest.kt @@ -20,13 +20,13 @@ */ package com.vitorpamplona.amethyst.navigation +import com.vitorpamplona.amethyst.commons.model.navigation.DrawerSectionId import com.vitorpamplona.amethyst.commons.model.navigation.MandatoryDrawerItems +import com.vitorpamplona.amethyst.commons.model.navigation.drawerSectionIdsFromNames +import com.vitorpamplona.amethyst.commons.model.navigation.toNames import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarCatalog -import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerSectionId import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerSections import com.vitorpamplona.amethyst.ui.navigation.drawer.SdkGatedDrawerItems -import com.vitorpamplona.amethyst.ui.navigation.drawer.drawerSectionIdsFromNames -import com.vitorpamplona.amethyst.ui.navigation.drawer.toNames import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/DataStoreNostrSignerPermissionStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/DataStoreNostrSignerPermissionStore.kt index 60bf4f7ffe..0efbba4b5e 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/DataStoreNostrSignerPermissionStore.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/DataStoreNostrSignerPermissionStore.kt @@ -32,6 +32,7 @@ import com.vitorpamplona.amethyst.commons.model.preferences.AppPreferenceStores import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.utils.sha256.sha256 import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.flow.first import kotlinx.coroutines.withContext diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/navigation/DrawerSectionId.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/navigation/DrawerSectionId.kt new file mode 100644 index 0000000000..cdb41678a5 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/navigation/DrawerSectionId.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.navigation + +enum class DrawerSectionId { + YOU, + NAVIGATE, + FEEDS, + + /** Composer entry points. Carries no catalog destinations, so nothing in it is configurable. */ + CREATE, + + /** Also renders the relay-status row, which isn't a catalog destination (it shows a live counter). */ + SYSTEM, +} + +private val DrawerSectionIdsByName = DrawerSectionId.entries.associateBy { it.name } + +/** + * Parses the persisted names of the headings the user has collapsed, silently dropping any this + * build doesn't know. Mirrors [com.vitorpamplona.amethyst.commons.model.navigation.navBarItemsFromNames]: + * names rather than ordinals, so reordering this enum renames nothing by accident, and a value left + * by a build with one more section costs that heading rather than the whole read. + * + * The stored set holds the **collapsed** headings rather than the expanded ones, for the same reason + * [DrawerItemVisibility] stores the hidden rows: a heading nobody has ever collapsed simply isn't in + * the set, so a section added in a later release opens expanded for everyone with no migration. + */ +fun drawerSectionIdsFromNames(names: Collection): Set = names.mapNotNullTo(mutableSetOf()) { DrawerSectionIdsByName[it] } + +/** + * The inverse of [drawerSectionIdsFromNames]. Unlike the NavBarItem codec this returns an unsorted + * Set rather than a sorted List: the destination is a DataStore string set, whose equality is + * already order-independent, so there is no serialized form to keep deterministic. + */ +fun Set.toNames(): Set = mapTo(mutableSetOf()) { it.name } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/DrawerSectionCollapsePreferences.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/DrawerSectionCollapsePreferences.kt similarity index 94% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/DrawerSectionCollapsePreferences.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/DrawerSectionCollapsePreferences.kt index c29ae2cf13..6e7f5fd745 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/DrawerSectionCollapsePreferences.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/DrawerSectionCollapsePreferences.kt @@ -18,16 +18,16 @@ * 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.model.preferences +package com.vitorpamplona.amethyst.commons.model.preferences import androidx.compose.runtime.Stable import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringSetPreferencesKey -import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerSectionId -import com.vitorpamplona.amethyst.ui.navigation.drawer.drawerSectionIdsFromNames -import com.vitorpamplona.amethyst.ui.navigation.drawer.toNames +import com.vitorpamplona.amethyst.commons.model.navigation.DrawerSectionId +import com.vitorpamplona.amethyst.commons.model.navigation.drawerSectionIdsFromNames +import com.vitorpamplona.amethyst.commons.model.navigation.toNames import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/preferences/DrawerSectionCollapsePreferencesTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/DrawerSectionCollapsePreferencesTest.kt similarity index 97% rename from amethyst/src/test/java/com/vitorpamplona/amethyst/model/preferences/DrawerSectionCollapsePreferencesTest.kt rename to commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/DrawerSectionCollapsePreferencesTest.kt index d7c4aa0d4f..d453b47ee7 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/preferences/DrawerSectionCollapsePreferencesTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/DrawerSectionCollapsePreferencesTest.kt @@ -18,12 +18,12 @@ * 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.model.preferences +package com.vitorpamplona.amethyst.commons.model.preferences import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.PreferenceDataStoreFactory import androidx.datastore.preferences.core.Preferences -import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerSectionId +import com.vitorpamplona.amethyst.commons.model.navigation.DrawerSectionId import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.cancel From cdb4f1f76e32c194309b8fc2b26e001885bfcc01 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 15:39:35 +0000 Subject: [PATCH 28/50] ci: cap Gradle memory on the Android job; add AppPreferenceStores.release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two small things. **The Android job's memory.** gradle.properties asks for -Xmx6g (Gradle) plus -Xmx8g and 2g metaspace (Kotlin daemon). That is roughly 16GB of ceiling on a 16GB ubuntu-latest runner, before the launcher JVM, Lint's fork and the KSP workers are counted, and no CI job overrides it. test-and-build-android is the only job that comes near that ceiling — two lint variants, two flavours of unit tests, assembleBenchmark — and it is the only job that has died: five times, each one mid-compile or mid-lint rather than at a random moment, reported as "the runner has received a shutdown signal". That is what the Linux OOM killer taking the runner agent looks like from the outside, and it is the same configuration, on the same 16GB size, that repeatedly killed the Gradle daemon in the container this branch was developed in. Capped to 4g apiece for this job only, so local builds on bigger machines keep their headroom. 4g was verified to complete lintFdroidBenchmark and both unit-test tasks; it trades some build time for a job that finishes. This corrects an earlier guess of mine, posted on the PR, that `concurrency: cancel-in-progress` was behind these. It is not: a concurrency cancel ends with conclusion `cancelled`, and these are `failure`. The cancelled runs on main are a separate and expected effect of merging quickly. **AppPreferenceStores.release.** There was no way to let go of a store, so "write the file, reopen it, check what is on disk" was impossible — which is why the migration-guard test had to be driven against the DataMigration directly rather than through a real reopen. Mirrors AccountPreferenceStores.removeAccount, including the join: cancel() only asks, and DataStore's registry entry survives until the owning job actually completes. That detail produced "there are multiple DataStores active for the same file" twice in this codebase already. Production has no reason to call it; these stores live as long as the process. Two tests now use it, and the second is the one that was missing: a migration runs when the file is first opened and does NOT run again when a later instance opens the same file — the copy-once guarantee the Cashu counter and UI settings copies both rest on, checked the way it actually happens at runtime. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AXvKXakvup4inNFfAhhr4L --- .github/workflows/build.yml | 16 +++++ .../model/preferences/AppPreferenceStores.kt | 29 ++++++++++ .../preferences/AppPreferenceStoresTest.kt | 58 +++++++++++++++++++ 3 files changed, 103 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 17a402ae78..faf7452235 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -380,9 +380,25 @@ jobs: # variants are compile-equivalent for unit-test purposes; running all six # adds ~5× the kotlinc work without catching new defects on PRs. Push to # main still gets the full test matrix via the production-build path. + # Memory caps, CI-only. gradle.properties asks for -Xmx6g (Gradle) plus + # -Xmx8g and 2g metaspace (Kotlin daemon), which is ~16GB of ceiling on a + # 16GB ubuntu-latest runner before the launcher JVM, Lint's fork and the + # KSP workers are counted. Every other job stays well under it; this one + # runs two lint variants, two flavours of unit tests and + # assembleBenchmark, and it is the only job that has died — five times, + # each mid-compile or mid-lint, reported as "the runner has received a + # shutdown signal", which is what the Linux OOM killer taking the runner + # agent looks like from the outside. + # + # Overridden here rather than in gradle.properties so local builds on + # bigger machines keep the headroom. 4g apiece was verified to complete + # lintFdroidBenchmark and both unit-test tasks; it trades some build time + # for a job that finishes. - name: Test + Build Android (gradle) run: | ./gradlew \ + -Dorg.gradle.jvmargs="-Xmx4g -Dfile.encoding=UTF-8" \ + -Dkotlin.daemon.jvmargs="-Xmx4g -XX:MaxMetaspaceSize=1g" \ :amethyst:lintFdroidBenchmark \ :amethyst:lintPlayBenchmark \ :quartz:jvmTest \ diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AppPreferenceStores.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AppPreferenceStores.kt index edf30e73e8..a188cb3719 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AppPreferenceStores.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AppPreferenceStores.kt @@ -30,6 +30,8 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.job import okio.Path /** @@ -117,6 +119,33 @@ class AppPreferenceStores( /** The file UI, Tor, OTS, Namecoin and friends share. */ fun sharedSettings(): DataStore = getDataStore(SHARED_SETTINGS) + /** + * Releases the store for [name], so the file can be opened again. + * + * DataStore keeps a process-wide registry keyed by path and refuses a second + * live instance, and `cancel()` only *asks* a scope to stop — the registry + * entry survives until the owning job actually completes, which is why this + * joins. Getting that wrong produced "there are multiple DataStores active + * for the same file" twice in this codebase already. + * + * Production has no reason to call this: these stores live as long as the + * process. It exists so a test can write a file, let go of it, and reopen it + * to check what is actually on disk — the one thing that was impossible + * before, and the reason the migration-guard test had to be driven against + * the DataMigration directly instead. + * + * Returns false if nothing was open under that name. + */ + suspend fun release(name: String): Boolean { + val entry = storeCache.get(name) ?: return false + + entry.scope.cancel() + entry.scope.coroutineContext.job + .join() + storeCache.remove(name) + return true + } + /** * The names of stores already on disk whose name starts with [prefix]. * diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AppPreferenceStoresTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AppPreferenceStoresTest.kt index a71c28cd60..59dfcefb7a 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AppPreferenceStoresTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AppPreferenceStoresTest.kt @@ -26,6 +26,7 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest import okio.Path.Companion.toOkioPath import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Assert.assertSame import org.junit.Assert.assertTrue @@ -121,4 +122,61 @@ class AppPreferenceStoresTest { assertTrue("cashu_npubA" in asked && "cashu_npubB" in asked && "shared_settings" in asked) } + + /** + * Releasing a store lets the same file be opened again. + * + * DataStore's registry is keyed by path and `cancel()` only asks, so this is + * only safe because [AppPreferenceStores.release] joins the scope's job. + * Without the join this test is exactly the "multiple DataStores active for + * the same file" crash. + */ + @Test + fun aReleasedStoreCanBeReopenedAndStillHasItsData() = + runTest { + val key = stringPreferencesKey("k") + val subject = stores() + + subject.getDataStore("reopen").edit { it[key] = "written once" } + + assertTrue("something was open", subject.release("reopen")) + assertFalse("and now nothing is", subject.release("reopen")) + + // a genuinely new instance over the same file + val reopened = subject.getDataStore("reopen") + assertEquals("written once", reopened.data.first()[key]) + } + + /** + * The guard the Cashu and UI copies both rest on, now checked the way it + * actually happens at runtime: the migration runs when the file is first + * opened, and must not run again when a later instance opens the same file. + */ + @Test + fun aMigrationRunsOnceEvenAcrossAReopen() = + runTest { + val marker = stringPreferencesKey("copied") + var runs = 0 + + val subject = + AppPreferenceStores( + rootFilesDir = { folder.root.toOkioPath() }, + migrations = { + listOf( + CopyOnceMigration("migrated.once") { out -> + runs++ + out[marker] = "run $runs" + }, + ) + }, + ) + + assertEquals("run 1", subject.getDataStore("once").data.first()[marker]) + assertEquals(1, runs) + + subject.release("once") + + assertEquals("still the first copy", "run 1", subject.getDataStore("once").data.first()[marker]) + assertEquals("the migration must not run a second time", 1, runs) + } } From c47b9e50a8cea7b9fbfaaf198f68c9882dd0d57e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 15:54:28 +0000 Subject: [PATCH 29/50] fix: join cancelled scopes in the store tests before reopening the file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `:commons:jvmTest` failed on CI with IllegalStateException from DataStore's FileStorage — "multiple DataStores active for the same file" — in TopNavFollowListStoreTest.migrationDoesNotClobberLaterEdits. It has passed locally every time, including under this branch's own verification. It is a race, not a change in behaviour. The test cancels the scope owning one DataStore and immediately opens another on the same file, and `cancel()` only *asks*: DataStore's registry keeps the entry until the owning job actually completes. On an idle machine the cancellation wins; on a loaded runner it does not. The same shape was in two more tests that also reopen a file right after cancelling — EncryptedDataStoreTest and DrawerSectionCollapsePreferences- Test — so all three are fixed rather than just the one that happened to fire. On a real dispatcher the fix is `job.join()`; on a test dispatcher it is `advanceUntilIdle()`, which is the same guarantee. LegacyKeyTableTest already joined. This is the third time this exact rule has cost something in this codebase: twice in production code (AccountPreferenceStores.removeAccount, and the per-account holder in AccountCacheState) and now in the tests. AppPreferenceStores.release, added in the previous commit, exists to give tests one correct way to do it. Verified by rerunning the three classes three times under saturating CPU load, which is the condition that makes the cancellation lose. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AXvKXakvup4inNFfAhhr4L --- .../preferences/DrawerSectionCollapsePreferencesTest.kt | 4 ++++ .../commons/model/preferences/EncryptedDataStoreTest.kt | 2 ++ .../commons/model/preferences/TopNavFollowListStoreTest.kt | 6 ++++++ 3 files changed, 12 insertions(+) diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/DrawerSectionCollapsePreferencesTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/DrawerSectionCollapsePreferencesTest.kt index d453b47ee7..83bfc0a5b9 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/DrawerSectionCollapsePreferencesTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/DrawerSectionCollapsePreferencesTest.kt @@ -59,7 +59,11 @@ class DrawerSectionCollapsePreferencesTest { advanceUntilIdle() prefs.taps() advanceUntilIdle() + // On the test dispatcher advanceUntilIdle is the join: cancel() only + // asks, and the next session() opens the same file, which DataStore + // refuses while the previous instance is still winding down. scope.cancel() + advanceUntilIdle() return prefs.flow.value } diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/EncryptedDataStoreTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/EncryptedDataStoreTest.kt index 97c2b6ec3a..07b4a055f4 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/EncryptedDataStoreTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/EncryptedDataStoreTest.kt @@ -27,6 +27,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel +import kotlinx.coroutines.job import kotlinx.coroutines.test.runTest import okio.Path.Companion.toOkioPath import org.junit.Assert.assertEquals @@ -158,6 +159,7 @@ class EncryptedDataStoreTest { ) subject.save(key, "a real value") scope.cancel() + scope.coroutineContext.job.join() // Truncate the store so opening it fails rather than reading empty. dataFile.writeBytes(byteArrayOf(0x01, 0x02, 0x03)) diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/TopNavFollowListStoreTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/TopNavFollowListStoreTest.kt index 1a08dc1eb7..f0fa524877 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/TopNavFollowListStoreTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/TopNavFollowListStoreTest.kt @@ -29,6 +29,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel +import kotlinx.coroutines.job import kotlinx.coroutines.test.runTest import okio.Path.Companion.toOkioPath import org.junit.Assert.assertEquals @@ -206,7 +207,12 @@ class TopNavFollowListStoreTest { ), ) } finally { + // cancel() only asks. DataStore's registry keeps the entry until + // the owning job actually completes, so without this join the + // next open of the same file races it — which is what failed on + // a loaded CI runner while passing locally every time. scope.cancel() + scope.coroutineContext.job.join() } } From d1995108233bd5b5895640cb5a1493c9495e4e5e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 17:20:39 +0000 Subject: [PATCH 30/50] style: sort the relocated Tor label imports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fallout from moving TorSettingsLabels into commons/tor during the main reconciliation. The import paths were rewritten in place, which left `commons.tor.explainerId` and `commons.tor.resourceId` sitting where the old `commons.ui.settings.*` lines had been — after the `commons.ui.*` block rather than with their own package. Spotless sorts them back. No behaviour change; the same extensions were already resolving. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AXvKXakvup4inNFfAhhr4L --- .../ui/screen/loggedIn/privacy/PrivacyOptionsScreen.kt | 4 ++-- .../com/vitorpamplona/amethyst/ui/tor/TorSettingsDialog.kt | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/privacy/PrivacyOptionsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/privacy/PrivacyOptionsScreen.kt index c607da873a..5ecce348ac 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/privacy/PrivacyOptionsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/privacy/PrivacyOptionsScreen.kt @@ -91,6 +91,8 @@ import com.vitorpamplona.amethyst.commons.resources.use_internal_tor_explainer import com.vitorpamplona.amethyst.commons.tor.TorPresetType import com.vitorpamplona.amethyst.commons.tor.TorSettingsFlow import com.vitorpamplona.amethyst.commons.tor.TorType +import com.vitorpamplona.amethyst.commons.tor.explainerId +import com.vitorpamplona.amethyst.commons.tor.resourceId import com.vitorpamplona.amethyst.commons.tor.torDefaultPreset import com.vitorpamplona.amethyst.commons.tor.torFullyPrivate import com.vitorpamplona.amethyst.commons.tor.torOnlyWhenNeededPreset @@ -101,8 +103,6 @@ import com.vitorpamplona.amethyst.commons.ui.components.TitleExplainer import com.vitorpamplona.amethyst.commons.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton -import com.vitorpamplona.amethyst.commons.tor.explainerId -import com.vitorpamplona.amethyst.commons.tor.resourceId import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonRow import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SegmentedChoiceTile diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorSettingsDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorSettingsDialog.kt index 161362b619..415d54252d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorSettingsDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorSettingsDialog.kt @@ -80,12 +80,12 @@ import com.vitorpamplona.amethyst.commons.resources.use_internal_tor_explainer import com.vitorpamplona.amethyst.commons.tor.TorPresetType import com.vitorpamplona.amethyst.commons.tor.TorSettings import com.vitorpamplona.amethyst.commons.tor.TorType +import com.vitorpamplona.amethyst.commons.tor.explainerId import com.vitorpamplona.amethyst.commons.tor.parseTorPresetType import com.vitorpamplona.amethyst.commons.tor.parseTorType +import com.vitorpamplona.amethyst.commons.tor.resourceId import com.vitorpamplona.amethyst.commons.ui.components.TitleExplainer import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.SavingTopBar -import com.vitorpamplona.amethyst.commons.tor.explainerId -import com.vitorpamplona.amethyst.commons.tor.resourceId import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.Size10dp import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn From ea459bc51e503e3a3a52a19d44c3d1b1287f4c95 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 17:22:12 +0000 Subject: [PATCH 31/50] ci: cut only the Kotlin daemon heap, not Gradle's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Correcting cdb4f1f7, which capped both to 4g and traded one OOM for another. The diagnosis was right: test-and-build-android was dying of memory. gradle.properties asks for -Xmx6g (Gradle) plus -Xmx8g and 2g metaspace (Kotlin daemon), roughly 16GB of ceiling on a 16GB runner, and that job is the only one heavy enough to reach it. Capping both did stop the runner deaths — the job ran 61 minutes and reached R8 and lint, where it used to die at 11 to 14 minutes during compile. But it then failed with java.lang.OutOfMemoryError: Java heap space in minifyPlayBenchmarkWithR8 and lintAnalyzePlayBenchmark. Both draw on the Gradle daemon's heap, and 4g is not enough for them on this app. 6g always was — they never OOMed in the five earlier runs; the system did. So the cut belongs entirely on the Kotlin daemon: 8g + 2g metaspace down to 4g + 1g, Gradle left at its 6g default. Total ceiling ~11GB instead of ~16GB, which leaves the runner headroom without starving R8. Confirmation signals stay falsifiable: if the runner dies early again, the ceiling is still too high; if R8 or lint OOM again, 6g is not enough either and the job needs splitting rather than tuning. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AXvKXakvup4inNFfAhhr4L --- .github/workflows/build.yml | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index faf7452235..1a123e7e16 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -380,24 +380,25 @@ jobs: # variants are compile-equivalent for unit-test purposes; running all six # adds ~5× the kotlinc work without catching new defects on PRs. Push to # main still gets the full test matrix via the production-build path. - # Memory caps, CI-only. gradle.properties asks for -Xmx6g (Gradle) plus - # -Xmx8g and 2g metaspace (Kotlin daemon), which is ~16GB of ceiling on a - # 16GB ubuntu-latest runner before the launcher JVM, Lint's fork and the - # KSP workers are counted. Every other job stays well under it; this one - # runs two lint variants, two flavours of unit tests and - # assembleBenchmark, and it is the only job that has died — five times, - # each mid-compile or mid-lint, reported as "the runner has received a - # shutdown signal", which is what the Linux OOM killer taking the runner - # agent looks like from the outside. + # Memory, CI-only. gradle.properties asks for -Xmx6g (Gradle) plus -Xmx8g + # and 2g metaspace (Kotlin daemon) — about 16GB of ceiling on a 16GB + # ubuntu-latest runner, before the launcher JVM, Lint's fork and the KSP + # workers. This job is the only one heavy enough to reach it, and it died + # five times mid-compile with "the runner has received a shutdown signal", + # which is the OOM killer taking the runner agent. + # + # Only the Kotlin daemon is cut. An earlier attempt capped BOTH to 4g and + # traded one OOM for another: the runner survived and the job ran to + # completion, but R8 and lintAnalyze then failed with + # "java.lang.OutOfMemoryError: Java heap space" — they draw on the Gradle + # daemon's heap, and 4g is not enough for them on this app. 6g always was, + # so it stays; 8g + 2g for kotlinc is the part that did not fit. # # Overridden here rather than in gradle.properties so local builds on - # bigger machines keep the headroom. 4g apiece was verified to complete - # lintFdroidBenchmark and both unit-test tasks; it trades some build time - # for a job that finishes. + # bigger machines keep the headroom. - name: Test + Build Android (gradle) run: | ./gradlew \ - -Dorg.gradle.jvmargs="-Xmx4g -Dfile.encoding=UTF-8" \ -Dkotlin.daemon.jvmargs="-Xmx4g -XX:MaxMetaspaceSize=1g" \ :amethyst:lintFdroidBenchmark \ :amethyst:lintPlayBenchmark \ From 2539e5103521014d7dae61205f491a7c6e3d3744 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 22:42:22 +0000 Subject: [PATCH 32/50] refactor: take the last nine DataStores off the Context delegate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every other store in the app now opens through AppPreferenceStores; these nine were still on androidx's `Context.preferencesDataStore`, which is the pattern the rest of this branch removed. They are the stores behind the napplet sandbox, the in-app browser, favorites, chess dismissals and the two calendar-reminder files. The names are unchanged, and that is the whole safety argument. The holder reproduces `filesDir/datastore/.preferences_pb`, which is exactly what the delegate resolved to, so every one of these opens the file it was already using: nothing migrates, and a rollback finds its data where it left it. A typo in one of these strings would silently orphan that store's data rather than fail, so they were copied across verbatim. The calendar migrations moved with their files. DataStore runs a file's migrations once, when that file is first opened, so they have to be attached to the file by the holder rather than by whichever caller happens to open it first — the same reason shared_settings' migration lives there. Two shapes changed rather than being mechanically translated: - The registries' `Context` receiver is gone. These stores are app-scoped now, so a receiver the body ignores would claim a dependency that is not real. The `appContext ?: return` guards stay: they mean "init() has run", which is still true and still worth keeping. - DataStoreNappletStorage and DataStoreNappletPermissionStore lose their Context secondary constructors instead of keeping an unused parameter. Both call sites pass the store directly. On the process boundary, since Amethyst.instance is deliberately unset in the `:napplet` sandbox and reaching it there throws: all four components declared `android:process=":napplet"` are napplethost.* classes from the :nappletHost module, NappletBrokerService declares no process and so runs in main, and onCreate's sandbox early-return precedes the one WebAppNetworkRegistry.init() call. All nine stores are main-process only. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AXvKXakvup4inNFfAhhr4L --- .../com/vitorpamplona/amethyst/AppModules.kt | 14 +++- .../favorites/BrowserHistoryRegistry.kt | 20 +++-- .../favorites/FavoriteAppsRegistry.kt | 24 ++++-- .../DataStoreNappletPermissionStore.kt | 7 -- .../napplet/DataStoreNappletStorage.kt | 7 -- .../amethyst/napplet/NappletBrokerService.kt | 2 +- .../napplet/NappletNetworkRegistry.kt | 20 +++-- .../amethyst/napplet/WebAppNetworkRegistry.kt | 21 ++++-- .../calendar/CalendarReminderStores.kt | 74 ++++++++++--------- .../calendar/CalendarReminderWorker.kt | 4 +- .../CalendarReminderSettingsScreen.kt | 2 +- .../loggedIn/chess/ChessDismissedGamesData.kt | 8 +- .../loggedIn/chess/ChessViewModelNew.kt | 2 +- 13 files changed, 124 insertions(+), 81 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 431f57e5c3..26697a4fcb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -79,8 +79,12 @@ import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilde import com.vitorpamplona.amethyst.model.torState.AccountsTorStateConnector import com.vitorpamplona.amethyst.model.torState.TorRelayState import com.vitorpamplona.amethyst.napplet.DataStoreNappletPermissionStore +import com.vitorpamplona.amethyst.service.calendar.CALENDAR_REMINDER_LOG_STORE +import com.vitorpamplona.amethyst.service.calendar.CALENDAR_REMINDER_SETTINGS_STORE import com.vitorpamplona.amethyst.service.calendar.CalendarReminderWorker +import com.vitorpamplona.amethyst.service.calendar.calendarReminderLogMigrations import com.vitorpamplona.amethyst.service.calendar.calendarReminderSettings +import com.vitorpamplona.amethyst.service.calendar.calendarReminderSettingsMigrations import com.vitorpamplona.amethyst.service.cast.CastRegistry import com.vitorpamplona.amethyst.service.connectivity.ConnectivityManager import com.vitorpamplona.amethyst.service.crashreports.CrashReportCache @@ -257,6 +261,8 @@ class AppModules( // One file per account, so the migration is per name rather than a constant. name.startsWith(CashuPreferences.FILE_PREFIX) -> listOf(CashuPreferences.legacyMigration(appContext, name.removePrefix(CashuPreferences.FILE_PREFIX))) + name == CALENDAR_REMINDER_SETTINGS_STORE -> calendarReminderSettingsMigrations(appContext) + name == CALENDAR_REMINDER_LOG_STORE -> calendarReminderLogMigrations(appContext) else -> emptyList() } }, @@ -855,8 +861,10 @@ class AppModules( */ val nappletAccountScope: () -> String = { sessionManager.loggedInAccount()?.pubKey ?: "" } - // Singleton stores for napplet permissions — DataStore v1 enforces one instance per file. - val nappletPermissionStore by lazy { DataStoreNappletPermissionStore(appContext, nappletAccountScope) } + // Singleton stores for napplet permissions. The holder is what enforces + // DataStore's one-instance-per-file rule now; this stays a lazy val so the + // ledger below and the broker share one object. + val nappletPermissionStore by lazy { DataStoreNappletPermissionStore(appStores.getDataStore("napplet_permissions"), nappletAccountScope) } /** * The one napplet permission ledger for the main process. Its persistent half is just the store @@ -1347,7 +1355,7 @@ class AppModules( Filter(kinds = listOf(CalendarDateSlotEvent.KIND, CalendarTimeSlotEvent.KIND)), ).conflate() .collect { - if (appContext.calendarReminderSettings().load().enabled && + if (calendarReminderSettings().load().enabled && CalendarReminderWorker.couldStillFire(CalendarReminderWorker.acceptedRsvpsInCache(), TimeUtils.now()) ) { CalendarReminderWorker.schedule(appContext) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/BrowserHistoryRegistry.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/BrowserHistoryRegistry.kt index 5564139886..e9988b23e7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/BrowserHistoryRegistry.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/BrowserHistoryRegistry.kt @@ -21,9 +21,11 @@ package com.vitorpamplona.amethyst.favorites import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey -import androidx.datastore.preferences.preferencesDataStore +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.browser.OmniboxInput import com.vitorpamplona.quartz.nip01Core.core.JsonMapper import com.vitorpamplona.quartz.utils.Log @@ -37,7 +39,15 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.serialization.Serializable -private val Context.browserHistoryDataStore by preferencesDataStore(name = "browser_history") +/** + * The browser-history file, on the app-wide holder rather than a `Context` delegate. + * Same path the delegate resolved to, so nothing migrates. + * + * Main process only: [Amethyst.instance] is deliberately unset in the + * `:napplet` sandbox. + */ +private val browserHistoryDataStore: DataStore + get() = Amethyst.instance.appStores.getDataStore("browser_history") /** * One device-local visited site, keyed by full [url]. [visitCount]/[lastVisitedAt] drive frecency ranking @@ -83,7 +93,7 @@ object BrowserHistoryRegistry { val ctx = context.applicationContext appContext = ctx scope.launch { - val json = ctx.browserHistoryDataStore.data.first()[KEY] + val json = browserHistoryDataStore.data.first()[KEY] val loaded = if (json != null) decode(json) else emptyList() // Merge disk under anything already recorded this session (session wins, newest-first). update { current -> dedupeNewestFirst(current + loaded) } @@ -136,9 +146,9 @@ object BrowserHistoryRegistry { } private fun persist(json: String) { - val ctx = appContext ?: return + appContext ?: return scope.launch { - ctx.browserHistoryDataStore.edit { it[KEY] = json } + browserHistoryDataStore.edit { it[KEY] = json } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppsRegistry.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppsRegistry.kt index 99e36cf19b..ad9d03d325 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppsRegistry.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppsRegistry.kt @@ -21,9 +21,11 @@ package com.vitorpamplona.amethyst.favorites import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey -import androidx.datastore.preferences.preferencesDataStore +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp import com.vitorpamplona.quartz.nip01Core.core.JsonMapper import com.vitorpamplona.quartz.utils.Log @@ -38,7 +40,15 @@ import kotlinx.coroutines.launch import kotlinx.serialization.Serializable import java.util.concurrent.ConcurrentHashMap -private val Context.favoriteAppsDataStore by preferencesDataStore(name = "favorite_apps") +/** + * The favorite-apps file, on the app-wide holder rather than a `Context` delegate. + * Same path the delegate resolved to, so nothing migrates. + * + * Main process only: [Amethyst.instance] is deliberately unset in the + * `:napplet` sandbox. + */ +private val favoriteAppsDataStore: DataStore + get() = Amethyst.instance.appStores.getDataStore("favorite_apps") /** * The user's device-local list of [FavoriteApp]s — the single source of truth shared by the bottom @@ -81,7 +91,7 @@ object FavoriteAppsRegistry { val ctx = context.applicationContext appContext = ctx scope.launch { - val prefs = ctx.favoriteAppsDataStore.data.first() + val prefs = favoriteAppsDataStore.data.first() val loaded = prefs[KEY]?.let { decode(it) } ?: emptyList() // Don't clobber adds made in this session before hydration finished, and don't resurrect // anything the user removed in that same window. @@ -139,16 +149,16 @@ object FavoriteAppsRegistry { } private fun persist(json: String) { - val ctx = appContext ?: return + appContext ?: return scope.launch { - ctx.favoriteAppsDataStore.edit { it[KEY] = json } + favoriteAppsDataStore.edit { it[KEY] = json } } } private fun persistManifests(json: String) { - val ctx = appContext ?: return + appContext ?: return scope.launch { - ctx.favoriteAppsDataStore.edit { it[MANIFESTS_KEY] = json } + favoriteAppsDataStore.edit { it[MANIFESTS_KEY] = json } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNappletPermissionStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNappletPermissionStore.kt index 073d458f83..7550c41334 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNappletPermissionStore.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNappletPermissionStore.kt @@ -20,19 +20,15 @@ */ package com.vitorpamplona.amethyst.napplet -import android.content.Context import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey -import androidx.datastore.preferences.preferencesDataStore import com.vitorpamplona.amethyst.commons.napplet.NappletCapability import com.vitorpamplona.amethyst.commons.napplet.permissions.GrantState import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionStore import kotlinx.coroutines.flow.first -private val Context.nappletPermissionsDataStore by preferencesDataStore(name = "napplet_permissions") - /** * Persists the standing napplet grants ([GrantState.ALLOW_ALWAYS] / [GrantState.DENY]) in a * dedicated DataStore. Keyed by `"\u0000"` so a coordinate's grants can @@ -43,9 +39,6 @@ class DataStoreNappletPermissionStore( private val dataStore: DataStore, private val accountPubKey: () -> String, ) : NappletPermissionStore { - constructor(context: Context, accountPubKey: () -> String) : - this(context.applicationContext.nappletPermissionsDataStore, accountPubKey) - /** * Grants belong to one account. [accountPubKey] is read at call time, so an account switch moves * every read and write to that account's namespace with no rebuild — a grant made by one account diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNappletStorage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNappletStorage.kt index 14d3499371..4c3141b6e4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNappletStorage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNappletStorage.kt @@ -20,17 +20,13 @@ */ package com.vitorpamplona.amethyst.napplet -import android.content.Context import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey -import androidx.datastore.preferences.preferencesDataStore import com.vitorpamplona.amethyst.commons.napplet.NappletStorage import kotlinx.coroutines.flow.first -private val Context.nappletStorageDataStore by preferencesDataStore(name = "napplet_storage") - /** * DataStore-backed [NappletStorage]. Every key is prefixed with the **active account** and then the * applet's coordinate, so one napplet's keys can never collide with another's, one account's data is @@ -44,9 +40,6 @@ class DataStoreNappletStorage( private val dataStore: DataStore, private val accountPubKey: () -> String, ) : NappletStorage { - constructor(context: Context, accountPubKey: () -> String) : - this(context.applicationContext.nappletStorageDataStore, accountPubKey) - override suspend fun get( coordinate: String, key: String, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt index de5815af52..7f98885be1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt @@ -90,7 +90,7 @@ class NappletBrokerService : Service() { private val signerLedger by lazy { NostrSignerPermissionLedger(Amethyst.instance.signerPermissionStore) } // Per-applet sandboxed key-value store (namespaced by account + coordinate inside the impl). - private val storage by lazy { DataStoreNappletStorage(applicationContext, Amethyst.instance.nappletAccountScope) } + private val storage by lazy { DataStoreNappletStorage(Amethyst.instance.appStores.getDataStore("napplet_storage"), Amethyst.instance.nappletAccountScope) } private val incoming by lazy { Messenger(Handler(Looper.getMainLooper(), ::handleMessage)) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletNetworkRegistry.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletNetworkRegistry.kt index 3dc4e46da8..1e95e1fb02 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletNetworkRegistry.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletNetworkRegistry.kt @@ -21,9 +21,11 @@ package com.vitorpamplona.amethyst.napplet import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey -import androidx.datastore.preferences.preferencesDataStore +import com.vitorpamplona.amethyst.Amethyst import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -32,7 +34,15 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import java.util.concurrent.ConcurrentHashMap -private val Context.nappletNetworkDataStore by preferencesDataStore(name = "napplet_network") +/** + * The per-napplet routing file, on the app-wide holder rather than a `Context` delegate. + * Same path the delegate resolved to, so nothing migrates. + * + * Main process only: [Amethyst.instance] is deliberately unset in the + * `:napplet` sandbox. + */ +private val nappletNetworkDataStore: DataStore + get() = Amethyst.instance.appStores.getDataStore("napplet_network") /** * Per-nSite network-routing preference: whether a site's traffic goes through **Tor** (the default) @@ -69,7 +79,7 @@ object NappletNetworkRegistry { appContext = ctx hydration = scope.launch { - ctx.nappletNetworkDataStore.data.first().asMap().forEach { (key, value) -> + nappletNetworkDataStore.data.first().asMap().forEach { (key, value) -> // putIfAbsent: never clobber a choice made in this session before hydration finished. modes.putIfAbsent(key.name, value != OPEN_WEB) } @@ -95,9 +105,9 @@ object NappletNetworkRegistry { useTor: Boolean, ) { modes[coordinate] = useTor - val ctx = appContext ?: return + appContext ?: return scope.launch { - ctx.nappletNetworkDataStore.edit { it[stringPreferencesKey(coordinate)] = if (useTor) TOR else OPEN_WEB } + nappletNetworkDataStore.edit { it[stringPreferencesKey(coordinate)] = if (useTor) TOR else OPEN_WEB } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/WebAppNetworkRegistry.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/WebAppNetworkRegistry.kt index 2038417d1a..a274fb036c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/WebAppNetworkRegistry.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/WebAppNetworkRegistry.kt @@ -22,9 +22,11 @@ package com.vitorpamplona.amethyst.napplet import android.content.Context import androidx.core.net.toUri +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey -import androidx.datastore.preferences.preferencesDataStore +import com.vitorpamplona.amethyst.Amethyst import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -33,7 +35,16 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import java.util.concurrent.ConcurrentHashMap -private val Context.webUrlNetworkDataStore by preferencesDataStore(name = "weburl_network") +/** + * The per-site routing file, on the app-wide holder rather than a `Context` + * delegate. Same path the delegate resolved to, so nothing migrates. + * + * Main process only: [Amethyst.instance] is deliberately unset in the + * `:napplet` sandbox, and this registry is only touched from the browser + * chrome that runs in the main process. + */ +private val webUrlNetworkDataStore: DataStore + get() = Amethyst.instance.appStores.getDataStore("weburl_network") /** * Per-web-client network-routing preference: whether a favorited URL / browsed site routes through @@ -67,7 +78,7 @@ object WebAppNetworkRegistry { appContext = ctx hydration = scope.launch { - ctx.webUrlNetworkDataStore.data.first().asMap().forEach { (key, value) -> + webUrlNetworkDataStore.data.first().asMap().forEach { (key, value) -> // putIfAbsent: never clobber a choice made in this session before hydration finished. modes.putIfAbsent(key.name, value != OPEN_WEB) } @@ -98,9 +109,9 @@ object WebAppNetworkRegistry { ) { val host = hostKeyOf(url) modes[host] = useTor - val ctx = appContext ?: return + appContext ?: return scope.launch { - ctx.webUrlNetworkDataStore.edit { it[stringPreferencesKey(host)] = if (useTor) TOR else OPEN_WEB } + webUrlNetworkDataStore.edit { it[stringPreferencesKey(host)] = if (useTor) TOR else OPEN_WEB } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderStores.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderStores.kt index e7105b31b8..e430a79bc0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderStores.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderStores.kt @@ -21,9 +21,11 @@ package com.vitorpamplona.amethyst.service.calendar import android.content.Context +import androidx.datastore.core.DataMigration +import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.intPreferencesKey -import androidx.datastore.preferences.preferencesDataStore +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.model.preferences.CalendarReminderLogStore import com.vitorpamplona.amethyst.commons.model.preferences.CalendarReminderSettings import com.vitorpamplona.amethyst.commons.model.preferences.CalendarReminderSettingsStore @@ -34,43 +36,47 @@ import com.vitorpamplona.amethyst.commons.model.preferences.CopyOnceMigration * * The store classes live in commons; only the file location and the one-off * lift out of the legacy SharedPreferences are Android's business. + * + * Both files sit on `AppPreferenceStores` rather than a `Context` delegate. + * The names below are the delegate's names, and the holder reproduces the path + * it resolved to, so nothing migrates. The migrations move with them: DataStore + * runs a file's migrations once, when that file is first opened, so they have + * to be attached to the file by the holder rather than by whoever opens it. */ private const val LEGACY_SETTINGS_FILE = "amethyst_calendar_reminder_prefs" private const val LEGACY_LOG_FILE = "amethyst_calendar_reminders" -private val Context.calendarReminderSettingsData by preferencesDataStore( - name = "calendar_reminder_settings", - produceMigrations = { context -> - listOf( - CopyOnceMigration("migrated.calendarReminderSettings") { out -> - val legacy = context.getSharedPreferences(LEGACY_SETTINGS_FILE, Context.MODE_PRIVATE) - if (legacy.contains("enabled")) { - out[booleanPreferencesKey("enabled")] = legacy.getBoolean("enabled", CalendarReminderSettings.DEFAULT_ENABLED) - } - if (legacy.contains("lead_minutes")) { - out[intPreferencesKey("lead_minutes")] = legacy.getInt("lead_minutes", CalendarReminderSettings.DEFAULT_LEAD_MINUTES) - } - }, - ) - }, -) +/** Store (and file) names, as [Amethyst.appStores] keys them. */ +const val CALENDAR_REMINDER_SETTINGS_STORE = "calendar_reminder_settings" +const val CALENDAR_REMINDER_LOG_STORE = "calendar_reminder_log" -private val Context.calendarReminderLogData by preferencesDataStore( - name = "calendar_reminder_log", - produceMigrations = { context -> - listOf( - CopyOnceMigration("migrated.calendarReminderLog") { out -> - val legacy = context.getSharedPreferences(LEGACY_LOG_FILE, Context.MODE_PRIVATE) - // Values are the event-start times the reminders fired for; anything - // else in the file is not ours and is left behind. - legacy.all.forEach { (key, value) -> - if (value is Long) out[CalendarReminderLogStore.keyFor(key.removePrefix("notified:"))] = value - } - }, - ) - }, -) +/** The one-off copy of the reminder settings out of the legacy prefs file. */ +fun calendarReminderSettingsMigrations(context: Context): List> = + listOf( + CopyOnceMigration("migrated.calendarReminderSettings") { out -> + val legacy = context.getSharedPreferences(LEGACY_SETTINGS_FILE, Context.MODE_PRIVATE) + if (legacy.contains("enabled")) { + out[booleanPreferencesKey("enabled")] = legacy.getBoolean("enabled", CalendarReminderSettings.DEFAULT_ENABLED) + } + if (legacy.contains("lead_minutes")) { + out[intPreferencesKey("lead_minutes")] = legacy.getInt("lead_minutes", CalendarReminderSettings.DEFAULT_LEAD_MINUTES) + } + }, + ) -fun Context.calendarReminderSettings() = CalendarReminderSettingsStore(calendarReminderSettingsData) +/** The one-off copy of the fired-reminder log out of the legacy prefs file. */ +fun calendarReminderLogMigrations(context: Context): List> = + listOf( + CopyOnceMigration("migrated.calendarReminderLog") { out -> + val legacy = context.getSharedPreferences(LEGACY_LOG_FILE, Context.MODE_PRIVATE) + // Values are the event-start times the reminders fired for; anything + // else in the file is not ours and is left behind. + legacy.all.forEach { (key, value) -> + if (value is Long) out[CalendarReminderLogStore.keyFor(key.removePrefix("notified:"))] = value + } + }, + ) -fun Context.calendarReminderLog() = CalendarReminderLogStore(calendarReminderLogData) +fun calendarReminderSettings() = CalendarReminderSettingsStore(Amethyst.instance.appStores.getDataStore(CALENDAR_REMINDER_SETTINGS_STORE)) + +fun calendarReminderLog() = CalendarReminderLogStore(Amethyst.instance.appStores.getDataStore(CALENDAR_REMINDER_LOG_STORE)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderWorker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderWorker.kt index 40e1134f8d..b3f34ae3ce 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderWorker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderWorker.kt @@ -65,7 +65,7 @@ class CalendarReminderWorker( ) : CoroutineWorker(appContext, params) { override suspend fun doWork(): Result { runCatching { Amethyst.instance.resourceUsage.add(UsageKeys.workerRuns("calendarReminder"), 1) } - val settings = applicationContext.calendarReminderSettings().load() + val settings = calendarReminderSettings().load() if (!settings.enabled) { Log.d(TAG) { "Reminders disabled; ending periodic chain." } // The settings toggle re-schedules on enable; no reason to keep @@ -75,7 +75,7 @@ class CalendarReminderWorker( } val now = TimeUtils.now() val windowEnd = now + settings.leadMinutes * 60L - val store = applicationContext.calendarReminderLog() + val store = calendarReminderLog() // Walk every kind-31925 RSVP authored by an account on this device. We don't have a // multi-account "all logged-in pubkeys" view here, so we accept any RSVP that's diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarReminderSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarReminderSettingsScreen.kt index ce1888a33d..1115110ab4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarReminderSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarReminderSettingsScreen.kt @@ -68,7 +68,7 @@ import kotlinx.coroutines.launch fun CalendarReminderSettingsScreen(nav: INav) { val context = LocalContext.current val scope = rememberCoroutineScope() - val store = remember(context) { context.calendarReminderSettings() } + val store = remember { calendarReminderSettings() } // DataStore reads are suspend, so the first frame renders the defaults and // the stored values arrive right after. Collecting the flow rather than diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessDismissedGamesData.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessDismissedGamesData.kt index dc63800518..a0cba559e4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessDismissedGamesData.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessDismissedGamesData.kt @@ -20,8 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chess -import android.content.Context -import androidx.datastore.preferences.preferencesDataStore +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import com.vitorpamplona.amethyst.Amethyst /** * Where Android keeps the dismissed-chess-games store. @@ -31,4 +32,5 @@ import androidx.datastore.preferences.preferencesDataStore * data over is not worth the code. Anyone who had dismissed a game sees it once * more and dismisses it again. */ -internal val Context.chessDismissedGamesData by preferencesDataStore(name = "chess_dismissed_games_v2") +internal val chessDismissedGamesData: DataStore + get() = Amethyst.instance.appStores.getDataStore("chess_dismissed_games_v2") diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelNew.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelNew.kt index ae46d80463..9bd3819de4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelNew.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelNew.kt @@ -61,7 +61,7 @@ class ChessViewModelNew( private val publisher = AndroidChessPublisher(account) private val fetcher = AndroidRelayFetcher(account) private val metadataProvider = AndroidMetadataProvider() - private val dismissedStorage = ChessDismissedGamesStore(application.chessDismissedGamesData) + private val dismissedStorage = ChessDismissedGamesStore(chessDismissedGamesData) // Shared business logic (creates its own ChessLobbyState internally) private val logic = From 04ef41281aa38356d4f60690d679ee5d1601bfde Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 22:53:59 +0000 Subject: [PATCH 33/50] refactor: move the location-chat identity into the encrypted DataStore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GeohashChatIdentityState was the last thing in the app still reading and writing EncryptedSharedPreferences outside the four deliberate mirrors. It kept two keys — the seed its per-geohash throwaway keys derive from, and the handle the user posts under — and neither was covered by the 112-key migration, because neither is in PrefKeys: they are private constants in the state object, so LegacyKeyCoverageTest, which reflects over PrefKeys, structurally could not see them. They were also in a file nothing else uses. The writer passed `signer.pubKey`, which is hex, where every other caller passes an npub, so the identity lived in `secret_keeper_` while the account's own secrets live in `secret_keeper_`. That makes it an orphan rather than a hazard: LegacyPreferenceCleanup enumerates and deletes the npub file, so it never saw these keys, and deleting that file could not have lost them. It also means nothing will ever clean the hex file up on its own. So: read the hex file once, copy into the account's npub-keyed encrypted DataStore, prefer the new store on read, and keep mirroring the legacy write until the legacy writes are retired app-wide — the same terms as AccountSecrets. GeohashIdentitySecrets is its own group with its own migrated marker, and that is load-bearing rather than tidy. Every account save mirrors a whole AccountSecrets built field by field from AccountSettings, which does not hold these — they belong to the state object. Folded into that group, each save would write null over them, and the group save uses putOrRemove, so null removes: the seed would disappear on the next unrelated save and every geohash identity the user has would silently change. Its own marker for the same reason the group is separate — the two migrate out of different files, so neither marker can speak for the other. The keys are deliberately NOT added to LegacyAccountSecretNames.all. That set is what the cleanup gate treats as claimed in the npub file, and these never appear in it; listing them would be inert and would suggest the gate handles them. Shape changes this forced: nickname() and keyPair() are suspend (every call site was already inside withContext(Dispatchers.IO)); setNickname stays fire-and-forget on the account scope, as the SharedPreferences edit {} it replaces already was; and seed creation moved from synchronized to a Mutex, because the store reads it guards are suspending and two racers minting different seeds would strand one caller's identities. Tests: seven, covering the round trip, a seed with no handle, an empty identity still counting as migrated, the two markers staying independent in both directions, and anAccountSaveLeavesTheGeohashIdentityAlone — which pins the wipe this design exists to prevent. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AXvKXakvup4inNFfAhhr4L --- .../amethyst/AccountSecretsStore.kt | 41 ++++++ .../vitorpamplona/amethyst/model/Account.kt | 2 +- .../model/GeohashChatIdentityState.kt | 123 ++++++++++++------ .../model/preferences/AccountSecrets.kt | 65 +++++++++ .../AccountSecretsEncryptedStores.kt | 33 +++++ .../preferences/AccountSecretsStoreTest.kt | 90 +++++++++++++ 6 files changed, 311 insertions(+), 43 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AccountSecretsStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AccountSecretsStore.kt index e60aaa48cf..ee32dde855 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AccountSecretsStore.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AccountSecretsStore.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst import com.vitorpamplona.amethyst.commons.model.preferences.AccountSecrets import com.vitorpamplona.amethyst.commons.model.preferences.AccountSecretsEncryptedStores +import com.vitorpamplona.amethyst.commons.model.preferences.GeohashIdentitySecrets import com.vitorpamplona.quartz.utils.Log import okio.Path.Companion.toOkioPath @@ -98,6 +99,46 @@ class AccountSecretsStore( */ suspend fun stored(npub: String): AccountSecrets? = stores.loadSecrets(npub) + // ── the location-chat identity ──────────────────────────────────── + + /** + * The account's location-chat identity, migrating out of the legacy file on + * first use, on the same terms as [read]. + * + * @param legacy what `secret_keeper_` holds. Note the *hex*: this + * group's legacy file is keyed by the signer's pubkey rather than the npub + * every other group uses, so the caller opens a different file for it. + */ + suspend fun readGeohashIdentity( + npub: String, + legacy: GeohashIdentitySecrets, + ): GeohashIdentitySecrets { + val stored = + try { + stores.loadGeohashIdentity(npub) + } catch (e: Exception) { + Log.w(TAG, "Could not read the location-chat identity for $npub; using the legacy file", e) + return legacy + } + + if (stored != null) return stored + + mirrorGeohashIdentity(npub, legacy) + return legacy + } + + /** Mirrors a save into the new store. The legacy write stays where it is. */ + suspend fun mirrorGeohashIdentity( + npub: String, + value: GeohashIdentitySecrets, + ) { + try { + stores.saveGeohashIdentity(npub, value) + } catch (e: Exception) { + Log.w(TAG, "Could not write the location-chat identity for $npub to the current store", e) + } + } + suspend fun delete(npub: String) { try { stores.removeAccount(npub) 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 f99574df02..385a1e6389 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -759,7 +759,7 @@ class Account( val geohashList = GeohashListState(signer, cache, geohashListDecryptionCache, scope, settings) // Anonymous, per-geohash throwaway identities for Bitchat-interoperable location chats. - val geohashIdentity = GeohashChatIdentityState(signer) + val geohashIdentity = GeohashChatIdentityState(signer, scope) val muteListDecryptionCache = MuteListDecryptionCache(signer) val muteList = MuteListState(signer, cache, muteListDecryptionCache, scope, settings) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/GeohashChatIdentityState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/GeohashChatIdentityState.kt index 9517a3b62b..699fbb9f37 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/GeohashChatIdentityState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/GeohashChatIdentityState.kt @@ -22,13 +22,23 @@ package com.vitorpamplona.amethyst.model import androidx.core.content.edit import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.LegacySharedPreferences +import com.vitorpamplona.amethyst.accountSecretsStore +import com.vitorpamplona.amethyst.commons.model.preferences.GeohashIdentitySecrets +import com.vitorpamplona.amethyst.commons.model.preferences.readLegacyGeohashIdentity import com.vitorpamplona.quartz.experimental.bitchat.identity.GeohashKeyDerivation import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip19Bech32.toNpub import com.vitorpamplona.quartz.utils.RandomInstance +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.util.concurrent.ConcurrentHashMap /** * The account's anonymous, per-geohash chat identities. @@ -52,68 +62,97 @@ import com.vitorpamplona.quartz.utils.RandomInstance */ class GeohashChatIdentityState( private val signer: NostrSigner, + private val scope: CoroutineScope, ) { - private val lock = Any() - private val cache = HashMap() + /** + * Guards seed creation as well as the key cache: two callers racing into + * [deviceSeed] must not mint two different seeds, or the loser's cells get + * identities the next launch cannot reproduce. A [Mutex] rather than + * `synchronized`, because the store reads it protects are suspending. + */ + private val mutex = Mutex() + private val cache = ConcurrentHashMap() - @Volatile private var cachedDeviceSeed: ByteArray? = null + /** + * The npub the current store is keyed by. + * + * The legacy file is keyed by the pubkey *hex* — the old code passed + * `signer.pubKey` where every other caller passes an npub, so the identity + * lived in `secret_keeper_`, a different file from the account's own + * `secret_keeper_`. The copy below reads that file and writes the + * npub-keyed store, which is what folds this orphan back in with the rest. + */ + private val npub by lazy { signer.pubKey.hexToByteArray().toNpub() } - @Volatile private var cachedNickname: String? = null + @Volatile private var loaded: GeohashIdentitySecrets? = null + + /** What `secret_keeper_` holds. Touches disk; callers are off the main thread. */ + private fun legacy(): GeohashIdentitySecrets = readLegacyGeohashIdentity(LegacySharedPreferences(Amethyst.instance.encryptedStorage(signer.pubKey))) + + /** The stored identity, copying it out of the legacy file the first time. */ + private suspend fun current(): GeohashIdentitySecrets { + loaded?.let { return it } + return accountSecretsStore.readGeohashIdentity(npub, legacy()).also { loaded = it } + } + + private suspend fun persist(value: GeohashIdentitySecrets) { + loaded = value + accountSecretsStore.mirrorGeohashIdentity(npub, value) + } /** * The user's display handle for location chats: a single global nickname, persisted per account. * Bitchat carries this as the per-message `["n", …]` tag rather than a kind-0 profile, and kind-20000 * messages are ephemeral (relays needn't store them), so the only durable home for it is the device. - * Kept in this account's encrypted storage, so it survives restarts and switches with the account. - * Empty string means "no nickname set". Reads touch disk on first call — invoke off the main thread. + * Empty string means "no nickname set". */ - fun nickname(): String { - cachedNickname?.let { return it } - synchronized(lock) { - cachedNickname?.let { return it } - val value = Amethyst.instance.encryptedStorage(signer.pubKey).getString(PREF_NICKNAME, "") ?: "" - cachedNickname = value - return value - } - } + suspend fun nickname(): String = current().nickname ?: "" - /** Persists the global location-chat nickname (trimmed) for this account. */ + /** + * Persists the global location-chat nickname (trimmed) for this account. + * + * Fire-and-forget on the account scope, which is what the SharedPreferences + * `edit {}` this replaced already did — the caller is a click handler on the + * main thread and the write is not something it waits for. + */ fun setNickname(value: String) { val trimmed = value.trim() - synchronized(lock) { - cachedNickname = trimmed + scope.launch { + persist(current().copy(nickname = trimmed)) + // Mirrored, not moved: the legacy file stays readable until the + // legacy writes are retired app-wide, so a rollback keeps the handle. Amethyst.instance.encryptedStorage(signer.pubKey).edit { putString(PREF_NICKNAME, trimmed) } } } - /** The Nostr key pair to use inside [geohash]. Derivation is cheap but cached; call off the main thread. */ - fun keyPair(geohash: String): KeyPair = - synchronized(lock) { - cache.getOrPut(geohash) { GeohashKeyDerivation.deriveKeyPair(seed(), geohash) } - } + /** The Nostr key pair to use inside [geohash]. Derivation is cheap but cached. */ + suspend fun keyPair(geohash: String): KeyPair { + cache[geohash]?.let { return it } - private fun seed(): ByteArray = accountPrivKey()?.let { GeohashKeyDerivation.accountSeed(it) } ?: deviceSeed() + return mutex.withLock { + cache[geohash] ?: GeohashKeyDerivation.deriveKeyPair(seed(), geohash).also { cache[geohash] = it } + } + } + + /** Call under [mutex]. */ + private suspend fun seed(): ByteArray = accountPrivKey()?.let { GeohashKeyDerivation.accountSeed(it) } ?: deviceSeed() private fun accountPrivKey(): ByteArray? = (signer as? NostrSignerInternal)?.keyPair?.privKey - /** Random per-account seed, used only when the account key is unreachable (bunker / external signer). */ - private fun deviceSeed(): ByteArray { - cachedDeviceSeed?.let { return it } - synchronized(lock) { - cachedDeviceSeed?.let { return it } - val prefs = Amethyst.instance.encryptedStorage(signer.pubKey) - val existing = prefs.getString(PREF_KEY, null) - val seed = - if (existing != null && existing.length == GeohashKeyDerivation.SEED_SIZE * 2) { - existing.hexToByteArray() - } else { - val fresh = RandomInstance.bytes(GeohashKeyDerivation.SEED_SIZE) - prefs.edit { putString(PREF_KEY, fresh.toHexKey()) } - fresh - } - cachedDeviceSeed = seed - return seed - } + /** + * Random per-account seed, used only when the account key is unreachable (bunker / external signer). + * + * Call under [mutex]: minting a second seed for an account that already has + * one would change every throwaway identity it has ever used. + */ + private suspend fun deviceSeed(): ByteArray { + val stored = current().deviceSeed + if (stored != null && stored.length == GeohashKeyDerivation.SEED_SIZE * 2) return stored.hexToByteArray() + + val fresh = RandomInstance.bytes(GeohashKeyDerivation.SEED_SIZE) + persist(current().copy(deviceSeed = fresh.toHexKey())) + Amethyst.instance.encryptedStorage(signer.pubKey).edit { putString(PREF_KEY, fresh.toHexKey()) } + return fresh } companion object { diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountSecrets.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountSecrets.kt index 7a18508ff1..46769ead3d 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountSecrets.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountSecrets.kt @@ -102,6 +102,22 @@ object LegacyAccountSecretNames { /** The private key, which lives in its own store rather than in [AccountSecrets]. */ const val NOSTR_PRIVKEY = "nostr_privkey" + /** + * The location-chat identity, which is [GeohashIdentitySecrets] rather than + * part of [AccountSecrets] — see that class for why it is its own group. + * + * These two sit in `secret_keeper_`, not `secret_keeper_`: + * the writer passed `signer.pubKey`, which is hex, where every other caller + * passes an npub. They are therefore in a *different file* from everything + * else named here, which is why they are deliberately **not** in [all]: + * [all] is what `LegacyPreferenceCleanup` treats as claimed in the npub + * file, and these never appear in it. That file's deletion cannot lose + * them, and cannot clean them up either — retiring the hex file is its own + * job, once these writes stop. + */ + const val GEOHASH_DEVICE_SEED = "geohash_chat_device_seed" + const val GEOHASH_NICKNAME = "geohash_chat_nickname" + val all = setOf( NIP46_SIGNER_ENABLED, @@ -135,3 +151,52 @@ fun readLegacyAccountSecrets(source: LegacyPreferenceSource) = legacyDefaultNwcWalletId = source.getString(LegacyAccountSecretNames.DEFAULT_NWC_WALLET_ID), legacyZapPaymentRequestServer = source.getString(LegacyAccountSecretNames.ZAP_PAYMENT_REQUEST_SERVER), ) + +/** + * The account's location-chat identity: the seed its per-geohash throwaway keys + * come from, and the handle it posts under. + * + * # Why this is not two more fields on [AccountSecrets] + * + * Every account save mirrors a whole [AccountSecrets], built field by field from + * `AccountSettings` — which does not hold these, because they are owned by + * `GeohashChatIdentityState` rather than by the settings object. Folding them in + * would make each save write null over them, and the group save uses + * `putOrRemove`, so null *deletes*. The seed would vanish on the next unrelated + * save and every geohash identity the user has would silently change. A separate + * group with its own save path cannot be wiped by a save that does not know + * about it. + * + * # Why encrypted + * + * The whole point of the seed is that the identities derived from it are + * unlinkable to the npub. Anyone who can read it can link every cell the user + * has ever posted in, to each other and to the device, which is exactly what the + * feature exists to prevent. It was in an encrypted file before; it stays in one. + */ +data class GeohashIdentitySecrets( + val deviceSeed: String? = null, + val nickname: String? = null, +) + +/** Keys for [GeohashIdentitySecrets] inside an [EncryptedDataStore]. */ +internal object GeohashIdentityKeys { + val deviceSeed = stringPreferencesKey(LegacyAccountSecretNames.GEOHASH_DEVICE_SEED) + val nickname = stringPreferencesKey(LegacyAccountSecretNames.GEOHASH_NICKNAME) + + /** Records that the one-off copy out of the legacy file has run for this account. */ + val migrated = stringPreferencesKey("migrated.geohashIdentity") +} + +/** + * The location-chat identity as the legacy file holds it. + * + * Both absent is a real answer — an account that never opened a location chat — + * and is why the caller compares against [GeohashIdentitySecrets] rather than + * treating null as "not migrated". + */ +fun readLegacyGeohashIdentity(source: LegacyPreferenceSource) = + GeohashIdentitySecrets( + deviceSeed = source.getString(LegacyAccountSecretNames.GEOHASH_DEVICE_SEED), + nickname = source.getString(LegacyAccountSecretNames.GEOHASH_NICKNAME), + ) diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountSecretsEncryptedStores.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountSecretsEncryptedStores.kt index 082c14fd06..90d4a323e3 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountSecretsEncryptedStores.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountSecretsEncryptedStores.kt @@ -182,6 +182,39 @@ class AccountSecretsEncryptedStores( } } + // ── the location-chat identity ──────────────────────────────────── + + /** + * Reads [GeohashIdentitySecrets], or null when this account has not been + * copied out of the legacy encrypted file yet. + * + * Its own marker, not [AccountSecretKeys.migrated]: the two groups migrate + * from *different files* (this one from `secret_keeper_`, the + * secrets from `secret_keeper_`), so one marker cannot speak for both. + */ + suspend fun loadGeohashIdentity(npub: String): GeohashIdentitySecrets? { + val stored = getDataStore(npub).snapshot() + if (stored[GeohashIdentityKeys.migrated] == null) return null + + return GeohashIdentitySecrets( + deviceSeed = stored[GeohashIdentityKeys.deviceSeed], + nickname = stored[GeohashIdentityKeys.nickname], + ) + } + + /** Writes the group and its marker as one edit, for the reasons [saveSecrets] gives. */ + suspend fun saveGeohashIdentity( + npub: String, + value: GeohashIdentitySecrets, + ) { + getDataStore(npub).edit { + putOrRemove(GeohashIdentityKeys.deviceSeed, value.deviceSeed) + putOrRemove(GeohashIdentityKeys.nickname, value.nickname) + + put(GeohashIdentityKeys.migrated, "true") + } + } + private fun decodeSet(raw: String?): Set = raw ?.split(AccountSecretKeys.SET_SEPARATOR) diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountSecretsStoreTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountSecretsStoreTest.kt index 32962ebd25..fc22b515a1 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountSecretsStoreTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountSecretsStoreTest.kt @@ -206,4 +206,94 @@ class AccountSecretsStoreTest { assertEquals(filled, subject.loadSecrets(npub)) } + // ── the location-chat identity ──────────────────────────────────── + + @Test + fun anUnmigratedGeohashIdentityReadsAsNull() = + runTest { + assertNull(stores().loadGeohashIdentity(npub)) + } + + @Test + fun theGeohashIdentityRoundTrips() = + runTest { + val subject = stores() + val value = GeohashIdentitySecrets(deviceSeed = "a".repeat(64), nickname = "vitor") + + subject.saveGeohashIdentity(npub, value) + + assertEquals(value, subject.loadGeohashIdentity(npub)) + } + + /** + * An account that opened a location chat under a bunker signer has a seed but + * never set a handle. Absent must come back absent rather than as "". + */ + @Test + fun aSeedWithNoNicknameRoundTrips() = + runTest { + val subject = stores() + val value = GeohashIdentitySecrets(deviceSeed = "b".repeat(64), nickname = null) + + subject.saveGeohashIdentity(npub, value) + + assertEquals(value, subject.loadGeohashIdentity(npub)) + } + + /** An account that holds neither key still counts as migrated, or the copy runs forever. */ + @Test + fun anEmptyGeohashIdentityStillCountsAsMigrated() = + runTest { + val subject = stores() + + subject.saveGeohashIdentity(npub, GeohashIdentitySecrets()) + + assertEquals(GeohashIdentitySecrets(), subject.loadGeohashIdentity(npub)) + } + + /** + * The two groups migrate out of *different* legacy files — the secrets from + * `secret_keeper_`, the identity from `secret_keeper_` — + * so neither marker may stand in for the other. Saving one must leave the + * other reading as not-yet-copied. + */ + @Test + fun theTwoGroupsMigrateIndependently() = + runTest { + val subject = stores() + + subject.saveSecrets(npub, filled) + + assertNull("saving the secrets must not mark the identity migrated", subject.loadGeohashIdentity(npub)) + } + + @Test + fun savingTheIdentityDoesNotMarkTheSecretsMigrated() = + runTest { + val subject = stores() + + subject.saveGeohashIdentity(npub, GeohashIdentitySecrets(deviceSeed = "c".repeat(64))) + + assertNull(subject.loadSecrets(npub)) + } + + /** + * The seed must survive an unrelated account save. This is the whole reason + * the identity is its own group: every save mirrors a full AccountSecrets + * built from AccountSettings, which does not hold the seed, and the group + * save removes keys whose value is null. Folded into that group, the seed + * would be deleted here — and every geohash identity the user has would + * silently change. + */ + @Test + fun anAccountSaveLeavesTheGeohashIdentityAlone() = + runTest { + val subject = stores() + val identity = GeohashIdentitySecrets(deviceSeed = "d".repeat(64), nickname = "vitor") + subject.saveGeohashIdentity(npub, identity) + + subject.saveSecrets(npub, filled) + + assertEquals(identity, subject.loadGeohashIdentity(npub)) + } } From 48bb8d37e345d400a70fb32683e4e704c936f76c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 23:33:23 +0000 Subject: [PATCH 34/50] ci: cut the Android job's worker concurrency, not more heap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test-and-build-android died again with "the runner has received a shutdown signal" — the OOM killer taking the runner agent, about 20 minutes in, with every task up to that point green. No test failed and nothing failed to compile. The previous fix is not wrong, it was validated on the easy case. ea459bc5 changed only this file, so the run restored main's Gradle cache and rebuilt almost nothing; it passed in 54 minutes and I read that as confirmation. The first PR run since that touches `commons` invalidated the cache — this job's own comment above notes a quartz/commons change does exactly that and costs 2-3x — rebuilt from cold, and died the same way. Cold is the case the 4g cap had never actually faced. So the lever is concurrency rather than ceilings. Those heap numbers are per-JVM limits; what tips a 16GB runner over is how many heavy JVMs are resident at once, and Gradle defaults max-workers to the core count with org.gradle.parallel on. The log shows the peak: R8 and both lintAnalyze tasks behind it, a cold compileFdroidDebugKotlin, and a forked test JVM starting, alongside the 6g Gradle and 4g Kotlin daemons. --max-workers=3, not 2. This job is mostly a chain of single-task module compiles, so it loses little real parallelism, and halving it risks the timeout on a cold run instead — the cap goes to 120 for that reason, which costs nothing on runs that finish early. Dropping the Kotlin daemon further was the obvious alternative and is the wrong one: cdb4f1f7 already lost that trade, starving R8 and lintAnalyze into "java.lang.OutOfMemoryError: Java heap space". If this still dies early, the job is simply too big for one 16GB runner and the answer is splitting lint out into its own job — not another number. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AXvKXakvup4inNFfAhhr4L --- .github/workflows/build.yml | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1a123e7e16..72b2548016 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -347,7 +347,7 @@ jobs: # workflow, which puts this one past the cap and gets it killed mid-step # with no test report. Raising the cap costs nothing on runs that finish # early — `timeout-minutes` bounds a job, it does not reserve the time. - timeout-minutes: 90 + timeout-minutes: 120 steps: - name: Checkout code uses: actions/checkout@v7 @@ -396,10 +396,31 @@ jobs: # # Overridden here rather than in gradle.properties so local builds on # bigger machines keep the headroom. + # + # `--max-workers` is the second half, and it is about concurrency rather + # than ceilings. The heap numbers above are per-JVM limits; what actually + # tips a 16GB runner over is how many heavy JVMs are live at once. Gradle + # defaults max-workers to the core count (4 on ubuntu-latest) and + # org.gradle.parallel is on, so a cold run can have several kotlinc + # workers, a lint fork and a forked test JVM resident alongside the two + # daemons. + # + # Cold is the case that matters. The 4g cap was first validated on a run + # that only changed this file, so it restored main's Gradle cache and + # built almost nothing; the next PR run that touched `commons` + # invalidated that cache, rebuilt from cold, and died the same way at + # ~20 minutes. Fewer workers is what makes the cold path fit — dropping + # heap further would start starving R8 again, which is the trade the + # previous attempt already lost. + # + # 3 rather than 2: this job is mostly a chain of single-task module + # compiles, so the parallelism it loses is small, and halving it risks + # the timeout on a cold run. The cap goes to 120 for the same reason. - name: Test + Build Android (gradle) run: | ./gradlew \ -Dkotlin.daemon.jvmargs="-Xmx4g -XX:MaxMetaspaceSize=1g" \ + --max-workers=3 \ :amethyst:lintFdroidBenchmark \ :amethyst:lintPlayBenchmark \ :quartz:jvmTest \ From e619fdcb410678f594cbcf3f3cba8d5c9a1d8964 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 14:10:26 +0000 Subject: [PATCH 35/50] fix: make the location-chat identity migration finish on its own MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two gaps left by 04ef4128, both of which would have surfaced as a half-finished release at the flag flip rather than as a failure now. The mirror ignored the retirement switch. LEGACY_WRITES_RETIRED was private to LocalPreferences, so GeohashChatIdentityState could not read it and wrote to its legacy file unconditionally. Flipping the flag would have retired the four documented mirrors and left this one running. It is `internal` now and both of that class's legacy writes are gated on it, so the flip is one switch that stops everything. Nothing would ever have deleted the identity's legacy file. It is `secret_keeper_`, not `secret_keeper_` — the writer passed signer.pubKey where every other caller passes an npub — and the cleanup enumerates npub-keyed files. It would have sat on disk holding a seed after every other legacy file was gone. LegacyAccountFiles.delete now clears and unlinks both, and verify() refuses while the hex file holds an identity the current store does not. That check reads geohashSource(), the hex file. An earlier version of it read the npub file and was reverted for being unable to fire; this is the corrected form, and it is paired with actually deleting the file it guards. The copy also had to stop being lazy, and that is the part that would have bitten users rather than the release. It ran only from GeohashChatIdentityState, so only for someone who opened a location chat. Combined with the new gate that is worse than the orphan it replaced: a user with an identity who never opens another location chat would never be copied, and their legacy files could then never be deleted at all. It now runs on the first account load after the upgrade, beside the cleanup call and before it, so every existing install converges whether or not location chat is ever touched again. It sits next to legacyCleanup rather than inside the loader because innerLoadCurrentAccountFromEncryptedStorage is at the JVM's 64KB method limit — AccountStoreData's KDoc already records that every suspend call in there costs a coroutine state, and adding one broke the build with "Method too large". Outside the lock, once per load, is also where it belongs: this is migration housekeeping, not part of building AccountSettings. Tests: four on the gate (an uncopied identity blocks deletion, a copied one does not, an account that never opened a location chat is not held hostage, and both files go together) and one on idempotence, which the copy now needs because it runs on every load. The plan doc is updated to match: the group and its two quirks, deletion covering both files, the fourth condition, the flip stopping this mirror too, and a device-pass step for the seed — the one migrated value whose loss is silent rather than visible. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AXvKXakvup4inNFfAhhr4L --- ...2026-09-23-encrypted-storage-retirement.md | 41 +++++++-- .../amethyst/AccountSecretsStore.kt | 8 ++ .../amethyst/LegacyPreferenceCleanup.kt | 54 ++++++++++++ .../amethyst/LocalPreferences.kt | 68 +++++++++++++- .../model/GeohashChatIdentityState.kt | 11 ++- .../amethyst/LegacyPreferenceCleanupTest.kt | 88 +++++++++++++++++++ .../preferences/AccountSecretsStoreTest.kt | 21 +++++ 7 files changed, 278 insertions(+), 13 deletions(-) diff --git a/amethyst/plans/2026-09-23-encrypted-storage-retirement.md b/amethyst/plans/2026-09-23-encrypted-storage-retirement.md index ceb220068c..b2eccd2986 100644 --- a/amethyst/plans/2026-09-23-encrypted-storage-retirement.md +++ b/amethyst/plans/2026-09-23-encrypted-storage-retirement.md @@ -42,10 +42,20 @@ to none of those fails that test at the commit that adds it. | NIP-46 material, wallets, payment source | the account's encrypted DataStore | **kept** | | current account, saved accounts | the encrypted roster store | **kept** | | UI settings (`shared_settings`) | `UiSharedPreferences`' own DataStore | none left | +| location-chat identity — seed, nickname | the account's encrypted DataStore, as its own `GeohashIdentitySecrets` group | **kept** | -The three stores that still mirror are the ones whose loss is not an -annoyance: an account that cannot be listed, signed with, or paid from. They -keep the rollback window open until the device pass below has happened. +The stores that still mirror are the ones whose loss is not an annoyance: an +account that cannot be listed, signed with, or paid from. They keep the +rollback window open until the device pass below has happened. + +The location-chat identity is the odd one, in two ways worth knowing before +step 4. It is its **own** group rather than fields on `AccountSecrets`: every +account save mirrors a whole `AccountSecrets` built from `AccountSettings`, +which does not hold these, and that group save removes keys whose value is +null — folded in, the seed would be deleted by the next unrelated save and +every geohash identity the user has would silently change. And its legacy home +is a **different file**, `secret_keeper_`, because its writer +passed `signer.pubKey` where every other caller passes an npub. The UI settings copy is **guarded** where the others are not. That store has been the real home of these settings for a while, so most installs already @@ -69,7 +79,11 @@ cleanup treat any *other* unclaimed key as a reason to keep the file. ## Deleting a legacy file `LegacyPreferenceCleanup` runs after every successful account load and deletes -that account's file only when it can prove nothing would be lost: +that account's files — `secret_keeper_` **and** the location-chat +identity's `secret_keeper_` — only when it can prove nothing would +be lost. Both, because nothing else would ever remove the second one: the +cleanup enumerates npub-keyed files, so left out of this it would sit on disk +holding a seed forever. 1. **Every key in the file is accounted for** — claimed by a table, one of the secrets, or on the accepted list. Driven from the file's own keys, not from @@ -83,9 +97,14 @@ that account's file only when it can prove nothing would be lost: 3. **The secrets and the private key read back identical** from the current stores. Those *are* still dual-written, so the stronger question is available and is asked. -4. A store that cannot be read is a reason, never a pass. +4. **The location-chat identity has been copied**, when its file holds one. + Read from the hex-keyed file, not the npub one — these two keys were never + in that one, so a check pointed at it would never fire. An account that + never opened a location chat holds neither key, which is a real answer and + must not hold the file hostage. +5. A store that cannot be read is a reason, never a pass. -It refuses today, and says so, because of the fifth condition: +It refuses today, and says so, because of the last condition: `LEGACY_WRITES_RETIRED` is false. While the app still mirrors into the file, deleting it achieves nothing — the next save recreates it — and would look like it had worked. @@ -97,7 +116,10 @@ like it had worked. 3. Do the device pass below. 4. Flip `LEGACY_WRITES_RETIRED` and drop the legacy writes for the identity, key, secret and roster stores. This ends the rollback window, so it is a - release of its own. + release of its own. The flag is `internal`, not private, so the + location-chat mirror in `GeohashChatIdentityState` reads the same switch — + flipping it stops that write too, and the cleanup then removes both of the + account's legacy files. One flip, nothing left behind. 5. Keep the reader, and `androidx.security.crypto`, indefinitely. ## Verification this needs and has not had @@ -110,6 +132,9 @@ the one irreversible step in the whole series. On a real device, before step 4 ships: upgrade an install holding accounts and confirm they all list; open one and sign; force-stop and relaunch; add and remove an account; pair a NIP-46 signer; pay from a wallet; check the -key-backup nudge stays dismissed; confirm UI settings survive the upgrade. +key-backup nudge stays dismissed; confirm UI settings survive the upgrade; +open a location chat under a bunker or external signer and confirm the +throwaway identity and nickname are the same ones as before the upgrade — the +seed is the one migrated value whose loss is silent rather than visible. Then let the cleanup run with the flag flipped, and confirm the files are gone and everything above still holds on the next cold start. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AccountSecretsStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AccountSecretsStore.kt index ee32dde855..2e73084664 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AccountSecretsStore.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AccountSecretsStore.kt @@ -127,6 +127,14 @@ class AccountSecretsStore( return legacy } + /** + * What the current store holds for the location-chat identity, with no + * fallback to the legacy file — the same distinction [stored] draws, and + * for the same reader: [LegacyPreferenceCleanup] has to tell "migrated" + * from "falling back and looking migrated". + */ + suspend fun storedGeohashIdentity(npub: String): GeohashIdentitySecrets? = stores.loadGeohashIdentity(npub) + /** Mirrors a save into the new store. The legacy write stays where it is. */ suspend fun mirrorGeohashIdentity( npub: String, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LegacyPreferenceCleanup.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LegacyPreferenceCleanup.kt index 9872ab3ac8..7a271b02ee 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LegacyPreferenceCleanup.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LegacyPreferenceCleanup.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.commons.model.preferences.AccountIdentityStore import com.vitorpamplona.amethyst.commons.model.preferences.AccountSecrets import com.vitorpamplona.amethyst.commons.model.preferences.DialogDismissalStore import com.vitorpamplona.amethyst.commons.model.preferences.FeedVisibilityStore +import com.vitorpamplona.amethyst.commons.model.preferences.GeohashIdentitySecrets import com.vitorpamplona.amethyst.commons.model.preferences.LatestEventCacheStore import com.vitorpamplona.amethyst.commons.model.preferences.LegacyAccountSecretNames import com.vitorpamplona.amethyst.commons.model.preferences.LegacyKeyTable @@ -33,6 +34,7 @@ import com.vitorpamplona.amethyst.commons.model.preferences.NotificationPrefsSto import com.vitorpamplona.amethyst.commons.model.preferences.RelayAuthStore import com.vitorpamplona.amethyst.commons.model.preferences.TopNavFollowListStore import com.vitorpamplona.amethyst.commons.model.preferences.UploadSettingsStore +import com.vitorpamplona.amethyst.commons.model.preferences.readLegacyGeohashIdentity import com.vitorpamplona.quartz.utils.Log /** @@ -100,6 +102,17 @@ sealed interface LegacyCleanupResult { interface LegacyAccountFiles { fun source(npub: String): LegacyPreferenceSource + /** + * The account's OTHER legacy file: the location-chat identity, which lives + * in `secret_keeper_` rather than `secret_keeper_` + * because that is the key its writer passed. + * + * Separate from [source] because [delete] removes both, and a check that + * read the npub file for these keys would never find them — they are not + * in it. That mistake was made once already. + */ + fun geohashSource(npub: String): LegacyPreferenceSource + fun exists(npub: String): Boolean /** Returns false when there was nothing to delete. */ @@ -113,6 +126,14 @@ interface MigratedSecrets { /** Null only when the account genuinely has no private key. Throws when the store is unreadable. */ suspend fun privateKey(npub: String): String? + + /** + * The location-chat identity, or null when it has not been copied across. + * + * Its own question because it migrates out of its own file: [AccountSecrets] + * being present says nothing about whether this was carried over. + */ + suspend fun geohashIdentity(npub: String): GeohashIdentitySecrets? } /** @@ -255,9 +276,42 @@ class LegacyPreferenceCleanup( } } + reasons += geohashMismatches(npub) + return reasons } + /** + * Whether deleting this account's `secret_keeper_` file would + * lose its location-chat identity. + * + * Read from [LegacyAccountFiles.geohashSource], not from the npub file the + * rest of [verify] walks: these two keys were never in that one. An account + * that never opened a location chat holds neither, and needs no copy. + */ + private suspend fun geohashMismatches(npub: String): List { + val legacy = + try { + readLegacyGeohashIdentity(files.geohashSource(npub)) + } catch (e: Exception) { + Log.w(TAG, "Could not read the location-chat identity file for $npub", e) + return listOf("the location-chat identity file could not be read") + } + + if (legacy == GeohashIdentitySecrets()) return emptyList() + + return try { + if (secrets.geohashIdentity(npub) == null) { + listOf("the location-chat identity has not been copied across") + } else { + emptyList() + } + } catch (e: Exception) { + Log.w(TAG, "Could not read the location-chat identity store for $npub", e) + listOf("the location-chat identity store could not be read") + } + } + /** * Deletes the account's legacy file if — and only if — [verify] comes back * empty and the app has stopped writing to it. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 430f20be37..c797fcd434 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -57,6 +57,7 @@ import com.vitorpamplona.amethyst.commons.model.preferences.UploadSettings import com.vitorpamplona.amethyst.commons.model.preferences.UploadSettingsStore import com.vitorpamplona.amethyst.commons.model.preferences.orIfUnusable import com.vitorpamplona.amethyst.commons.model.preferences.readLegacyAccountSecrets +import com.vitorpamplona.amethyst.commons.model.preferences.readLegacyGeohashIdentity import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy import com.vitorpamplona.amethyst.model.AccountSettings @@ -78,6 +79,7 @@ import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes import com.vitorpamplona.quartz.nip19Bech32.toNpub import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent @@ -325,8 +327,14 @@ object LocalPreferences { * Flipping this is a release of its own, and it ends the rollback window. * It waits on the device pass in * `amethyst/plans/2026-09-23-encrypted-storage-retirement.md`. + * + * `internal` rather than private because the mirror is not all in this + * file: [com.vitorpamplona.amethyst.model.GeohashChatIdentityState] writes + * the location-chat identity into its own legacy file and reads this to + * know when to stop. Private, it would have kept writing after the flip + * and the switch would only half work. */ - private const val LEGACY_WRITES_RETIRED = false + internal const val LEGACY_WRITES_RETIRED = false private val legacyCleanup: LegacyPreferenceCleanup by lazy { LegacyPreferenceCleanup( @@ -338,12 +346,25 @@ object LocalPreferences { override fun exists(npub: String) = legacyAccountFile(npub).exists() + override fun geohashSource(npub: String) = LegacySharedPreferences(encryptedPreferences(geohashLegacyKey(npub))) + override suspend fun delete(npub: String): Boolean { // Clear before unlinking, as deleteAccount does: the live // SharedPreferences still holds the values in memory and // would write them straight back out. encryptedPreferences(npub).edit(commit = true) { clear() } - return legacyAccountFile(npub).delete() + val removedAccountFile = legacyAccountFile(npub).delete() + + // The location-chat identity is in a SECOND file, keyed by the + // pubkey hex rather than the npub, because that is the key its + // writer passed. Nothing else would ever remove it, so it is + // deleted here with the account's own file rather than left as + // an orphan holding a seed forever. + val hex = geohashLegacyKey(npub) + encryptedPreferences(hex).edit(commit = true) { clear() } + legacyAccountFile(hex).delete() + + return removedAccountFile } }, currentStore = { npub -> accountStores.getDataStore(npub).data.first() }, @@ -352,6 +373,8 @@ object LocalPreferences { override suspend fun secrets(npub: String) = accountSecretsStore.stored(npub) override suspend fun privateKey(npub: String) = accountKeyStore.stored(npub) + + override suspend fun geohashIdentity(npub: String) = accountSecretsStore.storedGeohashIdentity(npub) }, legacyWritesRetired = LEGACY_WRITES_RETIRED, ) @@ -368,6 +391,37 @@ object LocalPreferences { return File(prefsDirPath, "$name.xml") } + /** + * The key the location-chat identity's legacy file is named by. + * + * [GeohashChatIdentityState] passed `signer.pubKey` — hex — where every + * other caller of [encryptedPreferences] passes an npub, so that material + * sits in `secret_keeper_`, a different file from the account's own + * `secret_keeper_`. Converting here keeps that quirk in one place. + */ + private fun geohashLegacyKey(npub: String): String = npub.bechToBytes("npub").toHexKey() + + /** + * Copies the location-chat identity out of `secret_keeper_` on + * the first load after the upgrade. + * + * Eager, not lazy. [GeohashChatIdentityState] also copies on first use, but + * only a user who opens a location chat ever reaches it — and the cleanup + * refuses to delete an account's legacy files while that file still holds + * an identity the current store does not. Left to the lazy path alone, a + * user who never opens another location chat would keep both files + * forever, which is the opposite of what the migration is for. + * + * Idempotent: the store's marker makes every run after the first a no-op, + * so re-running it on each load cannot overwrite a later edit. + */ + private suspend fun copyGeohashIdentity(npub: String) { + accountSecretsStore.readGeohashIdentity( + npub = npub, + legacy = readLegacyGeohashIdentity(LegacySharedPreferences(encryptedPreferences(geohashLegacyKey(npub)))), + ) + } + /** * Everything the account's DataStore holds, read in one hop. * @@ -1031,7 +1085,15 @@ object LocalPreferences { // while `mutex` serialises every account load — under the lock, each // account on a multi-account cold start would wait for the previous // one's full cleanup pass. Nothing here feeds the load. - if (loadedHere) legacyCleanup.deleteIfVerified(npub) + if (loadedHere) { + // Before the cleanup, which refuses to delete this account's files + // while the location-chat identity has not been copied. Here rather + // than inside the loader for the reason [AccountStoreData] gives: + // that method is at the JVM's 64KB limit and one more suspend call + // inside it does not fit. + copyGeohashIdentity(npub) + legacyCleanup.deleteIfVerified(npub) + } accountSettings } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/GeohashChatIdentityState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/GeohashChatIdentityState.kt index 699fbb9f37..21c011e8d6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/GeohashChatIdentityState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/GeohashChatIdentityState.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.model import androidx.core.content.edit import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.LegacySharedPreferences +import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.accountSecretsStore import com.vitorpamplona.amethyst.commons.model.preferences.GeohashIdentitySecrets import com.vitorpamplona.amethyst.commons.model.preferences.readLegacyGeohashIdentity @@ -121,7 +122,11 @@ class GeohashChatIdentityState( persist(current().copy(nickname = trimmed)) // Mirrored, not moved: the legacy file stays readable until the // legacy writes are retired app-wide, so a rollback keeps the handle. - Amethyst.instance.encryptedStorage(signer.pubKey).edit { putString(PREF_NICKNAME, trimmed) } + // Gated on the same switch as every other mirror — otherwise flipping + // it would retire the documented four and leave this one writing. + if (!LocalPreferences.LEGACY_WRITES_RETIRED) { + Amethyst.instance.encryptedStorage(signer.pubKey).edit { putString(PREF_NICKNAME, trimmed) } + } } } @@ -151,7 +156,9 @@ class GeohashChatIdentityState( val fresh = RandomInstance.bytes(GeohashKeyDerivation.SEED_SIZE) persist(current().copy(deviceSeed = fresh.toHexKey())) - Amethyst.instance.encryptedStorage(signer.pubKey).edit { putString(PREF_KEY, fresh.toHexKey()) } + if (!LocalPreferences.LEGACY_WRITES_RETIRED) { + Amethyst.instance.encryptedStorage(signer.pubKey).edit { putString(PREF_KEY, fresh.toHexKey()) } + } return fresh } diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/LegacyPreferenceCleanupTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/LegacyPreferenceCleanupTest.kt index 18bad7f959..e4cc8f3ebc 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/LegacyPreferenceCleanupTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/LegacyPreferenceCleanupTest.kt @@ -26,6 +26,7 @@ import androidx.datastore.preferences.core.emptyPreferences import androidx.datastore.preferences.core.mutablePreferencesOf import androidx.datastore.preferences.core.stringPreferencesKey import com.vitorpamplona.amethyst.commons.model.preferences.AccountSecrets +import com.vitorpamplona.amethyst.commons.model.preferences.GeohashIdentitySecrets import com.vitorpamplona.amethyst.commons.model.preferences.LegacyBooleanKey import com.vitorpamplona.amethyst.commons.model.preferences.LegacyKeyTable import com.vitorpamplona.amethyst.commons.model.preferences.LegacyPreferenceSource @@ -52,18 +53,26 @@ private class MapSource( private class FakeFiles( private val values: Map, + private val geohashValues: Map = emptyMap(), ) : LegacyAccountFiles { var deleted = false private set + /** The `secret_keeper_` file goes with the account's own. */ + var deletedGeohash = false + private set + var present = true override fun source(npub: String) = MapSource(values) + override fun geohashSource(npub: String) = MapSource(geohashValues) + override fun exists(npub: String) = present override suspend fun delete(npub: String): Boolean { deleted = true + deletedGeohash = true present = false return true } @@ -73,6 +82,7 @@ private class FakeSecrets( private val stored: AccountSecrets? = AccountSecrets(), private val key: String? = null, private val throws: Boolean = false, + private val geohash: GeohashIdentitySecrets? = GeohashIdentitySecrets(), ) : MigratedSecrets { override suspend fun secrets(npub: String): AccountSecrets? { if (throws) throw IllegalStateException("keystore unavailable") @@ -83,6 +93,11 @@ private class FakeSecrets( if (throws) throw IllegalStateException("keystore unavailable") return key } + + override suspend fun geohashIdentity(npub: String): GeohashIdentitySecrets? { + if (throws) throw IllegalStateException("keystore unavailable") + return geohash + } } /** @@ -307,4 +322,77 @@ class LegacyPreferenceCleanupTest { subject.verify(NPUB), ) } + // ── the location-chat identity's own legacy file ────────────────── + + /** + * The seed is in `secret_keeper_`, and [delete] removes that + * file too. So the gate has to refuse while it holds something the current + * store does not — otherwise every geohash identity the account has would + * change on the next launch. + */ + @Test + fun anUncopiedLocationChatIdentityBlocksDeletion() = + runTest { + val (files, subject) = + cleanup( + values = emptyMap(), + files = FakeFiles(emptyMap(), mapOf("geohash_chat_device_seed" to "a".repeat(64))), + secrets = FakeSecrets(geohash = null), + ) + + val result = subject.deleteIfVerified(NPUB) + + assertTrue(result is LegacyCleanupResult.Kept) + assertTrue( + "was ${(result as LegacyCleanupResult.Kept).reasons}", + result.reasons.any { it.contains("location-chat identity") }, + ) + assertTrue(!files.deleted) + } + + /** Copied across: nothing to lose, so it must not block. */ + @Test + fun aCopiedLocationChatIdentityDoesNotBlockDeletion() = + runTest { + val (files, subject) = + cleanup( + values = emptyMap(), + files = FakeFiles(emptyMap(), mapOf("geohash_chat_device_seed" to "a".repeat(64))), + secrets = FakeSecrets(geohash = GeohashIdentitySecrets(deviceSeed = "a".repeat(64))), + ) + + assertEquals(LegacyCleanupResult.Deleted, subject.deleteIfVerified(NPUB)) + assertTrue(files.deleted) + } + + /** + * An account that never opened a location chat holds neither key. That is a + * real answer, not "not migrated", and must not hold the file hostage. + */ + @Test + fun anAccountWithNoLocationChatIdentityIsNotBlocked() = + runTest { + val (files, subject) = + cleanup( + values = emptyMap(), + files = FakeFiles(emptyMap(), emptyMap()), + secrets = FakeSecrets(geohash = null), + ) + + assertEquals(LegacyCleanupResult.Deleted, subject.deleteIfVerified(NPUB)) + assertTrue(files.deleted) + } + + /** + * Both files go, or the hex one is an orphan nothing will ever remove — + * the whole reason it is wired into this gate. + */ + @Test + fun deletingTheAccountFileAlsoRemovesTheLocationChatFile() = + runTest { + val (files, subject) = cleanup(values = emptyMap()) + + assertEquals(LegacyCleanupResult.Deleted, subject.deleteIfVerified(NPUB)) + assertTrue("the hex-keyed file must be deleted with the account's own", files.deletedGeohash) + } } diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountSecretsStoreTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountSecretsStoreTest.kt index fc22b515a1..5685c6c603 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountSecretsStoreTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountSecretsStoreTest.kt @@ -296,4 +296,25 @@ class AccountSecretsStoreTest { assertEquals(identity, subject.loadGeohashIdentity(npub)) } + + /** + * The copy has to be idempotent, because the loader now runs it on every + * account load rather than only when a location chat is opened. A second + * run must not overwrite what the user has changed since the first. + */ + @Test + fun recopyingDoesNotClobberALaterEdit() = + runTest { + val subject = stores() + subject.saveGeohashIdentity(npub, GeohashIdentitySecrets(deviceSeed = "a".repeat(64), nickname = "old")) + + // What a fresh load would find in the legacy file: the pre-migration value. + val stored = subject.loadGeohashIdentity(npub) + assertEquals("old", stored?.nickname) + + subject.saveGeohashIdentity(npub, GeohashIdentitySecrets(deviceSeed = "a".repeat(64), nickname = "new")) + + assertEquals("new", subject.loadGeohashIdentity(npub)?.nickname) + assertEquals("a".repeat(64), subject.loadGeohashIdentity(npub)?.deviceSeed) + } } From 0bbe5054dcc29ba887e77ca04245c9e86318d6e2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 17:01:15 +0000 Subject: [PATCH 36/50] fix: close five review findings in the location-chat migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A /code-review pass over this session's commits found five, all in the geohash work and all in code that had already gone green in CI — the tests I wrote exercised the cleanup gate's decision logic against fakes, not the file lifecycle, which is where every one of these lived. Two of them together defeated the previous commit entirely. It added the hex-keyed file to the cleanup so nothing would be orphaned; in practice the next account load recreated the file and the gate then declined to look at it again, leaving the same orphan plus extra work per launch. 1. setNickname read-modify-wrote outside the mutex. Racing the first seed mint it persisted the nickname over a null deviceSeed, and the group save removes null keys, so the seed vanished and every per-cell identity used in that session became unreproducible. It is under the lock now, where deviceSeed() already was. current() and persist() are marked lock-required and deliberately do not self-lock: Mutex is not reentrant and keyPair() is already holding it when it reaches them. 2. readGeohashIdentity took the legacy value eagerly, so every account load opened secret_keeper_. Opening an EncryptedSharedPreferences writes its Tink keyset, which means the open CREATES the file: on the load after the cleanup deleted it, the file came straight back, for good. It also cost a Keystore-backed open per account on every cold start. The parameter is a suspend lambda now, called only when the store has nothing. 3. deleteAccount never touched the hex file, so the anonymous device seed outlived the account that owned it and returned if the same npub was re-added. For an identity whose whole purpose is to be unlinkable, that is the wrong direction. Both call sites now share deleteGeohashLegacyFile. 4. The copy was the only legacy step on the account-load path without a try/catch. An EncryptedSharedPreferences that cannot be opened would have failed the whole load, and with it the per-account loops in the notification consumers. 5. deleteIfVerified gated on the npub file alone, so once that was gone the hex file was unreachable and could never be removed. exists() is either file now. Tests: the gate still runs when only the location-chat file is left, and FakeFiles tracks the two files separately — modelling them behind one flag is what let finding 5 hide. That change also exposed anAccountWithNoLegacyFileIsAlreadyDone, which cleared one file and meant "neither"; it clears both now, which is what its name always claimed. Not pinned, and worth saying: finding 2's laziness has no unit test. The lambda lives in AccountSecretsStore, which takes the concrete AccountSecretsEncryptedStores rather than an interface, so it cannot be faked the way AccountKeyStore takes a FakeVault. A test was written and deleted for re-asserting existing coverage while looking like it covered the fix. Extracting a narrow interface there would close it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AXvKXakvup4inNFfAhhr4L --- .../amethyst/AccountSecretsStore.kt | 21 ++++--- .../amethyst/LocalPreferences.kt | 59 ++++++++++++++----- .../model/GeohashChatIdentityState.kt | 41 +++++++++---- .../amethyst/LegacyPreferenceCleanupTest.kt | 37 +++++++++++- 4 files changed, 123 insertions(+), 35 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AccountSecretsStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AccountSecretsStore.kt index 2e73084664..aaf46b8726 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AccountSecretsStore.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AccountSecretsStore.kt @@ -105,26 +105,33 @@ class AccountSecretsStore( * The account's location-chat identity, migrating out of the legacy file on * first use, on the same terms as [read]. * - * @param legacy what `secret_keeper_` holds. Note the *hex*: this - * group's legacy file is keyed by the signer's pubkey rather than the npub - * every other group uses, so the caller opens a different file for it. + * @param legacy opens and reads `secret_keeper_`. Note the + * *hex*: this group's legacy file is keyed by the signer's pubkey rather + * than the npub every other group uses, so it is a different file. + * + * A lambda, not a value, because opening that file **creates** it — an + * `EncryptedSharedPreferences` writes its Tink keyset on construction. An + * eager read would resurrect the file on the load after the cleanup + * deleted it, and would cost a Keystore-backed open per account on every + * cold start. Called only when the store has nothing yet. */ suspend fun readGeohashIdentity( npub: String, - legacy: GeohashIdentitySecrets, + legacy: suspend () -> GeohashIdentitySecrets, ): GeohashIdentitySecrets { val stored = try { stores.loadGeohashIdentity(npub) } catch (e: Exception) { Log.w(TAG, "Could not read the location-chat identity for $npub; using the legacy file", e) - return legacy + return legacy() } if (stored != null) return stored - mirrorGeohashIdentity(npub, legacy) - return legacy + val fromLegacy = legacy() + mirrorGeohashIdentity(npub, fromLegacy) + return fromLegacy } /** diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index c797fcd434..f0c6f739d0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -344,7 +344,9 @@ object LocalPreferences { object : LegacyAccountFiles { override fun source(npub: String) = legacySource(npub) - override fun exists(npub: String) = legacyAccountFile(npub).exists() + // Either file: once the npub one is gone, the hex one still + // has to be reachable or it can never be removed. + override fun exists(npub: String) = legacyAccountFile(npub).exists() || legacyAccountFile(geohashLegacyKey(npub)).exists() override fun geohashSource(npub: String) = LegacySharedPreferences(encryptedPreferences(geohashLegacyKey(npub))) @@ -357,14 +359,12 @@ object LocalPreferences { // The location-chat identity is in a SECOND file, keyed by the // pubkey hex rather than the npub, because that is the key its - // writer passed. Nothing else would ever remove it, so it is - // deleted here with the account's own file rather than left as - // an orphan holding a seed forever. - val hex = geohashLegacyKey(npub) - encryptedPreferences(hex).edit(commit = true) { clear() } - legacyAccountFile(hex).delete() + // writer passed. Nothing else would ever remove it, so it goes + // with the account's own file rather than being left as an + // orphan holding a seed forever. + val removedGeohashFile = deleteGeohashLegacyFile(npub) - return removedAccountFile + return removedAccountFile || removedGeohashFile } }, currentStore = { npub -> accountStores.getDataStore(npub).data.first() }, @@ -401,6 +401,19 @@ object LocalPreferences { */ private fun geohashLegacyKey(npub: String): String = npub.bechToBytes("npub").toHexKey() + /** + * Clears and unlinks `secret_keeper_`. + * + * Clear before unlinking, as everything else here does: the live + * SharedPreferences still holds the values in memory and would write them + * straight back out. + */ + private fun deleteGeohashLegacyFile(npub: String): Boolean { + val hex = geohashLegacyKey(npub) + encryptedPreferences(hex).edit(commit = true) { clear() } + return legacyAccountFile(hex).delete() + } + /** * Copies the location-chat identity out of `secret_keeper_` on * the first load after the upgrade. @@ -412,14 +425,17 @@ object LocalPreferences { * user who never opens another location chat would keep both files * forever, which is the opposite of what the migration is for. * - * Idempotent: the store's marker makes every run after the first a no-op, - * so re-running it on each load cannot overwrite a later edit. + * Idempotent, and cheap after the first run: the store's marker short-circuits + * it, so re-running on each load cannot overwrite a later edit and — because + * the legacy read is a lambda — does not open `secret_keeper_` + * either. That matters beyond speed: opening an `EncryptedSharedPreferences` + * writes its Tink keyset, so an eager read would recreate the file on the + * load right after the cleanup deleted it, permanently. */ private suspend fun copyGeohashIdentity(npub: String) { - accountSecretsStore.readGeohashIdentity( - npub = npub, - legacy = readLegacyGeohashIdentity(LegacySharedPreferences(encryptedPreferences(geohashLegacyKey(npub)))), - ) + accountSecretsStore.readGeohashIdentity(npub) { + readLegacyGeohashIdentity(LegacySharedPreferences(encryptedPreferences(geohashLegacyKey(npub)))) + } } /** @@ -686,6 +702,11 @@ object LocalPreferences { // would resurrect the deleted settings from this cache. mutex.withLock { cachedAccounts.remove(accountInfo.npub) } encryptedPreferences(accountInfo.npub).edit(commit = true) { clear() } + // The location-chat identity's own file, keyed by pubkey hex. Without + // this the anonymous device seed outlives the account that owned it + // and comes back if the same npub is re-added — the opposite of what + // an unlinkable per-cell identity is for. + deleteGeohashLegacyFile(accountInfo.npub) accountKeyStore.delete(accountInfo.npub) accountSecretsStore.delete(accountInfo.npub) // The account's plain DataStore, which deleteUserPreferenceFile cannot @@ -1091,7 +1112,15 @@ object LocalPreferences { // than inside the loader for the reason [AccountStoreData] gives: // that method is at the JVM's 64KB limit and one more suspend call // inside it does not fit. - copyGeohashIdentity(npub) + // Never fatal, like every other legacy step here: this runs on + // the account-load path, and an EncryptedSharedPreferences that + // cannot be opened must not take the whole load — and with it the + // per-account loops in the notification consumers — down with it. + try { + copyGeohashIdentity(npub) + } catch (e: Exception) { + Log.w("LocalPreferences", "Could not copy the location-chat identity for $npub", e) + } legacyCleanup.deleteIfVerified(npub) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/GeohashChatIdentityState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/GeohashChatIdentityState.kt index 21c011e8d6..e45bc638b7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/GeohashChatIdentityState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/GeohashChatIdentityState.kt @@ -87,15 +87,27 @@ class GeohashChatIdentityState( @Volatile private var loaded: GeohashIdentitySecrets? = null - /** What `secret_keeper_` holds. Touches disk; callers are off the main thread. */ + /** + * What `secret_keeper_` holds. + * + * Only called when the store has nothing yet: opening this file creates it, + * so reading it unconditionally would resurrect it after the cleanup has + * deleted it. Touches disk; callers are off the main thread. + */ private fun legacy(): GeohashIdentitySecrets = readLegacyGeohashIdentity(LegacySharedPreferences(Amethyst.instance.encryptedStorage(signer.pubKey))) - /** The stored identity, copying it out of the legacy file the first time. */ + /** + * The stored identity, copying it out of the legacy file the first time. + * + * **Call under [mutex].** Not self-locking, because [keyPair] already holds + * the lock when it reaches here and [Mutex] is not reentrant. + */ private suspend fun current(): GeohashIdentitySecrets { loaded?.let { return it } - return accountSecretsStore.readGeohashIdentity(npub, legacy()).also { loaded = it } + return accountSecretsStore.readGeohashIdentity(npub) { legacy() }.also { loaded = it } } + /** Call under [mutex], for the reason [current] gives. */ private suspend fun persist(value: GeohashIdentitySecrets) { loaded = value accountSecretsStore.mirrorGeohashIdentity(npub, value) @@ -107,7 +119,7 @@ class GeohashChatIdentityState( * messages are ephemeral (relays needn't store them), so the only durable home for it is the device. * Empty string means "no nickname set". */ - suspend fun nickname(): String = current().nickname ?: "" + suspend fun nickname(): String = mutex.withLock { current().nickname ?: "" } /** * Persists the global location-chat nickname (trimmed) for this account. @@ -119,13 +131,20 @@ class GeohashChatIdentityState( fun setNickname(value: String) { val trimmed = value.trim() scope.launch { - persist(current().copy(nickname = trimmed)) - // Mirrored, not moved: the legacy file stays readable until the - // legacy writes are retired app-wide, so a rollback keeps the handle. - // Gated on the same switch as every other mirror — otherwise flipping - // it would retire the documented four and leave this one writing. - if (!LocalPreferences.LEGACY_WRITES_RETIRED) { - Amethyst.instance.encryptedStorage(signer.pubKey).edit { putString(PREF_NICKNAME, trimmed) } + // Under the lock: this is a read-modify-write of the same group + // deviceSeed() writes. Racing the first seed mint, an unlocked copy + // would persist the nickname over a null deviceSeed, putOrRemove + // would delete the seed, and every per-cell identity minted that + // session would be unreproducible on the next launch. + mutex.withLock { + persist(current().copy(nickname = trimmed)) + // Mirrored, not moved: the legacy file stays readable until the + // legacy writes are retired app-wide, so a rollback keeps the handle. + // Gated on the same switch as every other mirror — otherwise flipping + // it would retire the documented four and leave this one writing. + if (!LocalPreferences.LEGACY_WRITES_RETIRED) { + Amethyst.instance.encryptedStorage(signer.pubKey).edit { putString(PREF_NICKNAME, trimmed) } + } } } } diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/LegacyPreferenceCleanupTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/LegacyPreferenceCleanupTest.kt index e4cc8f3ebc..ddb6ca0aa0 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/LegacyPreferenceCleanupTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/LegacyPreferenceCleanupTest.kt @@ -64,16 +64,23 @@ private class FakeFiles( var present = true + /** + * Tracked apart from [present]: the two are different files, and the gate + * has to stay reachable while only one of them is left. + */ + var geohashPresent = true + override fun source(npub: String) = MapSource(values) override fun geohashSource(npub: String) = MapSource(geohashValues) - override fun exists(npub: String) = present + override fun exists(npub: String) = present || geohashPresent override suspend fun delete(npub: String): Boolean { deleted = true deletedGeohash = true present = false + geohashPresent = false return true } } @@ -292,10 +299,15 @@ class LegacyPreferenceCleanupTest { assertTrue(!files.deleted) } + /** Neither file — the account's own nor the location-chat one. */ @Test fun anAccountWithNoLegacyFileIsAlreadyDone() = runTest { - val files = FakeFiles(emptyMap()).also { it.present = false } + val files = + FakeFiles(emptyMap()).also { + it.present = false + it.geohashPresent = false + } val (_, subject) = cleanup(emptyMap(), files = files) assertEquals(LegacyCleanupResult.NothingToDelete, subject.deleteIfVerified(NPUB)) @@ -395,4 +407,25 @@ class LegacyPreferenceCleanupTest { assertEquals(LegacyCleanupResult.Deleted, subject.deleteIfVerified(NPUB)) assertTrue("the hex-keyed file must be deleted with the account's own", files.deletedGeohash) } + + /** + * Once the npub file is gone, the hex-keyed one is all that is left — and + * it still has to be removable. Gating on the account file alone returned + * NothingToDelete and stranded it forever. + */ + @Test + fun theGateStillRunsWhenOnlyTheLocationChatFileIsLeft() = + runTest { + val files = FakeFiles(emptyMap(), mapOf("geohash_chat_nickname" to "vitor")) + files.present = false + val (_, subject) = + cleanup( + values = emptyMap(), + files = files, + secrets = FakeSecrets(geohash = GeohashIdentitySecrets(nickname = "vitor")), + ) + + assertEquals(LegacyCleanupResult.Deleted, subject.deleteIfVerified(NPUB)) + assertTrue(files.deletedGeohash) + } } From f0c6695521d239e16286ee8c06dff07b9cde864f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 19:37:22 +0000 Subject: [PATCH 37/50] refactor(commons): move the favorites and browser-history registries out of the app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FavoriteAppsRegistry and BrowserHistoryRegistry were the two stores that 2539e510 repointed at AppPreferenceStores without relocating. That commit's scope was removing the Context.preferencesDataStore delegates, so these kept sitting in amethyst/ — by inertia, not because anything platform-specific held them there. Nothing in either was Android. The one real tie was that they reached out for their store: private val favoriteAppsDataStore: DataStore get() = Amethyst.instance.appStores.getDataStore("favorite_apps") Every store that already lives in commons takes its DataStore instead, for the reason DataStoreSearchHistoryStorage documents: DataStore refuses a second live instance on a path that already has one, so the caller's holder has to stay the single registry. Both are classes taking (store, scope) now, and the Android side does the binding in AppModules from appStores.getDataStore(FILE_NAME) and applicationIOScope. File names are unchanged, so nothing migrates. The Context parameter turned out to be dead. It was written once in init() and then only read as `appContext ?: return` — a has-init-run gate that happened to be typed Context?, never used as a Context. It is a Boolean now. The other platform call, System.currentTimeMillis() in record(), is TimeUtils.nowMillis(). ConcurrentHashMap.newKeySet() becomes commons' ConcurrentSet, which exists for exactly this (lock-striped on JVM, lock-guarded on iOS). BrowserHistoryRegistry's `hydrated` flag is gone: it was assigned and never read, unlike FavoriteAppsRegistry's, which gates the removal tombstones. The point of the move is that the disk lifecycle is now testable — as objects reaching into a singleton these had no unit coverage at all. 14 new tests, the ones worth naming being the two sides of the hydration window: a favorite removed after init() but before the merge lands must not be resurrected by it, and an add made in that same window must not be dropped by it. Both are driven on the test scheduler, so the window is a state the test controls rather than races. Also pinned: nothing is written before init() (without that gate a write in the window flushes a partial list over the stored one), a corrupt file hydrates empty, the history cap at 500, and that a blank title on a revisit does not overwrite the name the user recognises. Writing those tests surfaced the single-instance rule the hard way: the first restart tests opened a second DataStore on a live path and read an empty store, which looks exactly like data loss. Each test session now owns its Job and is cancelled before the next opens — the same rule production follows by keeping one instance per file. Verified: :commons:jvmTest, :commons:verifyKmpPurity, :commons:compileCommonMainKotlinMetadata, :amethyst:compilePlayDebugKotlin. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AXvKXakvup4inNFfAhhr4L --- .../com/vitorpamplona/amethyst/Amethyst.kt | 6 +- .../com/vitorpamplona/amethyst/AppModules.kt | 12 + .../amethyst/favorites/FavoriteAppLauncher.kt | 2 +- .../amethyst/napplet/NappletBrokerService.kt | 16 +- .../ui/navigation/bottombars/AppBottomBar.kt | 5 +- .../bottombars/AppNavigationRail.kt | 5 +- .../screen/loggedIn/browser/BrowserScreen.kt | 24 +- .../screen/loggedIn/browser/WebAppScreen.kt | 10 +- .../embed/EmbeddedTabPreloadSweeper.kt | 6 +- .../embed/FavoriteAppManifestPreloader.kt | 9 +- .../loggedIn/favorites/FavoriteAppsScreen.kt | 7 +- .../favorites/FavoriteToggleButton.kt | 9 +- .../loggedIn/favorites/NostrAppScreen.kt | 11 +- .../settings/BottomBarSettingsScreen.kt | 8 +- .../browser}/BrowserHistoryRegistry.kt | 80 +++--- .../favorites/FavoriteAppsRegistry.kt | 117 ++++----- .../browser/BrowserHistoryRegistryTest.kt | 218 +++++++++++++++ .../favorites/FavoriteAppsRegistryTest.kt | 248 ++++++++++++++++++ 18 files changed, 636 insertions(+), 157 deletions(-) rename {amethyst/src/main/java/com/vitorpamplona/amethyst/favorites => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser}/BrowserHistoryRegistry.kt (69%) rename {amethyst/src/main/java/com/vitorpamplona/amethyst => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons}/favorites/FavoriteAppsRegistry.kt (69%) create mode 100644 commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserHistoryRegistryTest.kt create mode 100644 commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/favorites/FavoriteAppsRegistryTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt index c3445013ca..7266fdc6cc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt @@ -25,9 +25,7 @@ import android.content.ComponentCallbacks2 import android.os.Build import com.vitorpamplona.amethyst.commons.service.http.HttpClientEnvironment import com.vitorpamplona.amethyst.commons.service.http.MediaCallEventListener -import com.vitorpamplona.amethyst.favorites.BrowserHistoryRegistry import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry -import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry import com.vitorpamplona.amethyst.napplet.WebAppNetworkRegistry import com.vitorpamplona.amethyst.service.logging.Logging import com.vitorpamplona.amethyst.service.nests.AppForegroundRecycleHook @@ -143,10 +141,10 @@ class Amethyst : Application() { WorkerThreadPriorityGovernor.start(this) // Hydrate the device-local favorite-apps list (main process only; the sandbox never reads it). - FavoriteAppsRegistry.init(this) + instance.favoriteApps.init() // Hydrate the device-local browser visit history (main process only; feeds the omnibox suggestions). - BrowserHistoryRegistry.init(this) + instance.browserHistory.init() // Index device-local captured favicons (main process only; decorates favorites + suggestions). BrowserIconRegistry.init(this) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 26697a4fcb..2bfc9ff3be 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -27,8 +27,10 @@ import android.os.SystemClock import androidx.security.crypto.EncryptedSharedPreferences import coil3.disk.DiskCache import coil3.memory.MemoryCache +import com.vitorpamplona.amethyst.commons.browser.BrowserHistoryRegistry import com.vitorpamplona.amethyst.commons.connectedApps.DataStoreNostrSignerPermissionStore import com.vitorpamplona.amethyst.commons.connectedApps.nip46.DataStoreNip46ClientStore +import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppsRegistry import com.vitorpamplona.amethyst.commons.model.NoteState import com.vitorpamplona.amethyst.commons.model.UiSettings import com.vitorpamplona.amethyst.commons.model.cache.LocalCache @@ -887,6 +889,16 @@ class AppModules( // Display + relay info for connected NIP-46 remote-signer clients. val nip46ClientStore by lazy { DataStoreNip46ClientStore(appStores.getDataStore(DataStoreNip46ClientStore.FILE_NAME)) } + // The device-local favorite-apps list behind the bottom bar, the Favorite Apps grid and the + // browser launcher, plus the browser's visit history behind the omnibox suggestions. Both live in + // commons and take their store and scope from here — that is the whole of their Android binding. + // + // Main process only: the keyless `:napplet` sandbox never builds AppModules, so it never builds + // these either. One instance each, so DataStore only ever sees one live reader per file. + val favoriteApps by lazy { FavoriteAppsRegistry(appStores.getDataStore(FavoriteAppsRegistry.FILE_NAME), applicationIOScope) } + + val browserHistory by lazy { BrowserHistoryRegistry(appStores.getDataStore(BrowserHistoryRegistry.FILE_NAME), applicationIOScope) } + // Authenticates with relays. val authCoordinator = AuthCoordinator(client, applicationIOScope) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppLauncher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppLauncher.kt index cd6fb36b17..45cfd84f05 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppLauncher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppLauncher.kt @@ -97,7 +97,7 @@ object FavoriteAppLauncher { if (nightMask == Configuration.UI_MODE_NIGHT_YES) "DARK" else "LIGHT" } } - val isFavorite = FavoriteAppsRegistry.isFavorite("url:$url") + val isFavorite = Amethyst.instance.favoriteApps.isFavorite("url:$url") val intent = NappletBrowserActivity .intent( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt index 7f98885be1..c6badf72e0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt @@ -42,9 +42,7 @@ import com.vitorpamplona.amethyst.commons.napplet.NappletIdentityWatch import com.vitorpamplona.amethyst.commons.napplet.NappletRequestRouter import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletResponse -import com.vitorpamplona.amethyst.favorites.BrowserHistoryRegistry import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry -import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.napplet.gateways.AccountNappletGateways import com.vitorpamplona.amethyst.napplethost.NappletIpc @@ -203,8 +201,9 @@ class NappletBrokerService : Service() { if (msg.what == NappletIpc.MSG_RECORD_HISTORY) { val data = msg.data ?: return true val url = data.getString(NappletIpc.KEY_HISTORY_URL)?.takeIf { it.isNotBlank() } ?: return true - BrowserHistoryRegistry.init(applicationContext) - BrowserHistoryRegistry.record(url, data.getString(NappletIpc.KEY_HISTORY_TITLE).orEmpty()) + val history = Amethyst.instance.browserHistory + history.init() + history.record(url, data.getString(NappletIpc.KEY_HISTORY_TITLE).orEmpty()) return true } @@ -223,12 +222,13 @@ class NappletBrokerService : Service() { val data = msg.data ?: return true val url = data.getString(NappletIpc.KEY_FAVORITE_URL)?.takeIf { it.isNotBlank() } ?: return true val label = data.getString(NappletIpc.KEY_FAVORITE_LABEL).orEmpty().ifBlank { url } - FavoriteAppsRegistry.init(applicationContext) + val favorites = Amethyst.instance.favoriteApps + favorites.init() val id = "url:$url" - if (FavoriteAppsRegistry.isFavorite(id)) { - FavoriteAppsRegistry.remove(id) + if (favorites.isFavorite(id)) { + favorites.remove(id) } else { - FavoriteAppsRegistry.add(FavoriteApp.WebApp(url, label, System.currentTimeMillis())) + favorites.add(FavoriteApp.WebApp(url, label, System.currentTimeMillis())) } return true } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt index 42c7f91d64..14d81fdaee 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt @@ -43,6 +43,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.browser.OmniboxInput import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon @@ -60,7 +61,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.Size25Modifier import com.vitorpamplona.amethyst.commons.ui.theme.Size27Modifier import com.vitorpamplona.amethyst.commons.ui.theme.onSurface65 import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry -import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry import com.vitorpamplona.amethyst.favorites.rememberNappletIconModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -112,7 +112,8 @@ fun AppBottomBar( // Favorite entries in the unified list resolve to a live favorite for their icon/label and to an // embedded-tab route. Both kinds embed in-process (WebApp → browser surface, NostrApp → napplet // surface), so such a tab swaps in place rather than launching an activity from the bottom row. - val favorites by FavoriteAppsRegistry.favorites.collectAsStateWithLifecycle() + val favorites by Amethyst.instance.favoriteApps.favorites + .collectAsStateWithLifecycle() val isKeyboardState by keyboardAsState() if (isKeyboardState == KeyboardState.Closed) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppNavigationRail.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppNavigationRail.kt index b51d1059b5..30dddb074a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppNavigationRail.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppNavigationRail.kt @@ -36,9 +36,9 @@ import androidx.navigation.NavDestination import androidx.navigation.NavDestination.Companion.hasRoute import androidx.navigation.NavHostController import androidx.navigation.compose.currentBackStackEntryAsState +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.model.navigation.BottomBarEntry import com.vitorpamplona.amethyst.commons.model.navigation.Route -import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry import com.vitorpamplona.amethyst.ui.navigation.navs.Nav import com.vitorpamplona.amethyst.ui.navigation.routes.getRouteWithArguments import com.vitorpamplona.amethyst.ui.navigation.topbars.LoggedInUserPictureDrawer @@ -58,7 +58,8 @@ fun AppNavigationRail( ) { val items by accountViewModel.account.settings.syncedSettings.navigation.bottomBarItems .collectAsStateWithLifecycle() - val favorites by FavoriteAppsRegistry.favorites.collectAsStateWithLifecycle() + val favorites by Amethyst.instance.favoriteApps.favorites + .collectAsStateWithLifecycle() val favoritesById = remember(favorites) { favorites.associateBy { it.id } } val reselectCoordinator = LocalTabReselectCoordinator.current diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/BrowserScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/BrowserScreen.kt index 06f038fb22..d4e10ff587 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/BrowserScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/BrowserScreen.kt @@ -75,6 +75,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.commons.browser.BrowserHistoryEntry import com.vitorpamplona.amethyst.commons.browser.DefaultWebClients import com.vitorpamplona.amethyst.commons.browser.OmniboxInput import com.vitorpamplona.amethyst.commons.browser.OmniboxSuggestions @@ -101,11 +102,8 @@ import com.vitorpamplona.amethyst.commons.resources.favorite_app_remove import com.vitorpamplona.amethyst.commons.resources.favorite_app_still_loading import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.note.ArrowBackIcon -import com.vitorpamplona.amethyst.favorites.BrowserHistoryEntry -import com.vitorpamplona.amethyst.favorites.BrowserHistoryRegistry import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry import com.vitorpamplona.amethyst.favorites.FavoriteAppLauncher -import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry import com.vitorpamplona.amethyst.favorites.PreloadFavoriteNostrApps import com.vitorpamplona.amethyst.favorites.rememberNappletIconModel import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar @@ -154,8 +152,10 @@ private fun BrowserLauncher( ) { val context = LocalContext.current val appStillLoadingStr = stringRes(Res.string.favorite_app_still_loading) - val apps by FavoriteAppsRegistry.favorites.collectAsStateWithLifecycle() - val history by BrowserHistoryRegistry.history.collectAsStateWithLifecycle() + val apps by Amethyst.instance.favoriteApps.favorites + .collectAsStateWithLifecycle() + val history by Amethyst.instance.browserHistory.history + .collectAsStateWithLifecycle() val iconKeys by BrowserIconRegistry.keys.collectAsStateWithLifecycle() // Fetch favorited nsite/napplet manifests up front so tapping one launches immediately instead of @@ -235,10 +235,10 @@ private fun BrowserLauncher( label: String, ) { val id = "url:$url" - if (FavoriteAppsRegistry.isFavorite(id)) { - FavoriteAppsRegistry.remove(id) + if (Amethyst.instance.favoriteApps.isFavorite(id)) { + Amethyst.instance.favoriteApps.remove(id) } else { - FavoriteAppsRegistry.add(FavoriteApp.WebApp(url, label.ifBlank { OmniboxInput.hostOf(url) ?: url }, System.currentTimeMillis())) + Amethyst.instance.favoriteApps.add(FavoriteApp.WebApp(url, label.ifBlank { OmniboxInput.hostOf(url) ?: url }, System.currentTimeMillis())) } } @@ -290,7 +290,7 @@ private fun BrowserLauncher( historyUrls = historyUrls, onOpen = { open(it.url) }, onToggleFavorite = { toggleFavorite(it.url, it.label) }, - onRemoveFromHistory = { BrowserHistoryRegistry.remove(it) }, + onRemoveFromHistory = { Amethyst.instance.browserHistory.remove(it) }, modifier = contentModifier, ) else -> { @@ -306,11 +306,11 @@ private fun BrowserLauncher( nsites = followedNsites, napplets = followedNapplets, onOpenApp = { FavoriteAppLauncher.launch(context, it, appStillLoadingStr) }, - onRemoveApp = { FavoriteAppsRegistry.remove(it.id) }, - onAddApp = { FavoriteAppsRegistry.add(it) }, + onRemoveApp = { Amethyst.instance.favoriteApps.remove(it.id) }, + onAddApp = { Amethyst.instance.favoriteApps.add(it) }, onOpenUrl = { open(it) }, onToggleRecentFavorite = { entry -> toggleFavorite(entry.url, entry.title.ifBlank { entry.host }) }, - onRemoveRecent = { BrowserHistoryRegistry.remove(it) }, + onRemoveRecent = { Amethyst.instance.browserHistory.remove(it) }, modifier = contentModifier, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/WebAppScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/WebAppScreen.kt index 8d0c221e5a..424c15c1bc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/WebAppScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/WebAppScreen.kt @@ -54,7 +54,6 @@ import com.vitorpamplona.amethyst.commons.resources.browser_unsupported import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.favorites.FavoriteAppLauncher -import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry import com.vitorpamplona.amethyst.napplet.WebAppNetworkRegistry import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -110,7 +109,8 @@ private fun EmbeddedWebAppTab( // can opt one out and it must stick). Only meaningful when Tor is actually available. var torOn by remember { mutableStateOf(proxyAvailable && WebAppNetworkRegistry.useTor(url)) } - val apps by FavoriteAppsRegistry.favorites.collectAsStateWithLifecycle() + val apps by Amethyst.instance.favoriteApps.favorites + .collectAsStateWithLifecycle() val isFavorite = remember(apps, currentUrl) { apps.any { it is FavoriteApp.WebApp && it.url == currentUrl } } val backgroundColor = MaterialTheme.colorScheme.background.toArgb() @@ -147,10 +147,10 @@ private fun EmbeddedWebAppTab( isFavorite = isFavorite, onFavorite = { val favId = "url:$currentUrl" - if (FavoriteAppsRegistry.isFavorite(favId)) { - FavoriteAppsRegistry.remove(favId) + if (Amethyst.instance.favoriteApps.isFavorite(favId)) { + Amethyst.instance.favoriteApps.remove(favId) } else { - FavoriteAppsRegistry.add(FavoriteApp.WebApp(currentUrl, hostLabel(currentUrl), System.currentTimeMillis())) + Amethyst.instance.favoriteApps.add(FavoriteApp.WebApp(currentUrl, hostLabel(currentUrl), System.currentTimeMillis())) } }, // NIP-07 grants for a plain web client are keyed per visited origin as `browser:` diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabPreloadSweeper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabPreloadSweeper.kt index 48c143ee19..8c68f970f1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabPreloadSweeper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabPreloadSweeper.kt @@ -23,8 +23,8 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.embed import android.content.Context import android.os.Build import androidx.annotation.RequiresApi +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.model.navigation.favoriteIds -import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry import com.vitorpamplona.amethyst.napplet.NappletNetworkRegistry import com.vitorpamplona.amethyst.napplet.WebAppNetworkRegistry import kotlinx.coroutines.CoroutineScope @@ -113,7 +113,9 @@ object EmbeddedTabPreloadSweeper { NappletNetworkRegistry.awaitReady() var attempt = 0 while (isActive) { - val byId = FavoriteAppsRegistry.favorites.value.associateBy { it.id } + val byId = + Amethyst.instance.favoriteApps.favorites.value + .associateBy { it.id } var stillPending = false for (id in favoriteIds) { val app = byId[id] ?: continue diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/FavoriteAppManifestPreloader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/FavoriteAppManifestPreloader.kt index b38eee524f..9edabf3bdb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/FavoriteAppManifestPreloader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/FavoriteAppManifestPreloader.kt @@ -26,9 +26,9 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.runtime.remember import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp import com.vitorpamplona.amethyst.commons.model.cache.LocalCache -import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip01Core.core.Event @@ -60,7 +60,8 @@ private const val MANIFEST_OFFLINE_FALLBACK_MS = 2_000L */ @Composable fun FavoriteAppManifestPreloader(accountViewModel: AccountViewModel) { - val favorites by FavoriteAppsRegistry.favorites.collectAsStateWithLifecycle() + val favorites by Amethyst.instance.favoriteApps.favorites + .collectAsStateWithLifecycle() val coordinates = remember(favorites) { favorites.filterIsInstance().map { it.coordinate } @@ -91,7 +92,7 @@ private fun WatchFavoriteManifest( LaunchedEffect(event?.id) { val resolved = event ?: return@LaunchedEffect withContext(Dispatchers.IO) { - FavoriteAppsRegistry.cacheManifest(coordinate, resolved.toJson()) + Amethyst.instance.favoriteApps.cacheManifest(coordinate, resolved.toJson()) } } @@ -103,7 +104,7 @@ private fun WatchFavoriteManifest( delay(MANIFEST_OFFLINE_FALLBACK_MS) if (LocalCache.getAddressableNoteIfExists(coordinate)?.event != null) return@LaunchedEffect withContext(Dispatchers.IO) { - val cached = FavoriteAppsRegistry.cachedManifest(coordinate) ?: return@withContext + val cached = Amethyst.instance.favoriteApps.cachedManifest(coordinate) ?: return@withContext Event.fromJsonOrNull(cached)?.let { LocalCache.justConsume(it, null, false) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/FavoriteAppsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/FavoriteAppsScreen.kt index b29f7462c2..c4e69e44f4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/FavoriteAppsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/FavoriteAppsScreen.kt @@ -59,6 +59,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.browser.OmniboxInput import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon @@ -74,7 +75,6 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry import com.vitorpamplona.amethyst.favorites.FavoriteAppLauncher -import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry import com.vitorpamplona.amethyst.favorites.PreloadFavoriteNostrApps import com.vitorpamplona.amethyst.favorites.rememberNappletIconModel import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar @@ -94,7 +94,8 @@ fun FavoriteAppsScreen( ) { val appStillLoadingStr = stringRes(Res.string.favorite_app_still_loading) val context = LocalContext.current - val apps by FavoriteAppsRegistry.favorites.collectAsStateWithLifecycle() + val apps by Amethyst.instance.favoriteApps.favorites + .collectAsStateWithLifecycle() // Fetch favorited nsite/napplet manifests up front so a tap launches immediately instead of showing // "isn't loaded yet" until the user happens to visit the nsite/napplet feed. @@ -126,7 +127,7 @@ fun FavoriteAppsScreen( FavoriteAppsGrid( apps = apps, onOpen = { FavoriteAppLauncher.launch(context, it, appStillLoadingStr) }, - onRemove = { FavoriteAppsRegistry.remove(it.id) }, + onRemove = { Amethyst.instance.favoriteApps.remove(it.id) }, modifier = Modifier .fillMaxSize() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/FavoriteToggleButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/FavoriteToggleButton.kt index b85cf1126c..0afea73440 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/FavoriteToggleButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/FavoriteToggleButton.kt @@ -27,6 +27,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols @@ -34,7 +35,6 @@ import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.favorite_app_add import com.vitorpamplona.amethyst.commons.resources.favorite_app_remove import com.vitorpamplona.amethyst.commons.ui.stringRes -import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry /** * A star toggle that pins/unpins an nsite or napplet (a [FavoriteApp.NostrApp]) by its addressable @@ -47,16 +47,17 @@ fun FavoriteToggleButton( label: String, iconUrl: String? = null, ) { - val apps by FavoriteAppsRegistry.favorites.collectAsStateWithLifecycle() + val apps by Amethyst.instance.favoriteApps.favorites + .collectAsStateWithLifecycle() val id = "nostr:$coordinate" val isFavorite = remember(apps, id) { apps.any { it.id == id } } IconButton( onClick = { if (isFavorite) { - FavoriteAppsRegistry.remove(id) + Amethyst.instance.favoriteApps.remove(id) } else { - FavoriteAppsRegistry.add(FavoriteApp.NostrApp(coordinate, label, System.currentTimeMillis(), iconUrl)) + Amethyst.instance.favoriteApps.add(FavoriteApp.NostrApp(coordinate, label, System.currentTimeMillis(), iconUrl)) } }, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/NostrAppScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/NostrAppScreen.kt index 1067fce67e..552dfc6441 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/NostrAppScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/NostrAppScreen.kt @@ -54,6 +54,7 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp import com.vitorpamplona.amethyst.commons.model.navigation.Route import com.vitorpamplona.amethyst.commons.model.navigation.favoriteIds @@ -72,7 +73,6 @@ import com.vitorpamplona.amethyst.commons.resources.favorite_notice_uploaded import com.vitorpamplona.amethyst.commons.ui.loadStringRes import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.favorites.FavoriteAppLauncher -import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry import com.vitorpamplona.amethyst.napplethost.HostProfile import com.vitorpamplona.amethyst.napplethost.NappletEmbedContract import com.vitorpamplona.amethyst.napplethost.NappletHostContract @@ -147,7 +147,8 @@ private fun EmbeddedNostrAppTab( var canGoBack by remember { mutableStateOf(false) } var showAccess by remember { mutableStateOf(false) } - val apps by FavoriteAppsRegistry.favorites.collectAsStateWithLifecycle() + val apps by Amethyst.instance.favoriteApps.favorites + .collectAsStateWithLifecycle() val isFavorite = remember(apps, coordinate) { apps.any { it.id == "nostr:$coordinate" } } val controller = @@ -182,10 +183,10 @@ private fun EmbeddedNostrAppTab( isFavorite = isFavorite, onFavorite = { val favId = "nostr:$coordinate" - if (FavoriteAppsRegistry.isFavorite(favId)) { - FavoriteAppsRegistry.remove(favId) + if (Amethyst.instance.favoriteApps.isFavorite(favId)) { + Amethyst.instance.favoriteApps.remove(favId) } else { - FavoriteAppsRegistry.add(FavoriteApp.NostrApp(coordinate, title, System.currentTimeMillis())) + Amethyst.instance.favoriteApps.add(FavoriteApp.NostrApp(coordinate, title, System.currentTimeMillis())) } }, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/BottomBarSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/BottomBarSettingsScreen.kt index d2d91c37a6..38b405e133 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/BottomBarSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/BottomBarSettingsScreen.kt @@ -67,6 +67,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon import com.vitorpamplona.amethyst.commons.icons.symbols.Icon @@ -92,7 +93,6 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.commons.ui.theme.Size22Modifier import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonRow -import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarCategories import com.vitorpamplona.amethyst.ui.navigation.bottombars.GroupEntryAvatar import com.vitorpamplona.amethyst.ui.navigation.bottombars.GroupEntryDisplay @@ -498,7 +498,8 @@ private fun PickerChildren( ) { when (item) { NavBarItem.BROWSER -> { - val favorites by FavoriteAppsRegistry.favorites.collectAsStateWithLifecycle() + val favorites by Amethyst.instance.favoriteApps.favorites + .collectAsStateWithLifecycle() if (favorites.isEmpty()) { EmptyChildHint(Res.string.bottom_bar_settings_no_favorites) } else { @@ -817,7 +818,8 @@ private fun rememberPinnedVisual( PinnedVisual.Glyph(def?.icon ?: MaterialSymbols.Apps, def?.let { stringRes(it.labelRes) } ?: "") } is BottomBarEntry.Favorite -> { - val favorites by FavoriteAppsRegistry.favorites.collectAsStateWithLifecycle() + val favorites by Amethyst.instance.favoriteApps.favorites + .collectAsStateWithLifecycle() val app = favorites.firstOrNull { it.id == entry.favoriteId } if (app != null) PinnedVisual.Favorite(app) else PinnedVisual.Glyph(MaterialSymbols.Public, "") } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/BrowserHistoryRegistry.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserHistoryRegistry.kt similarity index 69% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/BrowserHistoryRegistry.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserHistoryRegistry.kt index e9988b23e7..3d4f010948 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/BrowserHistoryRegistry.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserHistoryRegistry.kt @@ -18,36 +18,23 @@ * 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.favorites +package com.vitorpamplona.amethyst.commons.browser -import android.content.Context import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey -import com.vitorpamplona.amethyst.Amethyst -import com.vitorpamplona.amethyst.commons.browser.OmniboxInput import com.vitorpamplona.quartz.nip01Core.core.JsonMapper import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.serialization.Serializable - -/** - * The browser-history file, on the app-wide holder rather than a `Context` delegate. - * Same path the delegate resolved to, so nothing migrates. - * - * Main process only: [Amethyst.instance] is deliberately unset in the - * `:napplet` sandbox. - */ -private val browserHistoryDataStore: DataStore - get() = Amethyst.instance.appStores.getDataStore("browser_history") +import kotlin.concurrent.Volatile /** * One device-local visited site, keyed by full [url]. [visitCount]/[lastVisitedAt] drive frecency ranking @@ -65,39 +52,40 @@ data class BrowserHistoryEntry( /** * The browser's visit history — the data behind the omnibox suggestions, alongside the user's favorites. * - * **Only pages that actually loaded land here.** [record] is called from the `:napplet` browser host - * (relayed over IPC through `NappletBrokerService`) on a *successful* main-frame page-finish — never from - * the address bar as the user types — so misspelled/never-resolved hosts never pollute the list. Bounded - * to [MAX_ENTRIES] most-recent entries. + * **Only pages that actually loaded land here.** [record] is meant to be called on a *successful* + * main-frame page-finish — never from the address bar as the user types — so misspelled/never-resolved + * hosts never pollute the list. Bounded to [MAX_ENTRIES] most-recent entries. On Android the call is + * relayed from the `:napplet` browser host over IPC through `NappletBrokerService`. * - * Lives only in the **main process** (the launcher/omnibox consume it; the keyless `:napplet` sandbox - * never reads it). Same shape as [FavoriteAppsRegistry]: an authoritative in-memory [StateFlow] for - * synchronous Compose reads, with write-through persistence to a DataStore on a background scope. + * Same shape as [com.vitorpamplona.amethyst.commons.favorites.FavoriteAppsRegistry]: an authoritative + * in-memory [StateFlow] for synchronous Compose reads, write-through persistence to [store] on [scope], + * and the [DataStore] handed in rather than reached for, so the caller's store holder stays the single + * registry and nothing here depends on a front end. + * + * One instance per process. On Android the launcher/omnibox in the **main** process own it; the keyless + * `:napplet` sandbox never builds one. */ -object BrowserHistoryRegistry { - private val KEY = stringPreferencesKey("history") - private const val MAX_ENTRIES = 500 - +class BrowserHistoryRegistry( + private val store: DataStore, + private val scope: CoroutineScope, +) { private val _history = MutableStateFlow>(emptyList()) val history: StateFlow> = _history.asStateFlow() - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + // Gates persistence until init() has been called: writing before hydration has been scheduled + // would flush a partial list over the stored one. Set synchronously in init(), so the merge it + // launches still persists whatever the session recorded in the meantime. + @Volatile private var started = false - @Volatile private var appContext: Context? = null - - @Volatile private var hydrated = false - - /** Binds the app context and hydrates the on-disk list into [history]. Idempotent. */ - fun init(context: Context) { - if (appContext != null) return - val ctx = context.applicationContext - appContext = ctx + /** Hydrates the on-disk list into [history]. Idempotent. */ + fun init() { + if (started) return + started = true scope.launch { - val json = browserHistoryDataStore.data.first()[KEY] + val json = store.data.first()[KEY] val loaded = if (json != null) decode(json) else emptyList() // Merge disk under anything already recorded this session (session wins, newest-first). update { current -> dedupeNewestFirst(current + loaded) } - hydrated = true } } @@ -110,7 +98,7 @@ object BrowserHistoryRegistry { title: String, ) { val host = OmniboxInput.hostOf(url) ?: url - val now = System.currentTimeMillis() + val now = TimeUtils.nowMillis() update { current -> val existing = current.firstOrNull { it.url == url } val entry = @@ -146,9 +134,9 @@ object BrowserHistoryRegistry { } private fun persist(json: String) { - appContext ?: return + if (!started) return scope.launch { - browserHistoryDataStore.edit { it[KEY] = json } + store.edit { it[KEY] = json } } } @@ -161,4 +149,12 @@ object BrowserHistoryRegistry { Log.w("BrowserHistoryRegistry", "Failed to decode history", e) emptyList() } + + companion object { + /** Same file the `Context.preferencesDataStore("browser_history")` delegate resolved to. */ + const val FILE_NAME = "browser_history" + + private val KEY = stringPreferencesKey("history") + private const val MAX_ENTRIES = 500 + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppsRegistry.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/favorites/FavoriteAppsRegistry.kt similarity index 69% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppsRegistry.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/favorites/FavoriteAppsRegistry.kt index ad9d03d325..74c1480770 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppsRegistry.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/favorites/FavoriteAppsRegistry.kt @@ -18,89 +18,77 @@ * 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.favorites +package com.vitorpamplona.amethyst.commons.favorites -import android.content.Context import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey -import com.vitorpamplona.amethyst.Amethyst -import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp +import com.vitorpamplona.amethyst.commons.util.ConcurrentSet import com.vitorpamplona.quartz.nip01Core.core.JsonMapper import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.serialization.Serializable -import java.util.concurrent.ConcurrentHashMap - -/** - * The favorite-apps file, on the app-wide holder rather than a `Context` delegate. - * Same path the delegate resolved to, so nothing migrates. - * - * Main process only: [Amethyst.instance] is deliberately unset in the - * `:napplet` sandbox. - */ -private val favoriteAppsDataStore: DataStore - get() = Amethyst.instance.appStores.getDataStore("favorite_apps") +import kotlin.concurrent.Volatile /** * The user's device-local list of [FavoriteApp]s — the single source of truth shared by the bottom * bar, the Favorite Apps grid, and the browser launcher. Ordered (the user can reorder); de-duplicated * by [FavoriteApp.id]. * - * Lives only in the **main process** (the launcher/UI consume it); the keyless `:napplet` sandbox never - * touches it. An in-memory [StateFlow] is authoritative for the session so Compose can observe it - * synchronously, with write-through persistence to a DataStore on a background scope. The list is - * stored as a single JSON array under one key (small, bounded, hand-curated data — no need for one key - * per entry). + * An in-memory [StateFlow] is authoritative for the session so Compose can observe it synchronously, + * with write-through persistence to [store] on [scope]. The list is stored as a single JSON array + * under one key (small, bounded, hand-curated data — no need for one key per entry). + * + * Takes its [DataStore] rather than reaching for one, for the same reason + * `DataStoreSearchHistoryStorage` does: DataStore refuses a second live instance on a path that + * already has one, so the caller's store holder stays the single registry. That is also what keeps + * this class off any one front end — the Android app builds it in `AppModules` from + * `appStores.getDataStore(FILE_NAME)`, and nothing here knows about `Context` or the app singleton. + * + * One instance per process. On Android the launcher/UI in the **main** process own it; the keyless + * `:napplet` sandbox never builds one. */ -object FavoriteAppsRegistry { - private val KEY = stringPreferencesKey("favorites") - - // Raw manifest event JSON for each favorited [FavoriteApp.NostrApp], keyed by its addressable - // coordinate. Cached so a pinned nsite/napplet resolves instantly on the next cold start — and - // offline — instead of waiting on a relay round-trip the way a [FavoriteApp.WebApp]'s URL never - // has to. The relay subscription that warms these favorites keeps the cache fresh. - private val MANIFESTS_KEY = stringPreferencesKey("manifests") - +class FavoriteAppsRegistry( + private val store: DataStore, + private val scope: CoroutineScope, +) { private val _favorites = MutableStateFlow>(emptyList()) val favorites: StateFlow> = _favorites.asStateFlow() private val manifestCache = MutableStateFlow>(emptyMap()) - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + // Gates persistence until init() has been called: writing before hydration has been scheduled + // would flush a partial list over the stored one. Set synchronously in init(), so the merge it + // launches still persists whatever the session added in the meantime. + @Volatile private var started = false - @Volatile private var appContext: Context? = null - - // Hydration runs async on a background scope, so the user can add/remove before the disk list - // merges in. [removedBeforeHydration] tombstones any id removed in that window, so the merge can't + // Hydration runs async on [scope], so the user can add/remove before the disk list merges in. + // [removedBeforeHydration] tombstones any id removed in that window, so the merge can't // resurrect a just-deleted favorite from disk. @Volatile private var hydrated = false - private val removedBeforeHydration = ConcurrentHashMap.newKeySet() + private val removedBeforeHydration = ConcurrentSet() - /** Binds the app context and hydrates the on-disk list into [favorites]. Idempotent. */ - fun init(context: Context) { - if (appContext != null) return - val ctx = context.applicationContext - appContext = ctx + /** Hydrates the on-disk list into [favorites]. Idempotent. */ + fun init() { + if (started) return + started = true scope.launch { - val prefs = favoriteAppsDataStore.data.first() + val prefs = store.data.first() val loaded = prefs[KEY]?.let { decode(it) } ?: emptyList() // Don't clobber adds made in this session before hydration finished, and don't resurrect // anything the user removed in that same window. - update { current -> (loaded.filterNot { it.id in removedBeforeHydration } + current).distinctBy { it.id } } + update { current -> (loaded.filterNot { removedBeforeHydration.contains(it.id) } + current).distinctBy { it.id } } // Same race rules for the manifest cache: a cacheManifest() in this session wins over the // disk copy, and a manifest whose favorite was removed pre-hydration must not come back. val loadedManifests = prefs[MANIFESTS_KEY]?.let { decodeManifests(it) } ?: emptyMap() - updateManifests { current -> loadedManifests.filterKeys { "nostr:$it" !in removedBeforeHydration } + current } + updateManifests { current -> loadedManifests.filterKeys { !removedBeforeHydration.contains("nostr:$it") } + current } hydrated = true removedBeforeHydration.clear() @@ -138,27 +126,23 @@ object FavoriteAppsRegistry { val next = transform(_favorites.value) if (next == _favorites.value) return _favorites.value = next - persist(encode(next)) + persist(KEY, encode(next)) } private inline fun updateManifests(transform: (Map) -> Map) { val next = transform(manifestCache.value) if (next == manifestCache.value) return manifestCache.value = next - persistManifests(encodeManifests(next)) + persist(MANIFESTS_KEY, encodeManifests(next)) } - private fun persist(json: String) { - appContext ?: return + private fun persist( + key: Preferences.Key, + json: String, + ) { + if (!started) return scope.launch { - favoriteAppsDataStore.edit { it[KEY] = json } - } - } - - private fun persistManifests(json: String) { - appContext ?: return - scope.launch { - favoriteAppsDataStore.edit { it[MANIFESTS_KEY] = json } + store.edit { it[key] = json } } } @@ -199,9 +183,6 @@ object FavoriteAppsRegistry { emptyList() } - private const val TYPE_NOSTR = "nostr" - private const val TYPE_URL = "url" - // --- Manifest cache persistence ------------------------------------------------------------- // Stored as a flat list of (coordinate, json) records under one key — same single-key, hand-curated // shape as the favorites list, so we never serialize a raw polymorphic map. @@ -221,4 +202,20 @@ object FavoriteAppsRegistry { Log.w("FavoriteAppsRegistry", "Failed to decode favorite manifests", e) emptyMap() } + + companion object { + /** Same file the `Context.preferencesDataStore("favorite_apps")` delegate resolved to. */ + const val FILE_NAME = "favorite_apps" + + private val KEY = stringPreferencesKey("favorites") + + // Raw manifest event JSON for each favorited [FavoriteApp.NostrApp], keyed by its addressable + // coordinate. Cached so a pinned nsite/napplet resolves instantly on the next cold start — and + // offline — instead of waiting on a relay round-trip the way a [FavoriteApp.WebApp]'s URL never + // has to. The relay subscription that warms these favorites keeps the cache fresh. + private val MANIFESTS_KEY = stringPreferencesKey("manifests") + + private const val TYPE_NOSTR = "nostr" + private const val TYPE_URL = "url" + } } diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserHistoryRegistryTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserHistoryRegistryTest.kt new file mode 100644 index 0000000000..108389ccfb --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserHistoryRegistryTest.kt @@ -0,0 +1,218 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.browser + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import okio.Path.Companion.toOkioPath +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +/** + * The visit history's ranking inputs and its bound, neither of which had coverage while this was an + * Android-side `object`. The omnibox ranks on [BrowserHistoryEntry.visitCount] and + * [BrowserHistoryEntry.lastVisitedAt], so a bump that does not bump is a silently wrong suggestion + * order rather than a crash. + */ +class BrowserHistoryRegistryTest { + @get:Rule + val folder = TemporaryFolder() + + private var seq = 0 + + private fun newFile() = File(folder.root, "browser_history_${seq++}.preferences_pb") + + private fun store( + scope: CoroutineScope, + file: File, + ): DataStore = PreferenceDataStoreFactory.createWithPath(scope = scope, produceFile = { file.toOkioPath() }) + + /** + * One app "session" over [file]. Each gets its own [Job] so the DataStore it opened is released + * when the session is cancelled: DataStore refuses a second live instance on a path that already + * has one, which is exactly why production keeps a single instance per file in the store holder. + * A restart test that skipped this would read an empty store and look like data loss. + */ + private fun TestScope.session(file: File): Pair { + val scope = CoroutineScope(coroutineContext + Job()) + return BrowserHistoryRegistry(store(scope, file), scope) to scope + } + + /** A revisit is a bump, not a second row — this is what makes frecency mean anything. */ + @Test + fun revisitingAUrlBumpsTheCountInsteadOfAddingARow() = + runTest { + val (registry, _) = session(newFile()) + registry.init() + advanceUntilIdle() + + registry.record("https://example.com/a", "A") + registry.record("https://example.com/a", "A again") + + assertEquals("one row for one url", 1, registry.history.value.size) + assertEquals( + "visit count bumped", + 2, + registry.history.value + .single() + .visitCount, + ) + assertEquals( + "title refreshed", + "A again", + registry.history.value + .single() + .title, + ) + } + + /** A page that finishes loading with no must not blank out the name the user recognises. */ + @Test + fun aBlankTitleOnARevisitKeepsTheOldOne() = + runTest { + val (registry, _) = session(newFile()) + registry.init() + advanceUntilIdle() + + registry.record("https://example.com/a", "Real Title") + registry.record("https://example.com/a", " ") + + assertEquals( + "the blank did not overwrite it", + "Real Title", + registry.history.value + .single() + .title, + ) + } + + /** Most recent first, because that is the order the omnibox shows them in. */ + @Test + fun theMostRecentVisitLeads() = + runTest { + val (registry, _) = session(newFile()) + registry.init() + advanceUntilIdle() + + registry.record("https://first.example", "First") + registry.record("https://second.example", "Second") + + assertEquals( + "newest at the front", + listOf("https://second.example", "https://first.example"), + registry.history.value.map { it.url }, + ) + } + + /** The bound is what stops an unbounded JSON blob being rewritten on every page load. */ + @Test + fun historyIsCappedAtFiveHundredEntries() = + runTest { + val (registry, _) = session(newFile()) + registry.init() + advanceUntilIdle() + + repeat(505) { registry.record("https://example.com/page$it", "Page $it") } + + assertEquals("capped", 500, registry.history.value.size) + assertEquals( + "and it is the oldest that fell off", + "https://example.com/page504", + registry.history.value + .first() + .url, + ) + assertTrue("page0 is gone", registry.history.value.none { it.url == "https://example.com/page0" }) + } + + @Test + fun historySurvivesARestart() = + runTest { + val file = newFile() + + val (first, firstScope) = session(file) + first.init() + advanceUntilIdle() + first.record("https://example.com/a", "A") + advanceUntilIdle() + firstScope.cancel() + + val (second, _) = session(file) + second.init() + advanceUntilIdle() + + assertEquals("hydrated from disk", listOf("https://example.com/a"), second.history.value.map { it.url }) + assertEquals( + "with its count", + 1, + second.history.value + .single() + .visitCount, + ) + } + + /** Clearing is a privacy action: it has to reach disk, not just the in-memory flow. */ + @Test + fun clearingEmptiesTheStoredHistoryToo() = + runTest { + val file = newFile() + + val (first, firstScope) = session(file) + first.init() + advanceUntilIdle() + first.record("https://example.com/a", "A") + advanceUntilIdle() + first.clear() + advanceUntilIdle() + firstScope.cancel() + + val (second, _) = session(file) + second.init() + advanceUntilIdle() + + assertTrue("nothing came back", second.history.value.isEmpty()) + } + + @Test + fun removingOneUrlLeavesTheRest() = + runTest { + val (registry, _) = session(newFile()) + registry.init() + advanceUntilIdle() + + registry.record("https://keep.example", "Keep") + registry.record("https://drop.example", "Drop") + registry.remove("https://drop.example") + + assertEquals("only the one", listOf("https://keep.example"), registry.history.value.map { it.url }) + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/favorites/FavoriteAppsRegistryTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/favorites/FavoriteAppsRegistryTest.kt new file mode 100644 index 0000000000..1d4e8f52d1 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/favorites/FavoriteAppsRegistryTest.kt @@ -0,0 +1,248 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.favorites + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import okio.Path.Companion.toOkioPath +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +/** + * The registry's disk lifecycle, which had no coverage while it was an Android-side `object` reaching + * into `Amethyst.instance` for its store. Taking the [DataStore] as a constructor argument is what + * makes these reachable. + * + * Every test drives the registry on the test scheduler, so "hydration has not finished yet" is a state + * the test controls rather than races: [advanceUntilIdle] is the only thing that lets the coroutine + * [FavoriteAppsRegistry.init] launches actually run. + */ +class FavoriteAppsRegistryTest { + @get:Rule + val folder = TemporaryFolder() + + private var seq = 0 + + private fun newFile() = File(folder.root, "favorite_apps_${seq++}.preferences_pb") + + private fun store( + scope: CoroutineScope, + file: File, + ): DataStore<Preferences> = PreferenceDataStoreFactory.createWithPath(scope = scope, produceFile = { file.toOkioPath() }) + + /** + * One app "session" over [file]. Each gets its own [Job] so the DataStore it opened is released + * when the session is cancelled: DataStore refuses a second live instance on a path that already + * has one, which is exactly why production keeps a single instance per file in the store holder. + * A restart test that skipped this would read an empty store and look like data loss. + */ + private fun TestScope.session(file: File): Pair<FavoriteAppsRegistry, CoroutineScope> { + val scope = CoroutineScope(coroutineContext + Job()) + return FavoriteAppsRegistry(store(scope, file), scope) to scope + } + + private fun web( + url: String, + label: String = url, + ) = FavoriteApp.WebApp(url, label, addedAt = 1L) + + /** What the user actually notices: a favorite added in one session is there in the next. */ + @Test + fun aFavoriteSurvivesARestart() = + runTest { + val file = newFile() + + val (first, firstScope) = session(file) + first.init() + advanceUntilIdle() + first.add(web("https://example.com")) + advanceUntilIdle() + firstScope.cancel() + + val (second, _) = session(file) + second.init() + advanceUntilIdle() + + assertEquals( + "the favorite written by the first session is what the second one hydrates", + listOf("url:https://example.com"), + second.favorites.value.map { it.id }, + ) + } + + /** + * The tombstone. Removing between init() and the merge landing must win, or the disk copy + * resurrects a favorite the user just deleted — and then persists it again. + */ + @Test + fun hydrationDoesNotResurrectAFavoriteRemovedBeforeItFinished() = + runTest { + val file = newFile() + + val (seeded, seededScope) = session(file) + seeded.init() + advanceUntilIdle() + seeded.add(web("https://gone.example")) + seeded.add(web("https://kept.example")) + advanceUntilIdle() + seededScope.cancel() + + val (reopened, _) = session(file) + reopened.init() + // Still inside the window: init() launched the merge but nothing has run it yet. + reopened.remove("url:https://gone.example") + advanceUntilIdle() + + assertEquals( + "the removal beat the merge and must survive it", + listOf("url:https://kept.example"), + reopened.favorites.value.map { it.id }, + ) + } + + /** The other side of the same window: an add made before the merge must not be dropped by it. */ + @Test + fun hydrationKeepsAnAddMadeBeforeItFinished() = + runTest { + val file = newFile() + + val (seeded, seededScope) = session(file) + seeded.init() + advanceUntilIdle() + seeded.add(web("https://ondisk.example")) + advanceUntilIdle() + seededScope.cancel() + + val (reopened, _) = session(file) + reopened.init() + reopened.add(web("https://thissession.example")) + advanceUntilIdle() + + assertEquals( + "both the disk copy and the pre-merge add are present", + setOf("url:https://ondisk.example", "url:https://thissession.example"), + reopened.favorites.value + .map { it.id } + .toSet(), + ) + } + + /** + * Persistence is gated on init(). Without the gate a write in that window flushes a list that has + * not merged with disk yet, which is the stored list being replaced by a partial one. + */ + @Test + fun nothingIsWrittenBeforeInit() = + runTest { + val file = newFile() + + val (registry, writerScope) = session(file) + registry.add(web("https://notpersisted.example")) + advanceUntilIdle() + + assertEquals( + "the add is live in memory", + listOf("url:https://notpersisted.example"), + registry.favorites.value.map { it.id }, + ) + writerScope.cancel() + advanceUntilIdle() + val readerScope = CoroutineScope(coroutineContext + Job()) + assertNull( + "but nothing reached disk, because init() never ran", + store(readerScope, file).data.first()[stringPreferencesKey("favorites")], + ) + readerScope.cancel() + } + + /** A favorite's cached manifest must not outlive the favorite. */ + @Test + fun removingANostrFavoriteDropsItsCachedManifest() = + runTest { + val (registry, _) = session(newFile()) + registry.init() + advanceUntilIdle() + + val coordinate = "31990:pubkey:slug" + registry.add(FavoriteApp.NostrApp(coordinate, "An App", addedAt = 1L)) + registry.cacheManifest(coordinate, """{"kind":31990}""") + assertEquals("cached while favorited", """{"kind":31990}""", registry.cachedManifest(coordinate)) + + registry.remove("nostr:$coordinate") + advanceUntilIdle() + + assertNull("and gone with the favorite", registry.cachedManifest(coordinate)) + } + + /** Garbage on disk must degrade to an empty list, never take the launcher down with it. */ + @Test + fun aCorruptStoredListHydratesAsEmpty() = + runTest { + val file = newFile() + val seedScope = CoroutineScope(coroutineContext + Job()) + store(seedScope, file).edit { it[stringPreferencesKey("favorites")] = "}not json[" } + advanceUntilIdle() + seedScope.cancel() + advanceUntilIdle() + + val (registry, _) = session(file) + registry.init() + advanceUntilIdle() + + assertTrue("decode failed softly", registry.favorites.value.isEmpty()) + } + + /** Ids are the identity: adding the same app twice is a no-op, not a duplicate row. */ + @Test + fun addingTheSameAppTwiceKeepsOneEntry() = + runTest { + val (registry, _) = session(newFile()) + registry.init() + advanceUntilIdle() + + registry.add(web("https://example.com", "First")) + registry.add(web("https://example.com", "Second")) + + assertEquals("de-duplicated by id", 1, registry.favorites.value.size) + assertEquals( + "and the first one won", + "First", + registry.favorites.value + .single() + .label, + ) + } +} From 2cd6569fe97a8b09032e58e993be3beea0ca620d Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Fri, 25 Sep 2026 20:04:34 +0000 Subject: [PATCH 38/50] refactor(commons): move the browser favicon registry out of the app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BrowserIconRegistry was the third of the trio flagged as still sitting in amethyst/, and the only one with a real platform tie: unlike the other two, its Context was not dead — it supplied filesDir for the icon directory. It is a small tie, and commons already has the shape for it. AppPreferenceStores takes `rootFilesDir: () -> Path`, so this takes `iconDir: () -> Path` and goes through commons' platformFileSystem (the expect/actual that exists because okio declares FileSystem.SYSTEM per platform). The Android app passes `{ appContext.filesDir.toOkioPath() / BrowserIconRegistry.DIR }`, which is the same filesDir/browser_icons the object used, so stored favicons are found where they were left. No bitmaps are involved anywhere — it has always been ByteArray in, `file://` string out. One behaviour change, deliberate: the startup scan now merges into `keys` instead of assigning it. A record() that landed while the scan was in flight had already written its file and added its key, and the wholesale assignment dropped it — the icon sat on disk unshown until the next launch. Not pinned by a test, and that is on purpose: making the scan finish after a concurrent record is not something I can force deterministically, so any test I wrote would pass with or without the change and would only look like coverage. 8 new tests for what is deterministic: a recorded icon reaches disk with the bytes given and is announced, icons already on disk are indexed by init(), an unknown host has no model (iconModelFor is read from composition, so it answers from `keys` rather than touching the filesystem), a missing icon directory is created rather than dropping the icon, blank hosts and empty byte arrays are ignored, and hosts are sanitized into one flat filename both when storing and when looking up — "Example.COM:8080/../etc" cannot escape the directory. Writing them repeated the lesson from f0c66955 in a new form: with the registry's scope set to runTest's backgroundScope, none of the launched disk work ran under advanceUntilIdle and every assertion failed as though the code did nothing. Same `coroutineContext + Job()` session idiom as the other two suites now. Three call sites used it as a method reference (`::iconModelFor`), which has no trailing dot and so was missed by the first pass over the callers — caught by the compiler, not by grep. Verified: :commons:jvmTest, :commons:verifyKmpPurity, :commons:compileCommonMainKotlinMetadata, :amethyst:compilePlayDebugKotlin. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AXvKXakvup4inNFfAhhr4L --- .../com/vitorpamplona/amethyst/Amethyst.kt | 3 +- .../com/vitorpamplona/amethyst/AppModules.kt | 7 + .../amethyst/favorites/BrowserIconRegistry.kt | 124 ----------- .../amethyst/favorites/NappletFavoriteIcon.kt | 7 +- .../amethyst/napplet/NappletBrokerService.kt | 6 +- .../amethyst/napplet/NappletConsentSummary.kt | 4 +- .../amethyst/napplet/NostrSignerOpLabels.kt | 6 +- .../ui/navigation/bottombars/AppBottomBar.kt | 6 +- .../screen/loggedIn/browser/BrowserScreen.kt | 8 +- .../loggedIn/favorites/FavoriteAppsScreen.kt | 6 +- .../napplets/ConnectedAppDetailScreen.kt | 3 +- .../commons/browser/BrowserIconRegistry.kt | 141 +++++++++++++ .../browser/BrowserIconRegistryTest.kt | 197 ++++++++++++++++++ 13 files changed, 369 insertions(+), 149 deletions(-) delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/BrowserIconRegistry.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserIconRegistry.kt create mode 100644 commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserIconRegistryTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt index 7266fdc6cc..5715d4e98b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt @@ -25,7 +25,6 @@ import android.content.ComponentCallbacks2 import android.os.Build import com.vitorpamplona.amethyst.commons.service.http.HttpClientEnvironment import com.vitorpamplona.amethyst.commons.service.http.MediaCallEventListener -import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry import com.vitorpamplona.amethyst.napplet.WebAppNetworkRegistry import com.vitorpamplona.amethyst.service.logging.Logging import com.vitorpamplona.amethyst.service.nests.AppForegroundRecycleHook @@ -147,7 +146,7 @@ class Amethyst : Application() { instance.browserHistory.init() // Index device-local captured favicons (main process only; decorates favorites + suggestions). - BrowserIconRegistry.init(this) + instance.browserIcons.init() // Warm the global-settings prefs off-main so the first (deliberately synchronous) read of // them does not hit disk on the main thread. See LocalPreferences.warmGlobalSettings. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 2bfc9ff3be..8c98c1f1b4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -28,6 +28,7 @@ import androidx.security.crypto.EncryptedSharedPreferences import coil3.disk.DiskCache import coil3.memory.MemoryCache import com.vitorpamplona.amethyst.commons.browser.BrowserHistoryRegistry +import com.vitorpamplona.amethyst.commons.browser.BrowserIconRegistry import com.vitorpamplona.amethyst.commons.connectedApps.DataStoreNostrSignerPermissionStore import com.vitorpamplona.amethyst.commons.connectedApps.nip46.DataStoreNip46ClientStore import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppsRegistry @@ -899,6 +900,12 @@ class AppModules( val browserHistory by lazy { BrowserHistoryRegistry(appStores.getDataStore(BrowserHistoryRegistry.FILE_NAME), applicationIOScope) } + // Favicons captured by the browser host, one PNG per host. Not a DataStore — it takes the directory + // to keep them in, the same way AppPreferenceStores takes rootFilesDir. + val browserIcons by lazy { + BrowserIconRegistry({ appContext.filesDir.toOkioPath() / BrowserIconRegistry.DIR }, applicationIOScope) + } + // Authenticates with relays. val authCoordinator = AuthCoordinator(client, applicationIOScope) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/BrowserIconRegistry.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/BrowserIconRegistry.kt deleted file mode 100644 index f2d37b5453..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/BrowserIconRegistry.kt +++ /dev/null @@ -1,124 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.favorites - -import android.content.Context -import com.vitorpamplona.quartz.utils.Log -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import java.io.File - -/** - * Device-local favicon store for browsed sites, keyed by host. Favicons are **captured from the WebView - * that already loaded the page** in the keyless `:napplet` browser host (where they ride the page's own — - * Tor-routed — network path) and relayed here as PNG bytes over IPC; this is the privacy-preserving - * alternative to the main app fetching `host/favicon.ico` itself, which would bypass Tor and leak the - * visit. Used to decorate favorite cards and omnibox suggestion rows. - * - * Lives only in the **main process**. Bytes are persisted as one small PNG per host under - * `filesDir/browser_icons`; the deterministic path means the only in-memory state is [keys] — the set of - * hosts that currently have an icon — which exists purely to drive Compose recomposition (and to keep - * `File.exists()` disk checks out of composition). - */ -object BrowserIconRegistry { - private const val DIR = "browser_icons" - - private val _keys = MutableStateFlow<Set<String>>(emptySet()) - - /** Sanitized host keys that currently have a stored icon. Observe to recompose when an icon arrives. */ - val keys: StateFlow<Set<String>> = _keys.asStateFlow() - - @Volatile private var iconDir: File? = null - - // Disk work runs here, never on the caller's thread. Both entry points are reached from threads - // that must not block: init() from app startup and record() from the broker's IPC handler, which - // is the main looper — StrictMode flagged the write, and a slow filesystem would have stalled the - // UI while a favicon was saved. - private val io = CoroutineScope(SupervisorJob() + Dispatchers.IO) - - /** - * Binds the app context and indexes already-stored icons. Idempotent. - * - * [iconDir] is published synchronously so [iconModelFor] and [record] work immediately; only the - * directory scan is deferred. Until it lands [keys] is empty, so an icon simply renders its - * placeholder for one frame and then recomposes — [keys] is a StateFlow precisely so that arrival - * drives recomposition. - */ - fun init(context: Context) { - if (iconDir != null) return - val dir = File(context.applicationContext.filesDir, DIR) - iconDir = dir - io.launch { - dir.mkdirs() - _keys.value = dir.listFiles()?.mapNotNull { it.name.removeSuffix(PNG).takeIf { n -> n.isNotBlank() } }?.toSet() ?: emptySet() - } - } - - /** Persists [bytes] as the favicon for [host] and marks it available. Called from the broker on IPC. */ - fun record( - host: String, - bytes: ByteArray, - ) { - val dir = iconDir ?: return - if (host.isBlank() || bytes.isEmpty()) return - val key = sanitize(host) - // Fire-and-forget: a favicon is a decoration, and the IPC handler must not wait on disk. - // [keys] updates only after the bytes are actually on disk, so a reader can never be told an - // icon exists before the file backing it does. - io.launch { - try { - dir.mkdirs() - File(dir, key + PNG).writeBytes(bytes) - _keys.update { it + key } - } catch (e: Exception) { - Log.w("BrowserIconRegistry", "Failed to store favicon for $host", e) - } - } - } - - /** - * A Coil model (`file://…`) for [host]'s favicon, or null when none is stored. Reads [keys] so callers - * that observe the flow recompose as icons arrive — pass [keys]'s value as a `remember` key. - */ - fun iconModelFor(host: String): String? { - val dir = iconDir ?: return null - val key = sanitize(host) - if (key !in _keys.value) return null - return "file://" + File(dir, key + PNG).absolutePath - } - - // Hosts map to a flat, filesystem-safe filename. Collisions (two hosts → one key) only mean a shared - // icon file, which is harmless for a decoration. - private fun sanitize(host: String): String = - host - .lowercase() - .map { if (it.isLetterOrDigit() || it == '.' || it == '-') it else '_' } - .joinToString("") - .take(120) - - private const val PNG = ".png" -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/NappletFavoriteIcon.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/NappletFavoriteIcon.kt index 330f648f09..f6b1e609e7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/NappletFavoriteIcon.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/NappletFavoriteIcon.kt @@ -111,7 +111,7 @@ private fun resolveIconBlob(event: Event?): IconBlob? = /** * A Coil model (`file://…`) for the cached favicon of [url]'s host, or null when no favicon - * has been captured yet. The favicon is stored by [BrowserIconRegistry] at browse time (the + * has been captured yet. The favicon is stored by [com.vitorpamplona.amethyst.commons.browser.BrowserIconRegistry] at browse time (the * WebView captures it in the sandboxed `:napplet` process); this composable just reads the cache. * * Early-returns null when [url] is blank or has no parseable host — this early return is stable @@ -121,8 +121,9 @@ private fun resolveIconBlob(event: Event?): IconBlob? = @Composable fun rememberWebAppIconModel(url: String): String? { val host = remember(url) { OmniboxInput.hostOf(url) } ?: return null - val iconKeys by BrowserIconRegistry.keys.collectAsStateWithLifecycle() - return remember(host, iconKeys) { BrowserIconRegistry.iconModelFor(host) } + val iconKeys by Amethyst.instance.browserIcons.keys + .collectAsStateWithLifecycle() + return remember(host, iconKeys) { Amethyst.instance.browserIcons.iconModelFor(host) } } /** diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt index c6badf72e0..3340367115 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt @@ -42,7 +42,6 @@ import com.vitorpamplona.amethyst.commons.napplet.NappletIdentityWatch import com.vitorpamplona.amethyst.commons.napplet.NappletRequestRouter import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletResponse -import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.napplet.gateways.AccountNappletGateways import com.vitorpamplona.amethyst.napplethost.NappletIpc @@ -212,8 +211,9 @@ class NappletBrokerService : Service() { val data = msg.data ?: return true val host = data.getString(NappletIpc.KEY_ICON_HOST)?.takeIf { it.isNotBlank() } ?: return true val bytes = data.getByteArray(NappletIpc.KEY_ICON_BYTES) ?: return true - BrowserIconRegistry.init(applicationContext) - BrowserIconRegistry.record(host, bytes) + val icons = Amethyst.instance.browserIcons + icons.init() + icons.record(host, bytes) return true } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentSummary.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentSummary.kt index 500b133ef6..3c0d13871e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentSummary.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentSummary.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.napplet import android.content.Context +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.browser.OmniboxInput import com.vitorpamplona.amethyst.commons.napplet.NappletCapability import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity @@ -62,7 +63,6 @@ import com.vitorpamplona.amethyst.commons.resources.napplet_consent_upload import com.vitorpamplona.amethyst.commons.resources.napplet_fallback_title import com.vitorpamplona.amethyst.commons.ui.loadPluralStringRes import com.vitorpamplona.amethyst.commons.ui.loadStringRes -import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.quartz.lightning.LnInvoiceUtil import com.vitorpamplona.quartz.nip01Core.core.fastForEach @@ -95,7 +95,7 @@ class NappletConsentSummary( val (title, iconUrl) = if (identity.authorPubKey == "browser") { val host = OmniboxInput.hostOf(identity.identifier) ?: identity.identifier - host to BrowserIconRegistry.iconModelFor(host) + host to Amethyst.instance.browserIcons.iconModelFor(host) } else { resolveNappletMeta(identity.authorPubKey, identity.identifier, untitled) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NostrSignerOpLabels.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NostrSignerOpLabels.kt index ce636d427a..9ff3361771 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NostrSignerOpLabels.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NostrSignerOpLabels.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.napplet import android.content.Context +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.browser.OmniboxInput import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp import com.vitorpamplona.amethyst.commons.model.cache.LocalCache @@ -39,7 +40,6 @@ import com.vitorpamplona.amethyst.commons.resources.nip46_signer_allow_always_fo import com.vitorpamplona.amethyst.commons.ui.loadStringRes import com.vitorpamplona.amethyst.connectedApps.consent.SignerConnectInfo import com.vitorpamplona.amethyst.connectedApps.consent.SignerConsentInfo -import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.kindNameFor import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -83,7 +83,7 @@ suspend fun buildSignerConsentInfo( val (title, iconUrl) = if (identity.authorPubKey == "browser") { val host = OmniboxInput.hostOf(identity.identifier) ?: identity.identifier - host to BrowserIconRegistry.iconModelFor(host) + host to Amethyst.instance.browserIcons.iconModelFor(host) } else { resolveNappletMeta(identity.authorPubKey, identity.identifier, untitled) } @@ -183,7 +183,7 @@ suspend fun buildConnectInfo( val (title, iconUrl) = if (identity.authorPubKey == "browser") { val host = OmniboxInput.hostOf(identity.identifier) ?: identity.identifier - host to BrowserIconRegistry.iconModelFor(host) + host to Amethyst.instance.browserIcons.iconModelFor(host) } else { resolveNappletMeta(identity.authorPubKey, identity.identifier, untitled) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt index 14d81fdaee..c91ee5a633 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt @@ -60,7 +60,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.Size10Modifier import com.vitorpamplona.amethyst.commons.ui.theme.Size25Modifier import com.vitorpamplona.amethyst.commons.ui.theme.Size27Modifier import com.vitorpamplona.amethyst.commons.ui.theme.onSurface65 -import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry import com.vitorpamplona.amethyst.favorites.rememberNappletIconModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -132,9 +131,10 @@ internal fun rememberFavoriteIconModel(fav: FavoriteApp): Any? = when (fav) { is FavoriteApp.WebApp -> { // Captured favicons, keyed so the icon appears once the site's capture lands. - val iconKeys by BrowserIconRegistry.keys.collectAsStateWithLifecycle() + val iconKeys by Amethyst.instance.browserIcons.keys + .collectAsStateWithLifecycle() remember(fav, iconKeys) { - OmniboxInput.hostOf(fav.url)?.let(BrowserIconRegistry::iconModelFor) + OmniboxInput.hostOf(fav.url)?.let(Amethyst.instance.browserIcons::iconModelFor) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/BrowserScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/BrowserScreen.kt index d4e10ff587..cc5430711b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/BrowserScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/BrowserScreen.kt @@ -102,7 +102,6 @@ import com.vitorpamplona.amethyst.commons.resources.favorite_app_remove import com.vitorpamplona.amethyst.commons.resources.favorite_app_still_loading import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.note.ArrowBackIcon -import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry import com.vitorpamplona.amethyst.favorites.FavoriteAppLauncher import com.vitorpamplona.amethyst.favorites.PreloadFavoriteNostrApps import com.vitorpamplona.amethyst.favorites.rememberNappletIconModel @@ -156,7 +155,8 @@ private fun BrowserLauncher( .collectAsStateWithLifecycle() val history by Amethyst.instance.browserHistory.history .collectAsStateWithLifecycle() - val iconKeys by BrowserIconRegistry.keys.collectAsStateWithLifecycle() + val iconKeys by Amethyst.instance.browserIcons.keys + .collectAsStateWithLifecycle() // Fetch favorited nsite/napplet manifests up front so tapping one launches immediately instead of // showing "isn't loaded yet" until the user happens to visit the nsite/napplet feed. @@ -571,7 +571,7 @@ private fun SuggestedRow( onClick: () -> Unit, onAddFavorite: () -> Unit, ) { - val iconModel = remember(entry, iconKeys) { OmniboxInput.hostOf(entry.app.url)?.let(BrowserIconRegistry::iconModelFor) } + val iconModel = remember(entry, iconKeys) { OmniboxInput.hostOf(entry.app.url)?.let(Amethyst.instance.browserIcons::iconModelFor) } Row( modifier = Modifier @@ -798,7 +798,7 @@ private fun SiteIcon( iconKeys: Set<String>, modifier: Modifier = Modifier, ) { - val model = remember(host, iconKeys) { BrowserIconRegistry.iconModelFor(host) } + val model = remember(host, iconKeys) { Amethyst.instance.browserIcons.iconModelFor(host) } val symbol = if (isFavorite) MaterialSymbols.Star else MaterialSymbols.Public val tint = MaterialTheme.colorScheme.onSurfaceVariant if (model == null) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/FavoriteAppsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/FavoriteAppsScreen.kt index c4e69e44f4..f3e9e9f896 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/FavoriteAppsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/FavoriteAppsScreen.kt @@ -73,7 +73,6 @@ import com.vitorpamplona.amethyst.commons.resources.favorite_apps import com.vitorpamplona.amethyst.commons.resources.favorite_apps_empty import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.stringRes -import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry import com.vitorpamplona.amethyst.favorites.FavoriteAppLauncher import com.vitorpamplona.amethyst.favorites.PreloadFavoriteNostrApps import com.vitorpamplona.amethyst.favorites.rememberNappletIconModel @@ -190,12 +189,13 @@ internal fun FavoriteAppCell( // For a plain web favorite, prefer the favicon captured when its site was opened; an nsite/napplet uses // the verified icon blob bundled in its own content. Observing the key set recomputes the model as a // captured favicon arrives. - val iconKeys by BrowserIconRegistry.keys.collectAsStateWithLifecycle() + val iconKeys by Amethyst.instance.browserIcons.keys + .collectAsStateWithLifecycle() val faviconModel = when (app) { is FavoriteApp.WebApp -> remember(app, iconKeys) { - OmniboxInput.hostOf(app.url)?.let(BrowserIconRegistry::iconModelFor) + OmniboxInput.hostOf(app.url)?.let(Amethyst.instance.browserIcons::iconModelFor) } is FavoriteApp.NostrApp -> rememberNappletIconModel(app.coordinate) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt index ed86de0cb2..9e7491a45d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt @@ -110,7 +110,6 @@ import com.vitorpamplona.amethyst.commons.resources.nip46_signer_reconnecting import com.vitorpamplona.amethyst.commons.resources.nip46_signer_remote_app import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton -import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry import com.vitorpamplona.amethyst.favorites.rememberManifestIconModel import com.vitorpamplona.amethyst.favorites.rememberWebAppIconModel import com.vitorpamplona.amethyst.napplet.NappletBrokerService @@ -790,7 +789,7 @@ private suspend fun loadDetailState( val (title, iconUrl) = if (author == "browser") { val host = OmniboxInput.hostOf(identifier) ?: identifier - host to BrowserIconRegistry.iconModelFor(host) + host to Amethyst.instance.browserIcons.iconModelFor(host) } else { resolveNappletMeta(author, identifier, untitled) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserIconRegistry.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserIconRegistry.kt new file mode 100644 index 0000000000..f7f6c4bb69 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserIconRegistry.kt @@ -0,0 +1,141 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.browser + +import com.vitorpamplona.amethyst.commons.util.platformFileSystem +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import okio.Path +import kotlin.concurrent.Volatile + +/** + * Device-local favicon store for browsed sites, keyed by host. Favicons are **captured from the WebView + * that already loaded the page** — on Android, in the keyless `:napplet` browser host, where they ride the + * page's own (Tor-routed) network path — and handed here as PNG bytes; this is the privacy-preserving + * alternative to the app fetching `host/favicon.ico` itself, which would bypass Tor and leak the visit. + * Used to decorate favorite cards and omnibox suggestion rows. + * + * Bytes are persisted as one small PNG per host under [iconDir], so the only in-memory state is [keys] — + * the set of hosts that currently have an icon — which exists purely to drive Compose recomposition (and + * to keep filesystem existence checks out of composition). + * + * [iconDir] is a function rather than a path for the same reason [com.vitorpamplona.amethyst.commons.model.preferences.AppPreferenceStores] + * takes `rootFilesDir`: the front end owns where its files live, and resolving it lazily keeps this class + * free of any platform's notion of an app directory. The Android app passes + * `{ appContext.filesDir.toOkioPath() / DIR }`. + * + * One instance per process. On Android the launcher/UI in the **main** process own it; the keyless + * `:napplet` sandbox never builds one and relays captured bytes over IPC instead. + */ +class BrowserIconRegistry( + private val iconDir: () -> Path, + private val scope: CoroutineScope, +) { + private val _keys = MutableStateFlow<Set<String>>(emptySet()) + + /** Sanitized host keys that currently have a stored icon. Observe to recompose when an icon arrives. */ + val keys: StateFlow<Set<String>> = _keys.asStateFlow() + + @Volatile private var started = false + + /** + * Indexes already-stored icons. Idempotent. + * + * Only the directory scan is deferred; [iconModelFor] and [record] resolve [iconDir] themselves and + * work immediately. Until the scan lands [keys] is empty, so an icon renders its placeholder for one + * frame and then recomposes — [keys] is a StateFlow precisely so that arrival drives recomposition. + */ + fun init() { + if (started) return + started = true + scope.launch { + try { + val dir = iconDir() + platformFileSystem.createDirectories(dir) + val scanned = + platformFileSystem + .list(dir) + .mapNotNull { it.name.removeSuffix(PNG).takeIf { name -> name.isNotBlank() } } + .toSet() + // Merged rather than assigned: a record() that lands while the scan is in flight has + // already written its file and added its key, and overwriting the set wholesale would + // drop it — the icon would sit on disk unshown until the next launch. + _keys.update { it + scanned } + } catch (e: Exception) { + Log.w("BrowserIconRegistry", "Failed to index stored favicons", e) + } + } + } + + /** Persists [bytes] as the favicon for [host] and marks it available. */ + fun record( + host: String, + bytes: ByteArray, + ) { + if (host.isBlank() || bytes.isEmpty()) return + val key = sanitize(host) + // Fire-and-forget: a favicon is a decoration, and the caller (on Android, the broker's IPC + // handler, which runs on the main looper) must not wait on disk. + // [keys] updates only after the bytes are actually on disk, so a reader can never be told an + // icon exists before the file backing it does. + scope.launch { + try { + val dir = iconDir() + platformFileSystem.createDirectories(dir) + platformFileSystem.write(dir / (key + PNG)) { write(bytes) } + _keys.update { it + key } + } catch (e: Exception) { + Log.w("BrowserIconRegistry", "Failed to store favicon for $host", e) + } + } + } + + /** + * A Coil model (`file://…`) for [host]'s favicon, or null when none is stored. Reads [keys] so callers + * that observe the flow recompose as icons arrive — pass [keys]'s value as a `remember` key. + */ + fun iconModelFor(host: String): String? { + val key = sanitize(host) + if (key !in _keys.value) return null + return "file://" + (iconDir() / (key + PNG)) + } + + companion object { + /** Same directory the Android registry used: `filesDir/browser_icons`. */ + const val DIR = "browser_icons" + + private const val PNG = ".png" + + // Hosts map to a flat, filesystem-safe filename. Collisions (two hosts → one key) only mean a + // shared icon file, which is harmless for a decoration. + private fun sanitize(host: String): String = + host + .lowercase() + .map { if (it.isLetterOrDigit() || it == '.' || it == '-') it else '_' } + .joinToString("") + .take(120) + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserIconRegistryTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserIconRegistryTest.kt new file mode 100644 index 0000000000..1e5593e921 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserIconRegistryTest.kt @@ -0,0 +1,197 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.browser + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import okio.Path.Companion.toOkioPath +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +/** + * The favicon store's disk behaviour. Untestable while it was an Android `object` taking a `Context` + * for its `filesDir`; taking `iconDir: () -> Path` is what opens it up. + */ +class BrowserIconRegistryTest { + @get:Rule + val folder = TemporaryFolder() + + private var seq = 0 + + private fun newDir(): File = folder.newFolder("icons_${seq++}") + + private val png = byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47) + + /** One app "session" over [dir], on the test scheduler so [advanceUntilIdle] drives its disk work. */ + private fun TestScope.registryOver(dir: File) = BrowserIconRegistry({ dir.toOkioPath() }, CoroutineScope(coroutineContext + Job())) + + @Test + fun aRecordedIconBecomesAvailableAndReachesDisk() = + runTest { + val dir = newDir() + val registry = registryOver(dir) + registry.init() + advanceUntilIdle() + + registry.record("example.com", png) + advanceUntilIdle() + + assertEquals("the host is announced", setOf("example.com"), registry.keys.value) + assertEquals( + "and the model points at the file", + "file://" + File(dir, "example.com.png").absolutePath, + registry.iconModelFor("example.com"), + ) + assertTrue("which exists", File(dir, "example.com.png").exists()) + assertEquals("with the bytes given", png.toList(), File(dir, "example.com.png").readBytes().toList()) + } + + /** A cold start has to find what earlier sessions stored, or every icon redownloads on first paint. */ + @Test + fun iconsAlreadyOnDiskAreIndexedByInit() = + runTest { + val dir = newDir() + File(dir, "already.example.png").writeBytes(png) + + val registry = registryOver(dir) + assertTrue("nothing is known before init", registry.keys.value.isEmpty()) + + registry.init() + advanceUntilIdle() + + assertEquals("the stored icon is indexed", setOf("already.example"), registry.keys.value) + } + + /** + * [BrowserIconRegistry.iconModelFor] is read from composition, so it must answer from [keys] rather + * than touch the filesystem — a host with no icon is null, not a path to a file that is not there. + */ + @Test + fun aHostWithNoStoredIconHasNoModel() = + runTest { + val registry = registryOver(newDir()) + registry.init() + advanceUntilIdle() + + assertNull(registry.iconModelFor("never-visited.example")) + } + + /** Host keys become one flat filename, so a port or an uppercase host cannot escape the directory. */ + @Test + fun hostsAreSanitizedIntoASingleFlatFilename() = + runTest { + val dir = newDir() + val registry = registryOver(dir) + registry.init() + advanceUntilIdle() + + registry.record("Example.COM:8080/../etc", png) + advanceUntilIdle() + + assertEquals( + "lowercased, and everything but letters/digits/dot/dash replaced", + setOf("example.com_8080_.._etc"), + registry.keys.value, + ) + assertEquals( + "one file, directly in the icon dir", + listOf("example.com_8080_.._etc.png"), + dir.listFiles()?.map { it.name }, + ) + } + + /** Lookups are sanitized the same way, so the caller passes the raw host and still finds it. */ + @Test + fun aLookupSanitizesTheHostTheSameWay() = + runTest { + val dir = newDir() + val registry = registryOver(dir) + registry.init() + advanceUntilIdle() + + registry.record("Example.COM", png) + advanceUntilIdle() + + assertEquals( + "the raw host resolves to the sanitized file", + "file://" + File(dir, "example.com.png").absolutePath, + registry.iconModelFor("Example.COM"), + ) + } + + @Test + fun aBlankHostOrEmptyBytesAreIgnored() = + runTest { + val dir = newDir() + val registry = registryOver(dir) + registry.init() + advanceUntilIdle() + + registry.record(" ", png) + registry.record("example.com", ByteArray(0)) + advanceUntilIdle() + + assertTrue("nothing announced", registry.keys.value.isEmpty()) + assertEquals("nothing written", emptyList<String>(), dir.listFiles()?.map { it.name }) + } + + @Test + fun aRecordedIconSurvivesARestart() = + runTest { + val dir = newDir() + + val first = registryOver(dir) + first.init() + advanceUntilIdle() + first.record("example.com", png) + advanceUntilIdle() + + val second = registryOver(dir) + second.init() + advanceUntilIdle() + + assertEquals("indexed again from disk", setOf("example.com"), second.keys.value) + } + + /** The icon dir need not exist yet: a first run must create it rather than drop the icon. */ + @Test + fun aMissingIconDirectoryIsCreated() = + runTest { + val dir = File(folder.root, "not_yet_${seq++}") + val registry = registryOver(dir) + registry.init() + advanceUntilIdle() + + registry.record("example.com", png) + advanceUntilIdle() + + assertTrue("the directory was created", dir.isDirectory) + assertEquals("and the icon landed in it", setOf("example.com"), registry.keys.value) + } +} From f5fc432e950d9e05c650a5c2cd32e3aee05e495a Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Fri, 25 Sep 2026 21:05:27 +0000 Subject: [PATCH 39/50] refactor(commons): move the marmot encrypted stores and the NIP-11 / online caches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two clusters from the survey of what was left in amethyst/, both cases of code that was shared-layer already and just happened to live in the app. The four Marmot stores go to commons/jvmAndroid/marmot, next to the EncryptedAppendLog they were already built on. Nothing in them was Android despite the prefix: no android.* import, no Context, just (rootDir: File, encryption: SecretEncryption) and the quartz interfaces. One call site, AccountCacheState. The KDoc lines claiming "Android implementation" and "AES/GCM via Android KeyStore" are reworded: SecretEncryption is expect/actual, AndroidKeyStore on Android and a key file on desktop, so the old wording is now only half true. Commons already referred to two of them by name in its own KDoc; those references are updated. They are Encrypted* rather than File*, and that is not cosmetic. cli/stores already declares FileMlsGroupStateStore, FileMarmotMessageStore, FileKeyPackageBundleStore and FilePublishObligationStore — deliberately UNENCRYPTED test-harness stores, in a module that depends on commons. Two same-named classes with opposite encryption semantics, one import away from each other, is how MLS state ends up written in plaintext. Encrypted* is also what these actually are, and reads correctly next to EncryptedAppendLog. Nip11CachedRetriever + Nip11Retriever + RetrieveResult go to commons/relays/nip11, and OnlineChecker to commons/service. RetrieveResult had to travel: the cache is typed on it and commons cannot import from amethyst. Nip11RetrieverTest travels too — it sat in the same package and used the class with no import line. LoadRelayInfo and RelaySupportsNip stay behind; both reach Amethyst.instance. android.util.LruCache -> androidx.collection.LruCache is not the pure import swap it looks like, and the compiler said so: androidx bounds V to Any, while android.util.LruCache is a Java platform type that accepted LruCache<NormalizedRelayUrl, RetrieveResult?>. Checked all four put() sites first — every one stores a concrete RetrieveResult, so the nullable argument never meant anything and get() still returns null on a miss, which is what the readers already branch on. Dropping it is behaviour-preserving. NotifyCoordinator does NOT move despite being grouped with the other two: android.util.LruCache really is its only platform import, but it takes accountForPubkey: (HexKey) -> Account?, and Account lives in amethyst/model. It needs that abstraction, not an import swap. 17 new tests. OnlineChecker's predicates decide whether the UI shows a player or an offline placeholder, so the five-minute TTL is pinned in both directions — a stale online entry must stop reading online, and a stale offline one must stop suppressing retries — along with resetIfOfflineToRetry dropping only failures, since evicting good entries would refetch every URL that already worked. Its suspend probe is left alone: it needs a real OkHttp round trip and commons has no MockWebServer, so there is no honest way to drive it. Same reason Nip11CachedRetriever gets nothing new here; its fetch and error-caching paths are all network. The Marmot tests cover what restart depends on: group state, sender ratchet and message log surviving a new store over the same directory, deletes removing both the state and the listing, and two account directories not seeing each other. Writing them found validation I had not noticed reading the code — group ids must be hex, which is a path-traversal guard — after a first pass using readable labels failed every test on it. That guard is pinned now too: "../escape" and friends are rejected rather than resolved to a path. Verified: :commons:jvmTest (2549, 0 failures), :amethyst:testPlayDebugUnitTest, :cli:compileKotlin, :commons:verifyKmpPurity, :commons:compileCommonMainKotlinMetadata, :amethyst:compilePlayDebugKotlin. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AXvKXakvup4inNFfAhhr4L --- .../com/vitorpamplona/amethyst/AppModules.kt | 2 +- .../model/accountsCache/AccountCacheState.kt | 26 +-- .../model/nip11RelayInfo/LoadRelayInfo.kt | 1 + .../ui/screen/loggedIn/AccountViewModel.kt | 2 +- .../LiveActivityChannelScreen.kt | 2 +- .../DiscoverLiveFeedFilter.kt | 2 +- .../ui/screen/loggedIn/home/HomeScreen.kt | 2 +- .../loggedIn/home/live/LiveStatusIndicator.kt | 2 +- .../livestreams/dal/LiveStreamsFeedFilter.kt | 2 +- .../common/BasicRelaySetupInfoClickableRow.kt | 2 +- .../common/BasicRelaySetupInfoDialog.kt | 2 +- .../relays/common/RelayUrlEditField.kt | 2 +- .../relays/common/ShowRelaySuggestionList.kt | 2 +- .../relays/vanish/RequestToVanishScreen.kt | 2 +- .../marmot/InMemoryMlsGroupStateStore.kt | 2 +- .../commons/marmot/EncryptedAppendLog.kt | 2 +- .../marmot/EncryptedKeyPackageBundleStore.kt | 14 +- .../marmot/EncryptedMarmotMessageStore.kt | 12 +- .../marmot/EncryptedMlsGroupStateStore.kt | 14 +- .../marmot/EncryptedPublishObligationStore.kt | 8 +- .../relays/nip11}/Nip11CachedRetriever.kt | 11 +- .../commons/relays/nip11}/Nip11Retriever.kt | 2 +- .../commons/relays/nip11}/RetrieveResult.kt | 2 +- .../amethyst/commons}/service/OnlineCheck.kt | 4 +- .../marmot/EncryptedMarmotStoresTest.kt | 184 ++++++++++++++++++ .../relays/nip11}/Nip11RetrieverTest.kt | 2 +- .../commons/service/OnlineCheckerTest.kt | 117 +++++++++++ 27 files changed, 366 insertions(+), 59 deletions(-) rename amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidKeyPackageBundleStore.kt => commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/marmot/EncryptedKeyPackageBundleStore.kt (90%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidMarmotMessageStore.kt => commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/marmot/EncryptedMarmotMessageStore.kt (97%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidMlsGroupStateStore.kt => commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/marmot/EncryptedMlsGroupStateStore.kt (95%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidPublishObligationStore.kt => commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/marmot/EncryptedPublishObligationStore.kt (97%) rename {amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo => commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relays/nip11}/Nip11CachedRetriever.kt (92%) rename {amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo => commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relays/nip11}/Nip11Retriever.kt (98%) rename {amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo => commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relays/nip11}/RetrieveResult.kt (97%) rename {amethyst/src/main/java/com/vitorpamplona/amethyst => commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons}/service/OnlineCheck.kt (98%) create mode 100644 commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/EncryptedMarmotStoresTest.kt rename {amethyst/src/test/java/com/vitorpamplona/amethyst/model/nip11RelayInfo => commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relays/nip11}/Nip11RetrieverTest.kt (97%) create mode 100644 commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/service/OnlineCheckerTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 8c98c1f1b4..956a3aba61 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -55,6 +55,7 @@ import com.vitorpamplona.amethyst.commons.relayClient.event.EventFinderQueryStat import com.vitorpamplona.amethyst.commons.relayClient.speedLogger.RelaySpeedLogger import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderQueryState import com.vitorpamplona.amethyst.commons.relays.health.TorCircuitHealthTracker +import com.vitorpamplona.amethyst.commons.relays.nip11.Nip11CachedRetriever import com.vitorpamplona.amethyst.commons.richtext.CachedAsciiDocToMarkdown import com.vitorpamplona.amethyst.commons.richtext.CachedRichTextParser import com.vitorpamplona.amethyst.commons.robohash.CachedRobohash @@ -75,7 +76,6 @@ import com.vitorpamplona.amethyst.commons.state.UiSettingsState import com.vitorpamplona.amethyst.commons.tor.TorSettings import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState -import com.vitorpamplona.amethyst.model.nip11RelayInfo.Nip11CachedRetriever import com.vitorpamplona.amethyst.model.nip60Cashu.CashuPreferences import com.vitorpamplona.amethyst.model.preferences.UiSharedPreferences import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilder diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt index 2c347c81c7..c5559ecbe3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt @@ -26,6 +26,10 @@ import com.vitorpamplona.amethyst.commons.connectedApps.nip46.InMemoryNip46Clien import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46ClientStore import com.vitorpamplona.amethyst.commons.connectedApps.signers.InMemoryNostrSignerPermissionStore import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionStore +import com.vitorpamplona.amethyst.commons.marmot.EncryptedKeyPackageBundleStore +import com.vitorpamplona.amethyst.commons.marmot.EncryptedMarmotMessageStore +import com.vitorpamplona.amethyst.commons.marmot.EncryptedMlsGroupStateStore +import com.vitorpamplona.amethyst.commons.marmot.EncryptedPublishObligationStore import com.vitorpamplona.amethyst.commons.marmot.InMemoryMlsGroupStateStore import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.model.marmot.AndroidIngestDedupStore @@ -36,10 +40,6 @@ import com.vitorpamplona.amethyst.commons.relayauth.DataStoreRelayAuthPermission import com.vitorpamplona.amethyst.commons.service.pow.PoWPublishQueue import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AccountSettings -import com.vitorpamplona.amethyst.model.marmot.AndroidKeyPackageBundleStore -import com.vitorpamplona.amethyst.model.marmot.AndroidMarmotMessageStore -import com.vitorpamplona.amethyst.model.marmot.AndroidMlsGroupStateStore -import com.vitorpamplona.amethyst.model.marmot.AndroidPublishObligationStore import com.vitorpamplona.amethyst.service.location.LocationState import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey @@ -250,13 +250,13 @@ class AccountCacheState( val mlsStore = try { Log.d("AccountCacheState") { - "Initializing AndroidMlsGroupStateStore for ${signer.pubKey.take(8)}… at ${accountDir.absolutePath}" + "Initializing EncryptedMlsGroupStateStore for ${signer.pubKey.take(8)}… at ${accountDir.absolutePath}" } - AndroidMlsGroupStateStore(accountDir) + EncryptedMlsGroupStateStore(accountDir) } catch (e: Exception) { Log.e( "AccountCacheState", - "Failed to initialize AndroidMlsGroupStateStore, falling back to in-memory store (Marmot groups will NOT persist across restarts)", + "Failed to initialize EncryptedMlsGroupStateStore, falling back to in-memory store (Marmot groups will NOT persist across restarts)", e, ) InMemoryMlsGroupStateStore() @@ -267,11 +267,11 @@ class AccountCacheState( val marmotMessageStore = try { - AndroidMarmotMessageStore(accountDir) + EncryptedMarmotMessageStore(accountDir) } catch (e: Exception) { Log.e( "AccountCacheState", - "Failed to initialize AndroidMarmotMessageStore (Marmot messages will NOT persist across restarts)", + "Failed to initialize EncryptedMarmotMessageStore (Marmot messages will NOT persist across restarts)", e, ) null @@ -279,11 +279,11 @@ class AccountCacheState( val marmotKeyPackageStore = try { - AndroidKeyPackageBundleStore(accountDir) + EncryptedKeyPackageBundleStore(accountDir) } catch (e: Exception) { Log.e( "AccountCacheState", - "Failed to initialize AndroidKeyPackageBundleStore (Marmot KeyPackages will NOT persist across restarts)", + "Failed to initialize EncryptedKeyPackageBundleStore (Marmot KeyPackages will NOT persist across restarts)", e, ) null @@ -291,11 +291,11 @@ class AccountCacheState( val marmotPublishObligationStore = try { - AndroidPublishObligationStore(accountDir) + EncryptedPublishObligationStore(accountDir) } catch (e: Exception) { Log.e( "AccountCacheState", - "Failed to initialize AndroidPublishObligationStore " + + "Failed to initialize EncryptedPublishObligationStore " + "(a Marmot commit interrupted mid-publish will NOT be retried after a restart)", e, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/LoadRelayInfo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/LoadRelayInfo.kt index 854e7147ea..0d8df07141 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/LoadRelayInfo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/LoadRelayInfo.kt @@ -25,6 +25,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.State import androidx.compose.runtime.produceState import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.commons.relays.nip11.Nip11CachedRetriever import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation import com.vitorpamplona.quartz.utils.Log 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 a3229731b7..64c2e1ec1e 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 @@ -96,6 +96,7 @@ import com.vitorpamplona.amethyst.commons.resources.unauthorized_exception import com.vitorpamplona.amethyst.commons.resources.unauthorized_exception_description import com.vitorpamplona.amethyst.commons.resources.user_x_does_not_have_a_lightning_address_setup_to_receive_sats import com.vitorpamplona.amethyst.commons.resources.video_saved_to_the_gallery +import com.vitorpamplona.amethyst.commons.service.OnlineChecker import com.vitorpamplona.amethyst.commons.service.broadcast.BroadcastTracker import com.vitorpamplona.amethyst.commons.service.http.EmptyRoleBasedHttpClientBuilder import com.vitorpamplona.amethyst.commons.service.http.IRoleBasedHttpClientBuilder @@ -116,7 +117,6 @@ import com.vitorpamplona.amethyst.model.LatestKeyPackageOwner import com.vitorpamplona.amethyst.model.UrlCachedPreviewer import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilder import com.vitorpamplona.amethyst.service.ClinkDebitPayer -import com.vitorpamplona.amethyst.service.OnlineChecker import com.vitorpamplona.amethyst.service.V4VPaymentHandler import com.vitorpamplona.amethyst.service.ZapPaymentHandler import com.vitorpamplona.amethyst.service.cashu.melt.MeltProcessor diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/LiveActivityChannelScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/LiveActivityChannelScreen.kt index 3ff4e70d03..bf3b19c433 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/LiveActivityChannelScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/LiveActivityChannelScreen.kt @@ -27,8 +27,8 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import com.vitorpamplona.amethyst.commons.model.cache.LocalCache +import com.vitorpamplona.amethyst.commons.service.OnlineChecker import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav -import com.vitorpamplona.amethyst.service.OnlineChecker import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold import com.vitorpamplona.amethyst.ui.note.LoadLiveActivityChannel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/DiscoverLiveFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/DiscoverLiveFeedFilter.kt index 3f1dd2d396..8b7e5cb5e5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/DiscoverLiveFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/DiscoverLiveFeedFilter.kt @@ -32,8 +32,8 @@ import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.Aut import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavFilter import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsByOutboxTopNavFilter import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsByProxyTopNavFilter +import com.vitorpamplona.amethyst.commons.service.OnlineChecker import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.service.OnlineChecker import com.vitorpamplona.amethyst.ui.dal.FilterByListParams import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent 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 b3fb2e01f5..66ebccef97 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 @@ -74,6 +74,7 @@ import com.vitorpamplona.amethyst.commons.resources.feed_is_empty import com.vitorpamplona.amethyst.commons.resources.home_tab_everything import com.vitorpamplona.amethyst.commons.resources.new_threads import com.vitorpamplona.amethyst.commons.resources.refresh +import com.vitorpamplona.amethyst.commons.service.OnlineChecker import com.vitorpamplona.amethyst.commons.ui.components.CrossfadeIfEnabled import com.vitorpamplona.amethyst.commons.ui.feeds.FeedError import com.vitorpamplona.amethyst.commons.ui.feeds.LoadingFeed @@ -94,7 +95,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.Size5dp import com.vitorpamplona.amethyst.commons.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.commons.ui.theme.TabRowHeight import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonRow -import com.vitorpamplona.amethyst.service.OnlineChecker import com.vitorpamplona.amethyst.service.location.LocationState import com.vitorpamplona.amethyst.ui.feeds.ChannelFeedContentState import com.vitorpamplona.amethyst.ui.feeds.ChannelFeedState diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/live/LiveStatusIndicator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/live/LiveStatusIndicator.kt index 85f932fee3..69679d2ea5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/live/LiveStatusIndicator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/live/LiveStatusIndicator.kt @@ -32,7 +32,7 @@ import com.vitorpamplona.amethyst.commons.model.Channel import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel -import com.vitorpamplona.amethyst.service.OnlineChecker +import com.vitorpamplona.amethyst.commons.service.OnlineChecker import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent import com.vitorpamplona.quartz.utils.Log diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/livestreams/dal/LiveStreamsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/livestreams/dal/LiveStreamsFeedFilter.kt index 6c1bc81034..5fe9419106 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/livestreams/dal/LiveStreamsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/livestreams/dal/LiveStreamsFeedFilter.kt @@ -32,8 +32,8 @@ import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.Aut import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavFilter import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsByOutboxTopNavFilter import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsByProxyTopNavFilter +import com.vitorpamplona.amethyst.commons.service.OnlineChecker import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.service.OnlineChecker import com.vitorpamplona.amethyst.ui.dal.FilterByListParams import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoClickableRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoClickableRow.kt index abe244fb9b..90ff2fca72 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoClickableRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoClickableRow.kt @@ -41,6 +41,7 @@ import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.relays.nip11.Nip11CachedRetriever import com.vitorpamplona.amethyst.commons.relays.ui.RelayCountResult import com.vitorpamplona.amethyst.commons.relays.ui.RelayDragState import com.vitorpamplona.amethyst.commons.relays.ui.RelayEventCountRow @@ -59,7 +60,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.Height25Modifier import com.vitorpamplona.amethyst.commons.ui.theme.LargeRelayIconModifier import com.vitorpamplona.amethyst.commons.ui.theme.ReactionRowHeightChatMaxWidth import com.vitorpamplona.amethyst.commons.ui.theme.Size25dp -import com.vitorpamplona.amethyst.model.nip11RelayInfo.Nip11CachedRetriever import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon import com.vitorpamplona.amethyst.ui.note.UserPicture diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoDialog.kt index d5f1e6e2af..a188c63141 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoDialog.kt @@ -23,11 +23,11 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import com.vitorpamplona.amethyst.commons.model.navigation.Route +import com.vitorpamplona.amethyst.commons.relays.nip11.Nip11CachedRetriever import com.vitorpamplona.amethyst.commons.relays.ui.RelayCountResult import com.vitorpamplona.amethyst.commons.relays.ui.RelayDragState import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.screen.LocalDisplaySettings -import com.vitorpamplona.amethyst.model.nip11RelayInfo.Nip11CachedRetriever import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayUrlEditField.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayUrlEditField.kt index 3a503a1724..ca80618511 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayUrlEditField.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayUrlEditField.kt @@ -45,6 +45,7 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.commons.relays.nip11.Nip11CachedRetriever import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.add import com.vitorpamplona.amethyst.commons.resources.add_a_relay @@ -58,7 +59,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.PopupUpEffect import com.vitorpamplona.amethyst.commons.ui.theme.StdEndPadding import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText -import com.vitorpamplona.amethyst.model.nip11RelayInfo.Nip11CachedRetriever import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStat diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/ShowRelaySuggestionList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/ShowRelaySuggestionList.kt index 3b91761c41..ec43a64991 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/ShowRelaySuggestionList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/ShowRelaySuggestionList.kt @@ -26,11 +26,11 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.relays.nip11.Nip11CachedRetriever import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.screen.LocalDisplaySettings import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness import com.vitorpamplona.amethyst.commons.ui.theme.HalfVertPadding -import com.vitorpamplona.amethyst.model.nip11RelayInfo.Nip11CachedRetriever import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/vanish/RequestToVanishScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/vanish/RequestToVanishScreen.kt index 834f109067..47d7aa6059 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/vanish/RequestToVanishScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/vanish/RequestToVanishScreen.kt @@ -68,6 +68,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.relays.nip11.Nip11CachedRetriever import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.cancel import com.vitorpamplona.amethyst.commons.resources.confirm @@ -96,7 +97,6 @@ import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness import com.vitorpamplona.amethyst.commons.ui.theme.HorzHalfVertPadding import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn -import com.vitorpamplona.amethyst.model.nip11RelayInfo.Nip11CachedRetriever import com.vitorpamplona.amethyst.ui.note.formatMediumDateTime import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/InMemoryMlsGroupStateStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/InMemoryMlsGroupStateStore.kt index a5edbd0bd2..39df421c8b 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/InMemoryMlsGroupStateStore.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/InMemoryMlsGroupStateStore.kt @@ -26,7 +26,7 @@ import com.vitorpamplona.quartz.utils.concurrent.ConcurrentMap /** * In-memory fallback implementation of [MlsGroupStateStore]. * - * Used only when [AndroidMlsGroupStateStore] cannot be initialized (e.g., when the + * Used only when [EncryptedMlsGroupStateStore] cannot be initialized (e.g., when the * Android KeyStore is unavailable). State is lost on app restart, but this lets * Marmot group operations at least work within a single session instead of failing * with "Marmot not initialized". diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/marmot/EncryptedAppendLog.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/marmot/EncryptedAppendLog.kt index b8ef668cb8..0f662c8e58 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/marmot/EncryptedAppendLog.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/marmot/EncryptedAppendLog.kt @@ -58,7 +58,7 @@ import java.io.RandomAccessFile * * **Not thread-safe.** Entries are cached in memory so an append never has to * read the log back, and that cache assumes one owner. Callers hold their own - * lock around every method (see `AndroidMarmotMessageStore`), and one instance + * lock around every method (see `EncryptedMarmotMessageStore`), and one instance * must own any given file. * * @param encrypt must produce a self-describing blob — it carries its own IV / diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidKeyPackageBundleStore.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/marmot/EncryptedKeyPackageBundleStore.kt similarity index 90% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidKeyPackageBundleStore.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/marmot/EncryptedKeyPackageBundleStore.kt index 04c0db4493..4a67d695ec 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidKeyPackageBundleStore.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/marmot/EncryptedKeyPackageBundleStore.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.model.marmot +package com.vitorpamplona.amethyst.commons.marmot import com.vitorpamplona.amethyst.commons.model.preferences.SecretEncryption import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore @@ -30,7 +30,7 @@ import kotlinx.coroutines.withContext import java.io.File /** - * Android implementation of [KeyPackageBundleStore] using file-based encrypted storage. + * File-backed [KeyPackageBundleStore], encrypted at rest. * * Storage layout: * ``` @@ -40,10 +40,10 @@ import java.io.File * The blob contains private key material — init keys, encryption keys, * signature keys — that the MLS engine needs to process Welcome events * received days or weeks after the corresponding KeyPackage was published. - * It is encrypted at rest with [SecretEncryption] (AES/GCM via Android - * KeyStore), the same primitive used by [AndroidMlsGroupStateStore]. + * It is encrypted at rest with [SecretEncryption] (AES-256-GCM, keyed by the platform's + * keystore), the same primitive used by [EncryptedMlsGroupStateStore]. */ -class AndroidKeyPackageBundleStore( +class EncryptedKeyPackageBundleStore( private val rootDir: File, private val encryption: SecretEncryption = SecretEncryption(), ) : KeyPackageBundleStore { @@ -51,7 +51,7 @@ class AndroidKeyPackageBundleStore( init { Log.d(TAG) { - "Initialized AndroidKeyPackageBundleStore: rootDir=${rootDir.absolutePath}" + "Initialized EncryptedKeyPackageBundleStore: rootDir=${rootDir.absolutePath}" } } @@ -121,6 +121,6 @@ class AndroidKeyPackageBundleStore( } companion object { - private const val TAG = "AndroidKeyPackageBundleStore" + private const val TAG = "EncryptedKeyPackageBundleStore" } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidMarmotMessageStore.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/marmot/EncryptedMarmotMessageStore.kt similarity index 97% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidMarmotMessageStore.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/marmot/EncryptedMarmotMessageStore.kt index e6775aa56a..b1a5b8b7a8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidMarmotMessageStore.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/marmot/EncryptedMarmotMessageStore.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.model.marmot +package com.vitorpamplona.amethyst.commons.marmot import com.vitorpamplona.amethyst.commons.marmot.EncryptedAppendLog import com.vitorpamplona.amethyst.commons.model.preferences.SecretEncryption @@ -32,9 +32,9 @@ import kotlinx.coroutines.withContext import java.io.File /** - * Android implementation of [MarmotMessageStore] using file-based encrypted storage. + * File-backed [MarmotMessageStore], encrypted at rest. * - * Stored alongside the [AndroidMlsGroupStateStore] data: + * Stored alongside the [EncryptedMlsGroupStateStore] data: * ``` * <rootDir>/mls_groups/<nostrGroupId>/messages — encrypted message log * ``` @@ -44,7 +44,7 @@ import java.io.File * small encrypted segment instead of rewriting the conversation, which is what * keeps the cost of a send flat as the history grows. */ -class AndroidMarmotMessageStore( +class EncryptedMarmotMessageStore( private val rootDir: File, private val encryption: SecretEncryption = SecretEncryption(), ) : MarmotMessageStore { @@ -52,7 +52,7 @@ class AndroidMarmotMessageStore( init { Log.d(TAG) { - "Initialized AndroidMarmotMessageStore: rootDir=${rootDir.absolutePath}" + "Initialized EncryptedMarmotMessageStore: rootDir=${rootDir.absolutePath}" } } @@ -353,7 +353,7 @@ class AndroidMarmotMessageStore( ) = log.rewrite(file, messages) companion object { - private const val TAG = "AndroidMarmotMessageStore" + private const val TAG = "EncryptedMarmotMessageStore" private val HEX_PATTERN = Regex("^[0-9a-fA-F]+$") } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidMlsGroupStateStore.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/marmot/EncryptedMlsGroupStateStore.kt similarity index 95% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidMlsGroupStateStore.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/marmot/EncryptedMlsGroupStateStore.kt index 8d509ed7fc..8775e23087 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidMlsGroupStateStore.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/marmot/EncryptedMlsGroupStateStore.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.model.marmot +package com.vitorpamplona.amethyst.commons.marmot import com.vitorpamplona.amethyst.commons.model.preferences.SecretEncryption import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore @@ -29,10 +29,10 @@ import java.io.File import java.io.FileOutputStream /** - * Android implementation of [MlsGroupStateStore] using file-based encrypted storage. + * File-backed [MlsGroupStateStore], encrypted at rest. * * All MLS group state (containing private keys and epoch secrets) is encrypted - * at rest using [SecretEncryption] (AES/GCM backed by Android KeyStore). + * at rest using [SecretEncryption] (AES-256-GCM, keyed by the platform's keystore). * * Storage layout: * ``` @@ -41,13 +41,13 @@ import java.io.FileOutputStream * <rootDir>/mls_groups/<nostrGroupId>/ratchet — encrypted OwnSenderRatchet * ``` */ -class AndroidMlsGroupStateStore( +class EncryptedMlsGroupStateStore( private val rootDir: File, private val encryption: SecretEncryption = SecretEncryption(), ) : MlsGroupStateStore { init { Log.d(TAG) { - "Initialized AndroidMlsGroupStateStore: rootDir=${rootDir.absolutePath}, " + + "Initialized EncryptedMlsGroupStateStore: rootDir=${rootDir.absolutePath}, " + "mls_groups exists=${File(rootDir, "mls_groups").exists()}" } } @@ -61,7 +61,7 @@ class AndroidMlsGroupStateStore( } companion object { - private const val TAG = "AndroidMlsGroupStateStore" + private const val TAG = "EncryptedMlsGroupStateStore" private val HEX_PATTERN = Regex("^[0-9a-fA-F]+$") } @@ -274,7 +274,7 @@ class AndroidMlsGroupStateStore( // Fallback: if rename fails (e.g., cross-filesystem), copy and delete tempFile.copyTo(target, overwrite = true) if (!tempFile.delete()) { - Log.w("AndroidMlsGroupStateStore") { "Failed to delete temp file after copy fallback: ${tempFile.absolutePath}" } + Log.w("EncryptedMlsGroupStateStore") { "Failed to delete temp file after copy fallback: ${tempFile.absolutePath}" } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidPublishObligationStore.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/marmot/EncryptedPublishObligationStore.kt similarity index 97% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidPublishObligationStore.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/marmot/EncryptedPublishObligationStore.kt index 64b561935e..054ec0d035 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidPublishObligationStore.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/marmot/EncryptedPublishObligationStore.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.model.marmot +package com.vitorpamplona.amethyst.commons.marmot import com.vitorpamplona.amethyst.commons.model.preferences.SecretEncryption import com.vitorpamplona.quartz.marmot.protocolCore.MarmotPublishObligationStore @@ -31,7 +31,7 @@ import kotlinx.coroutines.withContext import java.io.File /** - * Android implementation of [MarmotPublishObligationStore], encrypted at rest + * File-backed [MarmotPublishObligationStore], encrypted at rest * with [SecretEncryption] like the group-state and KeyPackage stores. * * ``` @@ -49,7 +49,7 @@ import java.io.File * concurrently and resolve out of order, so removing one record must not * rewrite another's. */ -class AndroidPublishObligationStore( +class EncryptedPublishObligationStore( private val rootDir: File, private val encryption: SecretEncryption = SecretEncryption(), ) : MarmotPublishObligationStore { @@ -184,6 +184,6 @@ class AndroidPublishObligationStore( } companion object { - private const val TAG = "AndroidPublishObligationStore" + private const val TAG = "EncryptedPublishObligationStore" } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/Nip11CachedRetriever.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relays/nip11/Nip11CachedRetriever.kt similarity index 92% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/Nip11CachedRetriever.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relays/nip11/Nip11CachedRetriever.kt index e97b3a6270..5e89abff2a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/Nip11CachedRetriever.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relays/nip11/Nip11CachedRetriever.kt @@ -18,9 +18,9 @@ * 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.model.nip11RelayInfo +package com.vitorpamplona.amethyst.commons.relays.nip11 -import android.util.LruCache +import androidx.collection.LruCache import androidx.compose.runtime.Stable import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl @@ -33,7 +33,12 @@ class Nip11CachedRetriever( val okHttpClient: (NormalizedRelayUrl) -> OkHttpClient, ) { private val relayInformationEmptyCache = LruCache<NormalizedRelayUrl, Nip11RelayInformation>(1000) - private val relayInformationDocumentCache = LruCache<NormalizedRelayUrl, RetrieveResult?>(1000) + + // Value type is non-null: androidx.collection.LruCache bounds V to Any, and every put here + // stores a concrete RetrieveResult. The old android.util.LruCache was a Java platform type, so + // the nullable argument compiled but never meant anything — get() returns null on a miss either + // way, which is what the readers below already branch on. + private val relayInformationDocumentCache = LruCache<NormalizedRelayUrl, RetrieveResult>(1000) private val retriever = Nip11Retriever(okHttpClient) /** diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/Nip11Retriever.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relays/nip11/Nip11Retriever.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/Nip11Retriever.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relays/nip11/Nip11Retriever.kt index 10118b09ac..8380827a66 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/Nip11Retriever.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relays/nip11/Nip11Retriever.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.model.nip11RelayInfo +package com.vitorpamplona.amethyst.commons.relays.nip11 import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.toHttp diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/RetrieveResult.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relays/nip11/RetrieveResult.kt similarity index 97% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/RetrieveResult.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relays/nip11/RetrieveResult.kt index 7f749ae12b..1fa3fde337 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/RetrieveResult.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relays/nip11/RetrieveResult.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.model.nip11RelayInfo +package com.vitorpamplona.amethyst.commons.relays.nip11 import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation import com.vitorpamplona.quartz.utils.TimeUtils diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/OnlineCheck.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/OnlineCheck.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/OnlineCheck.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/OnlineCheck.kt index 4f1aa7271b..834d011380 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/OnlineCheck.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/OnlineCheck.kt @@ -18,9 +18,9 @@ * 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.commons.service -import android.util.LruCache +import androidx.collection.LruCache import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.RandomInstance diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/EncryptedMarmotStoresTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/EncryptedMarmotStoresTest.kt new file mode 100644 index 0000000000..4c9c0a3719 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/EncryptedMarmotStoresTest.kt @@ -0,0 +1,184 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.marmot + +import com.vitorpamplona.amethyst.commons.model.preferences.SecretEncryption +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +/** + * Round trips for the file-backed Marmot stores, which had no coverage while they sat in `amethyst/` + * behind an `Android` prefix they never earned. + * + * What matters here is that the bytes survive a restart: these hold MLS group state and the message + * log, so a store that writes but cannot read back loses a group's history and its ratchet — and MLS + * state that cannot be reloaded is not recoverable from the relays. + */ +class EncryptedMarmotStoresTest { + @get:Rule + val folder = TemporaryFolder() + + private var seq = 0 + + /** A fresh account directory plus its own key file, so tests cannot read each other's data. */ + private fun accountDir(): Pair<File, SecretEncryption> { + val n = seq++ + val dir = folder.newFolder("account_$n") + return dir to SecretEncryption(File(folder.root, "secret_$n.key")) + } + + // Group ids must be hex: both stores validate, which is what stops a crafted id escaping the + // account directory. Using a realistic 64-char id rather than a label keeps the tests honest. + private val groupId = "a".repeat(63) + "1" + private val otherGroupId = "b".repeat(63) + "2" + + @Test + fun groupStateSurvivesANewStoreOverTheSameDirectory() = + runTest { + val (dir, encryption) = accountDir() + val payload = byteArrayOf(1, 2, 3, 4, 5) + + EncryptedMlsGroupStateStore(dir, encryption).save(groupId, payload) + + val reopened = EncryptedMlsGroupStateStore(dir, encryption).load(groupId) + assertEquals("the same bytes come back", payload.toList(), reopened?.toList()) + } + + @Test + fun anUnknownGroupLoadsAsNull() = + runTest { + val (dir, encryption) = accountDir() + + assertNull(EncryptedMlsGroupStateStore(dir, encryption).load("c".repeat(63) + "3")) + } + + @Test + fun deletingAGroupRemovesItFromTheListing() = + runTest { + val (dir, encryption) = accountDir() + val store = EncryptedMlsGroupStateStore(dir, encryption) + store.save(groupId, byteArrayOf(9)) + store.save(otherGroupId, byteArrayOf(8)) + + store.delete(groupId) + + assertEquals("only the other group is left", listOf(otherGroupId), store.listGroups()) + assertNull("and its state is gone", store.load(groupId)) + } + + /** The sender ratchet is stored separately from the group blob; losing it breaks decryption. */ + @Test + fun theSenderRatchetRoundTripsIndependentlyOfTheGroupState() = + runTest { + val (dir, encryption) = accountDir() + val store = EncryptedMlsGroupStateStore(dir, encryption) + + store.save(groupId, byteArrayOf(1)) + store.saveSenderRatchet(groupId, byteArrayOf(7, 7, 7)) + + val reopened = EncryptedMlsGroupStateStore(dir, encryption) + assertEquals("ratchet preserved", listOf<Byte>(7, 7, 7), reopened.loadSenderRatchet(groupId)?.toList()) + assertEquals("and the group blob is untouched", listOf<Byte>(1), reopened.load(groupId)?.toList()) + } + + @Test + fun appendedMessagesComeBackInOrderAfterAReopen() = + runTest { + val (dir, encryption) = accountDir() + val store = EncryptedMarmotMessageStore(dir, encryption) + + store.appendMessage(groupId, """{"id":"one"}""") + store.appendMessage(groupId, """{"id":"two"}""") + + val reopened = EncryptedMarmotMessageStore(dir, encryption).loadMessages(groupId) + assertEquals("both, in append order", listOf("""{"id":"one"}""", """{"id":"two"}"""), reopened) + } + + @Test + fun aGroupWithNoMessagesLoadsEmptyRatherThanFailing() = + runTest { + val (dir, encryption) = accountDir() + + assertTrue(EncryptedMarmotMessageStore(dir, encryption).loadMessages("d".repeat(63) + "4").isEmpty()) + } + + @Test + fun deletingAGroupDropsItsMessageLog() = + runTest { + val (dir, encryption) = accountDir() + val store = EncryptedMarmotMessageStore(dir, encryption) + store.appendMessage(groupId, """{"id":"one"}""") + + store.delete(groupId) + + assertTrue(EncryptedMarmotMessageStore(dir, encryption).loadMessages(groupId).isEmpty()) + } + + /** The group snapshot is what a cold start restores from before replaying the log. */ + @Test + fun theGroupSnapshotRoundTrips() = + runTest { + val (dir, encryption) = accountDir() + val store = EncryptedMarmotMessageStore(dir, encryption) + + store.recordGroupSnapshot(groupId, """{"epoch":4}""") + + assertEquals("""{"epoch":4}""", EncryptedMarmotMessageStore(dir, encryption).loadGroupSnapshot(groupId)) + } + + /** Two accounts are two directories: one must never read the other's groups. */ + @Test + fun twoAccountDirectoriesDoNotSeeEachOther() = + runTest { + val (dirA, encA) = accountDir() + val (dirB, encB) = accountDir() + + EncryptedMlsGroupStateStore(dirA, encA).save(groupId, byteArrayOf(1)) + + assertNull("B cannot see A's group", EncryptedMlsGroupStateStore(dirB, encB).load(groupId)) + assertTrue("nor list it", EncryptedMlsGroupStateStore(dirB, encB).listGroups().isEmpty()) + } + + /** The hex check is a path-traversal guard: a crafted id must not be able to leave the account dir. */ + @Test + fun aNonHexGroupIdIsRejected() = + runTest { + val (dir, encryption) = accountDir() + val store = EncryptedMlsGroupStateStore(dir, encryption) + + listOf("../escape", "not hex", "abc/def", "").forEach { bad -> + val thrown = + try { + store.load(bad) + false + } catch (e: IllegalArgumentException) { + true + } + assertTrue("\"$bad\" must be rejected, not resolved to a path", thrown) + } + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/Nip11RetrieverTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relays/nip11/Nip11RetrieverTest.kt similarity index 97% rename from amethyst/src/test/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/Nip11RetrieverTest.kt rename to commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relays/nip11/Nip11RetrieverTest.kt index 07c46de62e..cb936017fe 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/Nip11RetrieverTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relays/nip11/Nip11RetrieverTest.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.model.nip11RelayInfo +package com.vitorpamplona.amethyst.commons.relays.nip11 import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import kotlinx.coroutines.runBlocking diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/service/OnlineCheckerTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/service/OnlineCheckerTest.kt new file mode 100644 index 0000000000..70ebf5bf93 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/service/OnlineCheckerTest.kt @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.service + +import com.vitorpamplona.quartz.utils.TimeUtils +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * [OnlineChecker]'s cache predicates — the part that decides, without touching the network, whether a + * media URL is known-good, known-bad, or unknown. + * + * These decide whether the UI shows a player or a "this is offline" placeholder, and a stale entry + * being trusted is the difference between a video that plays and one that never gets retried. Nothing + * here had coverage while the object sat in `amethyst/`. + * + * The suspend `isOnline` probe is deliberately not exercised: it needs a real OkHttp round trip and + * `commons` has no MockWebServer, so there is no honest way to drive it from here. + */ +class OnlineCheckerTest { + private val url = "https://example.com/video.mp4" + + @Before + fun clearSharedCache() { + // OnlineChecker is an object, so its LruCache outlives each test. + OnlineChecker.checkOnlineCache.evictAll() + } + + private fun seed( + online: Boolean, + ageSeconds: Long, + ) { + OnlineChecker.checkOnlineCache.put(url, OnlineCheckResult(TimeUtils.now() - ageSeconds, online)) + } + + @Test + fun anUnknownUrlIsNeitherOnlineNorKnownOffline() { + assertFalse("nothing cached, so not known online", OnlineChecker.isOnlineCached(url)) + assertFalse("and not known offline either", OnlineChecker.isCachedAndOffline(url)) + } + + @Test + fun aFreshOnlineEntryReadsOnline() { + seed(online = true, ageSeconds = 10) + + assertTrue(OnlineChecker.isOnlineCached(url)) + assertFalse("an online entry is not 'cached and offline'", OnlineChecker.isCachedAndOffline(url)) + } + + @Test + fun aFreshOfflineEntryReadsOffline() { + seed(online = false, ageSeconds = 10) + + assertTrue(OnlineChecker.isCachedAndOffline(url)) + assertFalse("and must not read as online", OnlineChecker.isOnlineCached(url)) + } + + /** + * The five-minute TTL in both directions. Trusting a stale *online* entry shows a player for + * something that has since gone; trusting a stale *offline* one never retries a URL that came back. + */ + @Test + fun anEntryOlderThanFiveMinutesIsTrustedForNothing() { + seed(online = true, ageSeconds = 301) + assertFalse("a stale online entry is no longer online", OnlineChecker.isOnlineCached(url)) + + seed(online = false, ageSeconds = 301) + assertFalse("and a stale offline entry no longer counts as known-offline", OnlineChecker.isCachedAndOffline(url)) + } + + @Test + fun anEntryJustInsideFiveMinutesIsStillTrusted() { + seed(online = true, ageSeconds = 290) + + assertTrue(OnlineChecker.isOnlineCached(url)) + } + + /** Retry is for failures only: dropping a good entry would refetch every URL that already worked. */ + @Test + fun resetIfOfflineToRetryDropsOnlyTheOfflineEntries() { + seed(online = false, ageSeconds = 10) + OnlineChecker.resetIfOfflineToRetry(url) + assertFalse("the offline entry is gone, so the next check refetches", OnlineChecker.isCachedAndOffline(url)) + + seed(online = true, ageSeconds = 10) + OnlineChecker.resetIfOfflineToRetry(url) + assertTrue("the online entry survived", OnlineChecker.isOnlineCached(url)) + } + + @Test + fun aBlankOrNullUrlIsNeverOnline() { + assertFalse(OnlineChecker.isOnlineCached(null)) + assertFalse(OnlineChecker.isOnlineCached(" ")) + assertFalse(OnlineChecker.isCachedAndOffline(null)) + assertFalse(OnlineChecker.isCachedAndOffline(" ")) + } +} From 5b103385bcaed7f2f6fca6592c98ed1ef70ae9b6 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Fri, 25 Sep 2026 21:54:20 +0000 Subject: [PATCH 40/50] refactor(commons): move PowJobStore, OkHttpWebSocket, TorRelayState and the NIP-29 predicates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four small, verified moves. Each was shared-layer code whose only tie to the app module was where the file happened to sit. - PowJobStore -> commons/service/pow. Its interface (PoWJobPersistence) and DTO (PersistedPoWJob) were already in commons; only the File-backed implementation was stranded. Zero android imports. PowJobRestorer stays: it takes an Account. - OkHttpWebSocket -> commons/service/http, beside the other OkHttp adapters. Zero android imports; quartz socket interfaces plus okhttp. Its close-handshake test travels with it. - TorRelayState -> commons/tor, beside the TorRelaySettings/TorType it reasons over. Only androidx.compose.runtime.Stable, which commons allows; jvmAndroid because of okhttp. AccountsTorStateConnector does NOT travel with it, contrary to the survey that proposed this batch — it imports Account and AccountCacheState. - The two pure NIP-29 predicates out of RelaySupportsNip -> commons/relays/nip11, next to the retriever moved in f5fc432e. looksLikeNonNip29Relay and the 2-arg isRelaySignedRelayGroup take a resolved Nip11RelayInformation and are therefore shareable; the three convenience forms that read Amethyst.instance.nip11Cache stay behind. Callers split by which overload they use. No new tests here: these are relocations of code whose behaviour is unchanged, and the one suite that existed (the websocket close handshake) moved with its class. The NIP-29 predicates are now reachable for testing from commons, which they were not before, but writing those tests is separable from the move. The same-package trap bit three more times and is worth naming, because grepping for imports cannot find it: a file in the moved class's own package has no import line to rewrite. It caught AccountsTorStateConnector and PowJobRestorer in main sources, PowAndUsageFileFormatTest in test sources — via PowJobsFile, a second public type declared inside PowJobStore.kt that a search for the class name does not match — and a [PowJobStore] KDoc link in PowMiningForegroundService. The check that actually works is to enumerate every top-level declaration in each moved file and grep for bare usages of all of them. Verified: :commons:jvmTest (2552, 0 failures), :amethyst:testPlayDebugUnitTest, :cli:compileKotlin, :commons:verifyKmpPurity, :commons:compileCommonMainKotlinMetadata, :amethyst:compilePlayDebugKotlin. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AXvKXakvup4inNFfAhhr4L --- .../NotificationFeedFilterModeOverrideTest.kt | 2 +- .../ThreadDualAxisChartAssemblerTest.kt | 2 +- .../com/vitorpamplona/amethyst/AppModules.kt | 6 +- .../model/nip11RelayInfo/RelaySupportsNip.kt | 35 +---------- .../torState/AccountsTorStateConnector.kt | 1 + .../amethyst/service/pow/PowJobRestorer.kt | 1 + .../service/pow/PowMiningForegroundService.kt | 2 +- .../relayGroup/RelayGroupChannelListScreen.kt | 4 +- .../relayGroup/RelayGroupParentPicker.kt | 2 +- .../relayGroup/RelayGroupServerList.kt | 2 +- .../service/pow/PowAndUsageFileFormatTest.kt | 1 + .../commons/relays/nip11/RelaySupportsNip.kt | 61 +++++++++++++++++++ .../commons/service/http}/OkHttpWebSocket.kt | 2 +- .../commons}/service/pow/PowJobStore.kt | 4 +- .../amethyst/commons/tor}/TorRelayState.kt | 7 +-- .../OkHttpWebSocketCloseHandshakeTest.kt | 2 +- 16 files changed, 79 insertions(+), 55 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/nip11/RelaySupportsNip.kt rename {amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp => commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/http}/OkHttpWebSocket.kt (99%) rename {amethyst/src/main/java/com/vitorpamplona/amethyst => commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons}/service/pow/PowJobStore.kt (96%) rename {amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState => commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/tor}/TorRelayState.kt (95%) rename {amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp => commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/service/http}/OkHttpWebSocketCloseHandshakeTest.kt (99%) diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/NotificationFeedFilterModeOverrideTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/NotificationFeedFilterModeOverrideTest.kt index d51726d393..8c8bf58097 100644 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/NotificationFeedFilterModeOverrideTest.kt +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/NotificationFeedFilterModeOverrideTest.kt @@ -25,10 +25,10 @@ import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuMintDirectoryFilterAssembler import com.vitorpamplona.amethyst.commons.relayClient.nip47WalletConnect.NWCPaymentFilterAssembler +import com.vitorpamplona.amethyst.commons.service.http.OkHttpWebSocket import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.service.location.LocationState -import com.vitorpamplona.amethyst.service.okhttp.OkHttpWebSocket import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.dal.NotificationFeedFilter import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ThreadDualAxisChartAssemblerTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ThreadDualAxisChartAssemblerTest.kt index 1a205eaf1b..06c0b9ba0c 100644 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ThreadDualAxisChartAssemblerTest.kt +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ThreadDualAxisChartAssemblerTest.kt @@ -24,11 +24,11 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuMintDirectoryFilterAssembler import com.vitorpamplona.amethyst.commons.relayClient.nip47WalletConnect.NWCPaymentFilterAssembler +import com.vitorpamplona.amethyst.commons.service.http.OkHttpWebSocket import com.vitorpamplona.amethyst.commons.viewmodels.thread.ThreadFeedFilter import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.service.location.LocationState -import com.vitorpamplona.amethyst.service.okhttp.OkHttpWebSocket import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.crypto.verify diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 956a3aba61..f0c1bffff2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -68,11 +68,14 @@ import com.vitorpamplona.amethyst.commons.service.http.DualHttpClientManager import com.vitorpamplona.amethyst.commons.service.http.DualHttpClientManagerForRelays import com.vitorpamplona.amethyst.commons.service.http.EncryptionKeyCache import com.vitorpamplona.amethyst.commons.service.http.LocalBlossomMediaCallFactory +import com.vitorpamplona.amethyst.commons.service.http.OkHttpWebSocket import com.vitorpamplona.amethyst.commons.service.http.OnionLocationCache import com.vitorpamplona.amethyst.commons.service.lnurl.OkHttpLnurlEndpointResolver import com.vitorpamplona.amethyst.commons.service.pow.PoWPolicy import com.vitorpamplona.amethyst.commons.service.pow.PoWPublishQueue +import com.vitorpamplona.amethyst.commons.service.pow.PowJobStore import com.vitorpamplona.amethyst.commons.state.UiSettingsState +import com.vitorpamplona.amethyst.commons.tor.TorRelayState import com.vitorpamplona.amethyst.commons.tor.TorSettings import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState @@ -80,7 +83,6 @@ import com.vitorpamplona.amethyst.model.nip60Cashu.CashuPreferences import com.vitorpamplona.amethyst.model.preferences.UiSharedPreferences import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilder import com.vitorpamplona.amethyst.model.torState.AccountsTorStateConnector -import com.vitorpamplona.amethyst.model.torState.TorRelayState import com.vitorpamplona.amethyst.napplet.DataStoreNappletPermissionStore import com.vitorpamplona.amethyst.service.calendar.CALENDAR_REMINDER_LOG_STORE import com.vitorpamplona.amethyst.service.calendar.CALENDAR_REMINDER_SETTINGS_STORE @@ -102,13 +104,11 @@ import com.vitorpamplona.amethyst.service.notifications.AlwaysOnNotificationServ import com.vitorpamplona.amethyst.service.notifications.NotificationDispatcher import com.vitorpamplona.amethyst.service.notifications.NwcPaymentNotificationWatcher import com.vitorpamplona.amethyst.service.notifications.PokeyReceiver -import com.vitorpamplona.amethyst.service.okhttp.OkHttpWebSocket import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCache import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCacheFactory import com.vitorpamplona.amethyst.service.playback.pip.BackgroundMedia import com.vitorpamplona.amethyst.service.playback.service.PlaybackServiceClient import com.vitorpamplona.amethyst.service.pow.PowJobRestorer -import com.vitorpamplona.amethyst.service.pow.PowJobStore import com.vitorpamplona.amethyst.service.pow.PowMiningForegroundService import com.vitorpamplona.amethyst.service.relayClient.CacheClientConnector import com.vitorpamplona.amethyst.service.relayClient.RelayProxyClientConnector diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/RelaySupportsNip.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/RelaySupportsNip.kt index 792daf74fd..12edff9e8b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/RelaySupportsNip.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/RelaySupportsNip.kt @@ -22,8 +22,8 @@ package com.vitorpamplona.amethyst.model.nip11RelayInfo import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel +import com.vitorpamplona.amethyst.commons.relays.nip11.isRelaySignedRelayGroup import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation /** * Whether [relay]'s cached NIP-11 document advertises support for [nip] (as a decimal string, e.g. @@ -43,38 +43,5 @@ fun relayAdvertisesNip( /** NIP-29 (relay-based groups): the relay must run it for its groups to be real. */ fun relayAdvertisesNip29(relay: NormalizedRelayUrl): Boolean = relayAdvertisesNip(relay, "29") -/** - * Whether [relayInfo] affirmatively signals that its relay does NOT run NIP-29 groups: the doc - * resolved with an explicit `supported_nips` list that lacks "29" and no `self` key (the field - * NIP-29 relays publish so clients can verify their relay-signed group metadata — see - * [isRelaySignedRelayGroup]). A doc with a null `supported_nips` proves nothing (still loading, - * or the fetch failed), so it never triggers the warning. - */ -fun looksLikeNonNip29Relay(relayInfo: Nip11RelayInformation): Boolean = relayInfo.supported_nips?.none { it == "29" } == true && relayInfo.self == null - -/** - * Whether [channel]'s relay-signed metadata is genuinely from its host relay, per NIP-29: - * "these are addressable events signed by the relay keypair directly … as stated by the NIP-11 - * `self` pubkey", and "relays shouldn't accept these events if they're signed by anyone else". - * - * So the authoritative check is `39000.author == relay.self`. When the relay publishes a `self` - * key we enforce that strictly — this rejects a stray user-published 39000 even on a real NIP-29 - * relay. When the relay does NOT advertise `self` at all (we can't verify cryptographically), we - * fall back to the weaker "advertises NIP-29" signal so a compliant relay that merely omits `self` - * still works. A relay with neither fails. Reads only the cached NIP-11 doc ([relayInfo]); callers - * driving a live surface should warm it first and re-evaluate as it resolves. - */ -fun isRelaySignedRelayGroup( - channel: RelayGroupChannel, - relayInfo: Nip11RelayInformation, -): Boolean { - val self = relayInfo.self - return if (self != null) { - channel.event?.pubKey == self - } else { - relayInfo.supported_nips?.any { it == "29" } == true - } -} - /** [isRelaySignedRelayGroup] reading the host relay's cached NIP-11 doc (for non-Compose callers). */ fun isRelaySignedRelayGroup(channel: RelayGroupChannel): Boolean = isRelaySignedRelayGroup(channel, Amethyst.instance.nip11Cache.getFromCache(channel.groupId.relayUrl)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/AccountsTorStateConnector.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/AccountsTorStateConnector.kt index 8f6dc5c9c0..8476c746b8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/AccountsTorStateConnector.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/AccountsTorStateConnector.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.model.torState +import com.vitorpamplona.amethyst.commons.tor.TorRelayState import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowJobRestorer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowJobRestorer.kt index e28ec90c8a..20dad23f49 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowJobRestorer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowJobRestorer.kt @@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPost import com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPostStore import com.vitorpamplona.amethyst.commons.service.pow.PersistedPoWJob import com.vitorpamplona.amethyst.commons.service.pow.PoWPublishQueue +import com.vitorpamplona.amethyst.commons.service.pow.PowJobStore import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowMiningForegroundService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowMiningForegroundService.kt index 08d040d01c..8c373cbdb6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowMiningForegroundService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowMiningForegroundService.kt @@ -45,7 +45,7 @@ import kotlinx.coroutines.launch * * Uses the Android 14+ `shortService` type — no special permission, but a * hard ~3 minute budget. On `onTimeout` the service exits cleanly; every - * persistable job is already checkpointed by [PowJobStore], so anything still + * persistable job is already checkpointed by [com.vitorpamplona.amethyst.commons.service.pow.PowJobStore], so anything still * unmined resumes on the next app launch. Started on every enqueue (the app * is necessarily in the foreground then), stops itself when the queue drains. * diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt index 7ce0a5aed3..1dd12c3e92 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt @@ -74,6 +74,8 @@ import com.vitorpamplona.amethyst.commons.model.navigation.Route import com.vitorpamplona.amethyst.commons.model.navigation.routeFor import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupDeletions +import com.vitorpamplona.amethyst.commons.relays.nip11.isRelaySignedRelayGroup +import com.vitorpamplona.amethyst.commons.relays.nip11.looksLikeNonNip29Relay import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.buzz_channel_create_title import com.vitorpamplona.amethyst.commons.resources.buzz_community_add_people @@ -103,8 +105,6 @@ import com.vitorpamplona.amethyst.commons.ui.screen.LocalDisplaySettings import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.warningColor import com.vitorpamplona.amethyst.commons.util.sortedBySnapshot -import com.vitorpamplona.amethyst.model.nip11RelayInfo.isRelaySignedRelayGroup -import com.vitorpamplona.amethyst.model.nip11RelayInfo.looksLikeNonNip29Relay import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserName import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupParentPicker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupParentPicker.kt index 0fc9ff32be..03b1528cbe 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupParentPicker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupParentPicker.kt @@ -64,6 +64,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel +import com.vitorpamplona.amethyst.commons.relays.nip11.isRelaySignedRelayGroup import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.relay_group_member_count import com.vitorpamplona.amethyst.commons.resources.relay_group_parent_desc @@ -80,7 +81,6 @@ import com.vitorpamplona.amethyst.commons.ui.pluralStringRes import com.vitorpamplona.amethyst.commons.ui.screen.LocalDisplaySettings import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.util.sortedBySnapshot -import com.vitorpamplona.amethyst.model.nip11RelayInfo.isRelaySignedRelayGroup import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupCardWarmupSubscription diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupServerList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupServerList.kt index b471fe199c..391ee0fa22 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupServerList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupServerList.kt @@ -37,6 +37,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.relays.nip11.looksLikeNonNip29Relay import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.relay_group_relay_not_nip29 import com.vitorpamplona.amethyst.commons.ui.screen.LocalDisplaySettings @@ -44,7 +45,6 @@ import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.LargeRelayIconModifier import com.vitorpamplona.amethyst.commons.ui.theme.warningColor import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo -import com.vitorpamplona.amethyst.model.nip11RelayInfo.looksLikeNonNip29Relay import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/pow/PowAndUsageFileFormatTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/pow/PowAndUsageFileFormatTest.kt index dbea72be3d..942d6ae828 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/pow/PowAndUsageFileFormatTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/pow/PowAndUsageFileFormatTest.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.service.pow import com.vitorpamplona.amethyst.commons.service.pow.PersistedPoWJob +import com.vitorpamplona.amethyst.commons.service.pow.PowJobsFile import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageStore import kotlinx.serialization.json.Json import org.junit.Assert.assertEquals diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/nip11/RelaySupportsNip.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/nip11/RelaySupportsNip.kt new file mode 100644 index 0000000000..4f25aeb13c --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/nip11/RelaySupportsNip.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.relays.nip11 + +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel +import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation + +/** + * Whether [relayInfo] affirmatively signals that its relay does NOT run NIP-29 groups: the doc + * resolved with an explicit `supported_nips` list that lacks "29" and no `self` key (the field + * NIP-29 relays publish so clients can verify their relay-signed group metadata — see + * [isRelaySignedRelayGroup]). A doc with a null `supported_nips` proves nothing (still loading, + * or the fetch failed), so it never triggers the warning. + * + * Takes the resolved document rather than a relay URL, which is what makes it shared: the cache + * lookup that produces one is the front end's business, the rule applied to it is not. The + * convenience forms that read Amethyst's in-memory cache stay in the app. + */ +fun looksLikeNonNip29Relay(relayInfo: Nip11RelayInformation): Boolean = relayInfo.supported_nips?.none { it == "29" } == true && relayInfo.self == null + +/** + * Whether [channel]'s relay-signed metadata is genuinely from its host relay, per NIP-29: + * "these are addressable events signed by the relay keypair directly … as stated by the NIP-11 + * `self` pubkey", and "relays shouldn't accept these events if they're signed by anyone else". + * + * So the authoritative check is `39000.author == relay.self`. When the relay publishes a `self` + * key we enforce that strictly — this rejects a stray user-published 39000 even on a real NIP-29 + * relay. When the relay does NOT advertise `self` at all (we can't verify cryptographically), we + * fall back to the weaker "advertises NIP-29" signal so a compliant relay that merely omits `self` + * still works. A relay with neither fails. Reads only the resolved doc ([relayInfo]); callers + * driving a live surface should warm it first and re-evaluate as it resolves. + */ +fun isRelaySignedRelayGroup( + channel: RelayGroupChannel, + relayInfo: Nip11RelayInformation, +): Boolean { + val self = relayInfo.self + return if (self != null) { + channel.event?.pubKey == self + } else { + relayInfo.supported_nips?.any { it == "29" } == true + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpWebSocket.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/http/OkHttpWebSocket.kt similarity index 99% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpWebSocket.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/http/OkHttpWebSocket.kt index e90277072f..60591cc64c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpWebSocket.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/http/OkHttpWebSocket.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.okhttp +package com.vitorpamplona.amethyst.commons.service.http import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocket diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowJobStore.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PowJobStore.kt similarity index 96% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowJobStore.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PowJobStore.kt index 97e68b8b19..4a51a5d68f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowJobStore.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PowJobStore.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.pow +package com.vitorpamplona.amethyst.commons.service.pow -import com.vitorpamplona.amethyst.commons.service.pow.PersistedPoWJob -import com.vitorpamplona.amethyst.commons.service.pow.PoWJobPersistence import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/TorRelayState.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/tor/TorRelayState.kt similarity index 95% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/TorRelayState.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/tor/TorRelayState.kt index 589fe2ebf7..0c00f35f06 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/TorRelayState.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/tor/TorRelayState.kt @@ -18,15 +18,10 @@ * 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.model.torState +package com.vitorpamplona.amethyst.commons.tor import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.service.http.DualHttpClientManager -import com.vitorpamplona.amethyst.commons.tor.RelayClassification -import com.vitorpamplona.amethyst.commons.tor.TorRelayEvaluation -import com.vitorpamplona.amethyst.commons.tor.TorRelaySettings -import com.vitorpamplona.amethyst.commons.tor.TorSettingsFlow -import com.vitorpamplona.amethyst.commons.tor.TorType import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpWebSocketCloseHandshakeTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/service/http/OkHttpWebSocketCloseHandshakeTest.kt similarity index 99% rename from amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpWebSocketCloseHandshakeTest.kt rename to commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/service/http/OkHttpWebSocketCloseHandshakeTest.kt index 907a0b1ec4..424bba7093 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpWebSocketCloseHandshakeTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/service/http/OkHttpWebSocketCloseHandshakeTest.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.okhttp +package com.vitorpamplona.amethyst.commons.service.http import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocketListener From e88bf34a7410f0bc8a42aa869e8b7406c6f8bd97 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Fri, 25 Sep 2026 21:56:17 +0000 Subject: [PATCH 41/50] ci: give Android Lint its own job instead of tuning memory a third time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test-and-build-android has now died with "the runner has received a shutdown signal" seven times in this workflow's history — SIGTERM, exit 143, the OOM killer taking the runner agent on a 16GB box. Twice on this PR alone: 2cd6569f at 35 minutes during compileFdroidDebugKotlin, f5fc432e at 29.5 minutes during testFdroidDebugUnitTest. Neither had a failing test; the job is simply killed, which is why the report steps come back "skipped" rather than red. Two attempts to fix this by changing numbers are already spent, and the existing comments in this file record both. Capping both daemons to 4g traded the runner OOM for an R8/lintAnalyze "java.lang.OutOfMemoryError: Java heap space" — those draw on the Gradle daemon's heap and 4g is not enough for them here. Dropping to --max-workers=3 survives a warm cache but still dies on a cold one, which is the case that matters: a PR touching quartz or commons invalidates the read-only cache the PR runs restore, so they rebuild from cold at 2-3x the main-branch time. Every commit on this PR touches commons. So this is the structural fix rather than a third number. lintAnalyze is the single heaviest step in that job — 13 of the 35 minutes on the 2cd6569f run — and it holds a large analysis graph while the Kotlin daemon, a forked test JVM and R8 are still resident. Moving the two lint tasks to their own runner removes that peak from the critical job instead of trying to squeeze everything under one ceiling, and the two now run concurrently rather than in series. The cost is real and worth stating: both jobs restore the same read-only Gradle cache and therefore repeat some module compilation. That trade is favourable because the duplicated work is parallel while the memory pressure was not. Nothing about the tasks themselves changes — same task names, same daemon caps, same --max-workers. The lint-report artifact upload moves to the new job. Not verified locally: lintPlayBenchmark is a 13-minute task on CI hardware and this container is the same shape as the runner that keeps dying, so running it here would prove nothing useful. What is checked is that the workflow still parses, that lint-android carries needs: lint like its sibling, and that the android job no longer references the lint tasks. CI is the test for this one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AXvKXakvup4inNFfAhhr4L --- .github/workflows/build.yml | 80 ++++++++++++++++++++++++++++++------- 1 file changed, 65 insertions(+), 15 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 72b2548016..32fad8469a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -335,6 +335,62 @@ jobs: name: Quartz iOS Test Reports path: quartz/build/reports + # Android Lint, split out of test-and-build-android. + # + # That job died with "the runner has received a shutdown signal" (SIGTERM, + # exit 143) seven times across this workflow's history — the OOM killer taking + # the runner agent on a 16GB box. Two attempts to fix it by tuning numbers + # have now been spent: capping both daemons to 4g traded the runner OOM for an + # R8/lintAnalyze "Java heap space", and --max-workers=3 survives a warm cache + # but still dies on a cold one. + # + # This is the structural fix rather than a third number. lintAnalyze is the + # single heaviest step in that job — measured at 13 of its 35 minutes on one + # cold run — and it holds a large analysis graph while the Kotlin daemon, a + # forked test JVM and R8 are all still resident. Giving the two lint tasks + # their own runner removes that peak from the critical job instead of trying + # to squeeze everything under one ceiling, and the two now run concurrently. + # + # The cost is honest: both jobs restore the same read-only Gradle cache and so + # repeat some module compilation. That buys back more than it spends here, + # because the duplicated work is parallel while the memory pressure was not. + lint-android: + needs: lint + runs-on: ubuntu-latest + timeout-minutes: 90 + steps: + - name: Checkout code + uses: actions/checkout@v7 + + - name: Set up JDK 21 + uses: actions/setup-java@v6.0.0 + with: + distribution: 'temurin' + java-version: 21 + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v6 + with: + cache-read-only: ${{ github.ref != 'refs/heads/main' }} + + # Same daemon cap as the sibling job: lintAnalyze draws on the Gradle + # daemon's heap, which is why the earlier 4g experiment broke it. Only the + # Kotlin daemon is trimmed. + - name: Lint Android (gradle) + run: | + ./gradlew \ + -Dkotlin.daemon.jvmargs="-Xmx4g -XX:MaxMetaspaceSize=1g" \ + --max-workers=3 \ + :amethyst:lintFdroidBenchmark \ + :amethyst:lintPlayBenchmark + + - name: Upload Android Lint Reports + uses: actions/upload-artifact@v7 + if: always() + with: + name: Android Lint Reports + path: amethyst/build/reports/lint-results-*.html + test-and-build-android: needs: lint runs-on: ubuntu-latest @@ -363,12 +419,15 @@ jobs: with: cache-read-only: ${{ github.ref != 'refs/heads/main' }} - # Lint + focused unit tests + benchmark assembly in one Gradle invocation. - # Previously: one invocation for lint, one for `test` (which compiled all - # six amethyst variants × all flavors), one for `assembleBenchmark` - # (re-walking the same task graph). Combining them keeps the daemon hot - # across phases and lets task-level dedup (e.g. compileKotlin) only - # happen once. + # Focused unit tests + benchmark assembly in one Gradle invocation. + # Previously: one invocation for `test` (which compiled all six amethyst + # variants × all flavors) and one for `assembleBenchmark` (re-walking the + # same task graph). Combining them keeps the daemon hot across phases and + # lets task-level dedup (e.g. compileKotlin) only happen once. + # + # Lint used to run here too and now has its own job (lint-android above) — + # see the note there for why. What remains is still the heaviest job in + # the workflow, so the memory notes below continue to apply. # # `-PdisableAbiSplits=true` produces a single non-split APK per # (flavor, buildType) instead of 5 (4 ABIs + universal). The CI only @@ -421,8 +480,6 @@ jobs: ./gradlew \ -Dkotlin.daemon.jvmargs="-Xmx4g -XX:MaxMetaspaceSize=1g" \ --max-workers=3 \ - :amethyst:lintFdroidBenchmark \ - :amethyst:lintPlayBenchmark \ :quartz:jvmTest \ :commons:jvmTest \ :commonsUI:jvmTest \ @@ -433,13 +490,6 @@ jobs: :amethyst:assembleBenchmark \ -PdisableAbiSplits=true - - name: Upload Android Lint Reports - uses: actions/upload-artifact@v7 - if: always() - with: - name: Android Lint Reports - path: amethyst/build/reports/lint-results-*.html - # Publishes the JUnit XML produced by the unit-test tasks above as inline # annotations plus a job summary. Replaces asadmansr/android-test-report-action, # which was abandoned (last release 2020) and rebuilt an EOL Ubuntu 18.04 + From 07436be50d26cb1109a8db4974ed02fda171f5f6 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Fri, 25 Sep 2026 22:19:51 +0000 Subject: [PATCH 42/50] refactor(commons): match the documented package conventions for the moved classes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A review of where this session's moves landed, against commons/ARCHITECTURE.md — which I should have read before placing them, not after. Two of the ten placements broke conventions the doc states explicitly. relays/nip11 -> relays/nip11RelayInfo. The rule is "inside a layer, NIP-specific code goes in a nipNN<slug> subpackage whose name matches quartz exactly", with the worked trace quartz/nip57Zaps -> commons/model/nip57Zaps. Quartz's package is nip11RelayInfo, so nip11 broke the trace the convention exists to preserve. The point is that someone reading quartz/nip11RelayInfo can guess the commons package without searching. PowJobStore -> PoWJobStore (and PowJobsFile -> PoWJobsFile). Its five new siblings are PoWEstimator, PoWJobPersistence, PoWPolicy, PoWPublishQueue and PoWReplay. The name was fine in amethyst/service/pow; moving it next to those made it the odd one out. Same reasoning as the Encrypted* rename earlier: a name that was merely unremarkable in its old home can be wrong in the new one. Not changed, and the reasoning for each: - service/OnlineCheck.kt stays loose at the service root. Its callers are livestream and video URL checks and it also probes wss:// for LiveKit, so service/image is too narrow and service/connectivity is about device connectivity, not URL reachability. BundledUpdate.kt already sits at that root, and the doc says to resist a new package for a single file. This is the weakest of the ten placements and worth revisiting if a second URL-probing concern ever appears. - The amethyst-side Pow* classes (PowJobRestorer, PowMiningForegroundService, the PowOverrideButton/PowDifficultyTile UI) keep their spelling. That inconsistency predates this work and lives in a different module; renaming it is a separate change. - marmot, favorites, browser, tor, service/pow and service/http were already correct: each moved class landed in a package that existed and holds its siblings. marmot in particular is named in the doc as a self-contained feature package matching quartz. Verified: :commons:jvmTest, :amethyst:testPlayDebugUnitTest, :cli:compileKotlin, :commons:verifyKmpPurity, :commons:compileCommonMainKotlinMetadata, spotless. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AXvKXakvup4inNFfAhhr4L --- .../main/java/com/vitorpamplona/amethyst/AppModules.kt | 6 +++--- .../amethyst/model/nip11RelayInfo/LoadRelayInfo.kt | 2 +- .../amethyst/model/nip11RelayInfo/RelaySupportsNip.kt | 2 +- .../amethyst/service/pow/PowJobRestorer.kt | 6 +++--- .../amethyst/service/pow/PowMiningForegroundService.kt | 2 +- .../relayGroup/RelayGroupChannelListScreen.kt | 4 ++-- .../relayGroup/RelayGroupParentPicker.kt | 2 +- .../publicChannels/relayGroup/RelayGroupServerList.kt | 2 +- .../relays/common/BasicRelaySetupInfoClickableRow.kt | 2 +- .../relays/common/BasicRelaySetupInfoDialog.kt | 2 +- .../screen/loggedIn/relays/common/RelayUrlEditField.kt | 2 +- .../loggedIn/relays/common/ShowRelaySuggestionList.kt | 2 +- .../loggedIn/relays/vanish/RequestToVanishScreen.kt | 2 +- .../amethyst/service/pow/PowAndUsageFileFormatTest.kt | 8 ++++---- .../{nip11 => nip11RelayInfo}/RelaySupportsNip.kt | 2 +- .../{nip11 => nip11RelayInfo}/Nip11CachedRetriever.kt | 2 +- .../relays/{nip11 => nip11RelayInfo}/Nip11Retriever.kt | 2 +- .../relays/{nip11 => nip11RelayInfo}/RetrieveResult.kt | 2 +- .../service/pow/{PowJobStore.kt => PoWJobStore.kt} | 10 +++++----- .../{nip11 => nip11RelayInfo}/Nip11RetrieverTest.kt | 2 +- 20 files changed, 32 insertions(+), 32 deletions(-) rename commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/{nip11 => nip11RelayInfo}/RelaySupportsNip.kt (98%) rename commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relays/{nip11 => nip11RelayInfo}/Nip11CachedRetriever.kt (99%) rename commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relays/{nip11 => nip11RelayInfo}/Nip11Retriever.kt (98%) rename commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relays/{nip11 => nip11RelayInfo}/RetrieveResult.kt (96%) rename commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/pow/{PowJobStore.kt => PoWJobStore.kt} (96%) rename commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relays/{nip11 => nip11RelayInfo}/Nip11RetrieverTest.kt (97%) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index f0c1bffff2..3e0c5f6a6e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -55,7 +55,7 @@ import com.vitorpamplona.amethyst.commons.relayClient.event.EventFinderQueryStat import com.vitorpamplona.amethyst.commons.relayClient.speedLogger.RelaySpeedLogger import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderQueryState import com.vitorpamplona.amethyst.commons.relays.health.TorCircuitHealthTracker -import com.vitorpamplona.amethyst.commons.relays.nip11.Nip11CachedRetriever +import com.vitorpamplona.amethyst.commons.relays.nip11RelayInfo.Nip11CachedRetriever import com.vitorpamplona.amethyst.commons.richtext.CachedAsciiDocToMarkdown import com.vitorpamplona.amethyst.commons.richtext.CachedRichTextParser import com.vitorpamplona.amethyst.commons.robohash.CachedRobohash @@ -71,9 +71,9 @@ import com.vitorpamplona.amethyst.commons.service.http.LocalBlossomMediaCallFact import com.vitorpamplona.amethyst.commons.service.http.OkHttpWebSocket import com.vitorpamplona.amethyst.commons.service.http.OnionLocationCache import com.vitorpamplona.amethyst.commons.service.lnurl.OkHttpLnurlEndpointResolver +import com.vitorpamplona.amethyst.commons.service.pow.PoWJobStore import com.vitorpamplona.amethyst.commons.service.pow.PoWPolicy import com.vitorpamplona.amethyst.commons.service.pow.PoWPublishQueue -import com.vitorpamplona.amethyst.commons.service.pow.PowJobStore import com.vitorpamplona.amethyst.commons.state.UiSettingsState import com.vitorpamplona.amethyst.commons.tor.TorRelayState import com.vitorpamplona.amethyst.commons.tor.TorSettings @@ -983,7 +983,7 @@ class AppModules( // and every enqueue raises the shortService shield so backgrounding // doesn't freeze a miner. val powJobStore by lazy { - PowJobStore(File(appContext.filesDir, PowJobStore.FILE_NAME), applicationIOScope) + PoWJobStore(File(appContext.filesDir, PoWJobStore.FILE_NAME), applicationIOScope) } val powPublishQueue by lazy { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/LoadRelayInfo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/LoadRelayInfo.kt index 0d8df07141..86a39afb05 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/LoadRelayInfo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/LoadRelayInfo.kt @@ -25,7 +25,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.State import androidx.compose.runtime.produceState import com.vitorpamplona.amethyst.Amethyst -import com.vitorpamplona.amethyst.commons.relays.nip11.Nip11CachedRetriever +import com.vitorpamplona.amethyst.commons.relays.nip11RelayInfo.Nip11CachedRetriever import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation import com.vitorpamplona.quartz.utils.Log diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/RelaySupportsNip.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/RelaySupportsNip.kt index 12edff9e8b..6cf7b588c7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/RelaySupportsNip.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/RelaySupportsNip.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.model.nip11RelayInfo import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel -import com.vitorpamplona.amethyst.commons.relays.nip11.isRelaySignedRelayGroup +import com.vitorpamplona.amethyst.commons.relays.nip11RelayInfo.isRelaySignedRelayGroup import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl /** diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowJobRestorer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowJobRestorer.kt index 20dad23f49..2dfdcfb482 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowJobRestorer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowJobRestorer.kt @@ -23,8 +23,8 @@ package com.vitorpamplona.amethyst.service.pow import com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPost import com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPostStore import com.vitorpamplona.amethyst.commons.service.pow.PersistedPoWJob +import com.vitorpamplona.amethyst.commons.service.pow.PoWJobStore import com.vitorpamplona.amethyst.commons.service.pow.PoWPublishQueue -import com.vitorpamplona.amethyst.commons.service.pow.PowJobStore import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @@ -34,14 +34,14 @@ import com.vitorpamplona.quartz.utils.Log import java.util.UUID /** - * Re-enqueues the mining jobs checkpointed by [PowJobStore] when an account + * Re-enqueues the mining jobs checkpointed by [PoWJobStore] when an account * logs in, replacing the lost in-memory continuation with the headless replay * described by each record. Restore is idempotent: the queue dedupes by job * id, so a login flow that emits twice cannot double-mine. */ class PowJobRestorer( private val queue: PoWPublishQueue, - private val store: PowJobStore, + private val store: PoWJobStore, private val scheduledPostStore: ScheduledPostStore, ) { suspend fun restore(account: Account) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowMiningForegroundService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowMiningForegroundService.kt index 8c373cbdb6..2686ae587d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowMiningForegroundService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowMiningForegroundService.kt @@ -45,7 +45,7 @@ import kotlinx.coroutines.launch * * Uses the Android 14+ `shortService` type — no special permission, but a * hard ~3 minute budget. On `onTimeout` the service exits cleanly; every - * persistable job is already checkpointed by [com.vitorpamplona.amethyst.commons.service.pow.PowJobStore], so anything still + * persistable job is already checkpointed by [com.vitorpamplona.amethyst.commons.service.pow.PoWJobStore], so anything still * unmined resumes on the next app launch. Started on every enqueue (the app * is necessarily in the foreground then), stops itself when the queue drains. * diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt index 1dd12c3e92..c6e2de0394 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt @@ -74,8 +74,8 @@ import com.vitorpamplona.amethyst.commons.model.navigation.Route import com.vitorpamplona.amethyst.commons.model.navigation.routeFor import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupDeletions -import com.vitorpamplona.amethyst.commons.relays.nip11.isRelaySignedRelayGroup -import com.vitorpamplona.amethyst.commons.relays.nip11.looksLikeNonNip29Relay +import com.vitorpamplona.amethyst.commons.relays.nip11RelayInfo.isRelaySignedRelayGroup +import com.vitorpamplona.amethyst.commons.relays.nip11RelayInfo.looksLikeNonNip29Relay import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.buzz_channel_create_title import com.vitorpamplona.amethyst.commons.resources.buzz_community_add_people diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupParentPicker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupParentPicker.kt index 03b1528cbe..d0da74a4c0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupParentPicker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupParentPicker.kt @@ -64,7 +64,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel -import com.vitorpamplona.amethyst.commons.relays.nip11.isRelaySignedRelayGroup +import com.vitorpamplona.amethyst.commons.relays.nip11RelayInfo.isRelaySignedRelayGroup import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.relay_group_member_count import com.vitorpamplona.amethyst.commons.resources.relay_group_parent_desc diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupServerList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupServerList.kt index 391ee0fa22..8489a8e868 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupServerList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupServerList.kt @@ -37,7 +37,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols -import com.vitorpamplona.amethyst.commons.relays.nip11.looksLikeNonNip29Relay +import com.vitorpamplona.amethyst.commons.relays.nip11RelayInfo.looksLikeNonNip29Relay import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.relay_group_relay_not_nip29 import com.vitorpamplona.amethyst.commons.ui.screen.LocalDisplaySettings diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoClickableRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoClickableRow.kt index 90ff2fca72..a9f424323e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoClickableRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoClickableRow.kt @@ -41,7 +41,7 @@ import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols -import com.vitorpamplona.amethyst.commons.relays.nip11.Nip11CachedRetriever +import com.vitorpamplona.amethyst.commons.relays.nip11RelayInfo.Nip11CachedRetriever import com.vitorpamplona.amethyst.commons.relays.ui.RelayCountResult import com.vitorpamplona.amethyst.commons.relays.ui.RelayDragState import com.vitorpamplona.amethyst.commons.relays.ui.RelayEventCountRow diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoDialog.kt index a188c63141..85fe0a2ad8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoDialog.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import com.vitorpamplona.amethyst.commons.model.navigation.Route -import com.vitorpamplona.amethyst.commons.relays.nip11.Nip11CachedRetriever +import com.vitorpamplona.amethyst.commons.relays.nip11RelayInfo.Nip11CachedRetriever import com.vitorpamplona.amethyst.commons.relays.ui.RelayCountResult import com.vitorpamplona.amethyst.commons.relays.ui.RelayDragState import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayUrlEditField.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayUrlEditField.kt index ca80618511..c069ff51dd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayUrlEditField.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayUrlEditField.kt @@ -45,7 +45,7 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.Amethyst -import com.vitorpamplona.amethyst.commons.relays.nip11.Nip11CachedRetriever +import com.vitorpamplona.amethyst.commons.relays.nip11RelayInfo.Nip11CachedRetriever import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.add import com.vitorpamplona.amethyst.commons.resources.add_a_relay diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/ShowRelaySuggestionList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/ShowRelaySuggestionList.kt index ec43a64991..3c9c6771c1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/ShowRelaySuggestionList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/ShowRelaySuggestionList.kt @@ -26,7 +26,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.commons.relays.nip11.Nip11CachedRetriever +import com.vitorpamplona.amethyst.commons.relays.nip11RelayInfo.Nip11CachedRetriever import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.screen.LocalDisplaySettings import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/vanish/RequestToVanishScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/vanish/RequestToVanishScreen.kt index 47d7aa6059..abbb321c4c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/vanish/RequestToVanishScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/vanish/RequestToVanishScreen.kt @@ -68,7 +68,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols -import com.vitorpamplona.amethyst.commons.relays.nip11.Nip11CachedRetriever +import com.vitorpamplona.amethyst.commons.relays.nip11RelayInfo.Nip11CachedRetriever import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.cancel import com.vitorpamplona.amethyst.commons.resources.confirm diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/pow/PowAndUsageFileFormatTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/pow/PowAndUsageFileFormatTest.kt index 942d6ae828..91583920c1 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/pow/PowAndUsageFileFormatTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/pow/PowAndUsageFileFormatTest.kt @@ -21,7 +21,7 @@ package com.vitorpamplona.amethyst.service.pow import com.vitorpamplona.amethyst.commons.service.pow.PersistedPoWJob -import com.vitorpamplona.amethyst.commons.service.pow.PowJobsFile +import com.vitorpamplona.amethyst.commons.service.pow.PoWJobsFile import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageStore import kotlinx.serialization.json.Json import org.junit.Assert.assertEquals @@ -44,7 +44,7 @@ class PowAndUsageFileFormatTest { } private val powSample = - PowJobsFile( + PoWJobsFile( version = 1, jobs = listOf( @@ -80,7 +80,7 @@ class PowAndUsageFileFormatTest { @Test fun powJobsFromTheJacksonBuildStillLoad() { - val loaded = json.decodeFromString<PowJobsFile>(POW_JACKSON_OUTPUT) + val loaded = json.decodeFromString<PoWJobsFile>(POW_JACKSON_OUTPUT) assertEquals(1, loaded.jobs.size) val job = loaded.jobs.first() @@ -114,7 +114,7 @@ class PowAndUsageFileFormatTest { assertEquals( "j1", json - .decodeFromString<PowJobsFile>(pow) + .decodeFromString<PoWJobsFile>(pow) .jobs .first() .id, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/nip11/RelaySupportsNip.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/nip11RelayInfo/RelaySupportsNip.kt similarity index 98% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/nip11/RelaySupportsNip.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/nip11RelayInfo/RelaySupportsNip.kt index 4f25aeb13c..d59f7bd6c1 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/nip11/RelaySupportsNip.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/nip11RelayInfo/RelaySupportsNip.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.commons.relays.nip11 +package com.vitorpamplona.amethyst.commons.relays.nip11RelayInfo import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relays/nip11/Nip11CachedRetriever.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relays/nip11RelayInfo/Nip11CachedRetriever.kt similarity index 99% rename from commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relays/nip11/Nip11CachedRetriever.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relays/nip11RelayInfo/Nip11CachedRetriever.kt index 5e89abff2a..b6fc1b1e38 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relays/nip11/Nip11CachedRetriever.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relays/nip11RelayInfo/Nip11CachedRetriever.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.commons.relays.nip11 +package com.vitorpamplona.amethyst.commons.relays.nip11RelayInfo import androidx.collection.LruCache import androidx.compose.runtime.Stable diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relays/nip11/Nip11Retriever.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relays/nip11RelayInfo/Nip11Retriever.kt similarity index 98% rename from commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relays/nip11/Nip11Retriever.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relays/nip11RelayInfo/Nip11Retriever.kt index 8380827a66..a8ca53429d 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relays/nip11/Nip11Retriever.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relays/nip11RelayInfo/Nip11Retriever.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.commons.relays.nip11 +package com.vitorpamplona.amethyst.commons.relays.nip11RelayInfo import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.toHttp diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relays/nip11/RetrieveResult.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relays/nip11RelayInfo/RetrieveResult.kt similarity index 96% rename from commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relays/nip11/RetrieveResult.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relays/nip11RelayInfo/RetrieveResult.kt index 1fa3fde337..bb44f63cb7 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relays/nip11/RetrieveResult.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relays/nip11RelayInfo/RetrieveResult.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.commons.relays.nip11 +package com.vitorpamplona.amethyst.commons.relays.nip11RelayInfo import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation import com.vitorpamplona.quartz.utils.TimeUtils diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PowJobStore.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWJobStore.kt similarity index 96% rename from commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PowJobStore.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWJobStore.kt index 4a51a5d68f..7ee8316c18 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PowJobStore.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWJobStore.kt @@ -39,7 +39,7 @@ import java.io.File * [save]/[remove] are the queue-facing fire-and-forget hooks; they serialize * onto a single-lane dispatcher so writes land in call order. */ -class PowJobStore( +class PoWJobStore( private val storageFile: File, scope: CoroutineScope, ) : PoWJobPersistence { @@ -109,7 +109,7 @@ class PowJobStore( jobs = try { if (storageFile.exists() && storageFile.length() > 0) { - json.decodeFromString<PowJobsFile>(storageFile.readText()).jobs.toMutableList() + json.decodeFromString<PoWJobsFile>(storageFile.readText()).jobs.toMutableList() } else { mutableListOf() } @@ -126,7 +126,7 @@ class PowJobStore( storageFile.parentFile?.mkdirs() val tmp = File(storageFile.parentFile, storageFile.name + ".tmp") try { - tmp.writeText(json.encodeToString(PowJobsFile(version = 1, jobs = jobs.toList()))) + tmp.writeText(json.encodeToString(PoWJobsFile(version = 1, jobs = jobs.toList()))) if (!tmp.renameTo(storageFile)) { if (!storageFile.delete() || !tmp.renameTo(storageFile)) { Log.e(TAG) { "Failed to rename $tmp to $storageFile" } @@ -144,7 +144,7 @@ class PowJobStore( } companion object { - private const val TAG = "PowJobStore" + private const val TAG = "PoWJobStore" const val FILE_NAME = "pending_pow_jobs.json" // a job this stale is a post the user has long forgotten; publishing @@ -154,7 +154,7 @@ class PowJobStore( } @Serializable -data class PowJobsFile( +data class PoWJobsFile( val version: Int = 1, val jobs: List<PersistedPoWJob> = emptyList(), ) diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relays/nip11/Nip11RetrieverTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relays/nip11RelayInfo/Nip11RetrieverTest.kt similarity index 97% rename from commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relays/nip11/Nip11RetrieverTest.kt rename to commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relays/nip11RelayInfo/Nip11RetrieverTest.kt index cb936017fe..73a11a6335 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relays/nip11/Nip11RetrieverTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relays/nip11RelayInfo/Nip11RetrieverTest.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.commons.relays.nip11 +package com.vitorpamplona.amethyst.commons.relays.nip11RelayInfo import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import kotlinx.coroutines.runBlocking From 8346b43c06a45a6bdd8ac3efb02826cdc86fb0ad Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Fri, 25 Sep 2026 22:42:25 +0000 Subject: [PATCH 43/50] perf(commons): resolve the favicon directory once instead of per lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regression I introduced in 2cd6569f, found by a pre-merge review pass. The object this replaced cached its directory as a File in init(). Taking iconDir as a () -> Path is what freed the class from Context, but I then called it on every access, including from iconModelFor. On Android that lambda is `appContext.filesDir.toOkioPath() / DIR`, and Context.getFilesDir() is not a field read: it takes a lock and calls mkdir() to ensure the directory exists. iconModelFor is read from composition on the main thread — the bottom bar, the favorites grid, the omnibox suggestions and the napplet icon all call it, once per icon per recomposition. So the move quietly put a filesystem syscall, a lock and two allocations on every frame that draws a favicon. Resolved once in a lazy now, which is what the old code did and what the lambda was always meant to allow: the front end still owns the location, nothing resolves at construction, and the path is computed on first use rather than on every use. No test changes. aMissingIconDirectoryIsCreated already covers the case that matters for laziness — the directory does not exist when the registry is built, and is created when init() first touches it — and still passes. Verified: :commons:jvmTest, :commons:verifyKmpPurity, :commons:compileCommonMainKotlinMetadata, :amethyst:compilePlayDebugKotlin. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AXvKXakvup4inNFfAhhr4L --- .../amethyst/commons/browser/BrowserIconRegistry.kt | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserIconRegistry.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserIconRegistry.kt index f7f6c4bb69..bdd40d5199 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserIconRegistry.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserIconRegistry.kt @@ -54,6 +54,15 @@ class BrowserIconRegistry( private val iconDir: () -> Path, private val scope: CoroutineScope, ) { + // Resolved once, not per call. [iconDir] is a lambda so the front end owns the location and + // nothing resolves at construction; but on Android it is `appContext.filesDir`, and + // Context.getFilesDir() takes a lock and mkdir()s the directory every time it is asked. The + // object this replaced cached the File in init(), and iconModelFor is read from composition on + // the main thread by the bottom bar, the favorites grid, the omnibox suggestions and the + // napplet icon — so re-invoking the lambda there put a filesystem syscall on every frame that + // draws an icon. + private val dir: Path by lazy { iconDir() } + private val _keys = MutableStateFlow<Set<String>>(emptySet()) /** Sanitized host keys that currently have a stored icon. Observe to recompose when an icon arrives. */ @@ -73,7 +82,6 @@ class BrowserIconRegistry( started = true scope.launch { try { - val dir = iconDir() platformFileSystem.createDirectories(dir) val scanned = platformFileSystem @@ -103,7 +111,6 @@ class BrowserIconRegistry( // icon exists before the file backing it does. scope.launch { try { - val dir = iconDir() platformFileSystem.createDirectories(dir) platformFileSystem.write(dir / (key + PNG)) { write(bytes) } _keys.update { it + key } @@ -120,7 +127,7 @@ class BrowserIconRegistry( fun iconModelFor(host: String): String? { val key = sanitize(host) if (key !in _keys.value) return null - return "file://" + (iconDir() / (key + PNG)) + return "file://" + (dir / (key + PNG)) } companion object { From 333d305c1ac5358a62805219e029f0202e05bb7f Mon Sep 17 00:00:00 2001 From: Vitor Pamplona <vitor@vitorpamplona.com> Date: Sat, 26 Sep 2026 15:47:25 -0400 Subject: [PATCH 44/50] fix(cordn): observe the two StateFlows these screens were reading flat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Android Lint's `StateFlowValueCalledInComposition` on both, and in both cases the value it could not see changing is one the screen exists to show. `CordnCreateGroupScreen` read `runtime.groups.all.value` to count the groups this coordinator already carries for the exposure card. That count is the number the card is *about* — how much one operator can correlate — and creating a group is exactly what moves it, so a frozen read is wrong on the screen where it matters most. `CordnInvitationsScreen` read `runtime.coordinators.value` to choose between "you have no coordinators" and "nobody has invited you". Adding a coordinator elsewhere has to change which of those two sentences is on screen; otherwise it keeps telling you to add one you already added. Both now collect with `collectAsStateWithLifecycle`, which the create screen was already doing one line above for `coordinators`. Found running the Android Lint job locally; it was the only thing failing it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- .../chats/cordnGroup/CordnCreateGroupScreen.kt | 11 +++++++---- .../chats/cordnGroup/CordnInvitationsScreen.kt | 9 ++++++++- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnCreateGroupScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnCreateGroupScreen.kt index 651859f69e..6deedde2d4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnCreateGroupScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnCreateGroupScreen.kt @@ -147,6 +147,12 @@ fun CordnCreateGroupScreen( } val known by runtime.coordinators.collectAsStateWithLifecycle() + // Collected, not read as `.value` below: the exposure card counts the + // groups this coordinator already carries, and reading the flow inside + // composition would leave that count frozen at whatever it was when the + // screen opened — the one number on the card that can change while you + // are looking at it, since creating a group is what changes it. + val rooms by runtime.groups.all.collectAsStateWithLifecycle() val scope = rememberCoroutineScope() val me = accountViewModel.account.signer.pubKey @@ -491,10 +497,7 @@ fun CordnCreateGroupScreen( // serves for this account: the disclosure is about what // one coordinator can correlate, so a second group on // the same one widens it. - linkedGroupCount = - 1 + - runtime.groups.all.value - .count { room -> room.coordinatorPubKey == it.pubKey }, + linkedGroupCount = 1 + rooms.count { room -> room.coordinatorPubKey == it.pubKey }, joinedFromShareLink = false, publishedKeyPackage = false, encryptionPinned = true, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnInvitationsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnInvitationsScreen.kt index 147527e9ea..a1292ad0e6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnInvitationsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnInvitationsScreen.kt @@ -50,6 +50,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols @@ -155,6 +156,12 @@ fun CordnInvitationsScreen( return@Column } + // Collected rather than read as `.value` further down: the empty + // state says either "you have no coordinators" or "nobody has + // invited you", and adding a coordinator in another tab has to + // change which of those two this screen is showing. + val coordinators by runtime.coordinators.collectAsStateWithLifecycle() + Text( text = stringRes(R.string.cordn_invitations_explainer), style = MaterialTheme.typography.bodyMedium, @@ -227,7 +234,7 @@ fun CordnInvitationsScreen( if (loaded.isEmpty) { Text( text = - if (runtime.coordinators.value.isEmpty()) { + if (coordinators.isEmpty()) { stringRes(R.string.cordn_invitations_no_coordinators) } else { stringRes(R.string.cordn_invitations_none) From 69c2feab3ac35c0f2153e444c99136bb5e04324b Mon Sep 17 00:00:00 2001 From: Vitor Pamplona <vitor@vitorpamplona.com> Date: Sat, 26 Sep 2026 15:59:18 -0400 Subject: [PATCH 45/50] fix(quartz): drop the commas Kotlin/Native will not take in a test name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `:quartz:compileTestKotlinLinuxX64` and `compileTestKotlinIosSimulatorArm64` both fail with `Name contains illegal characters: ","`. A backtick-quoted identifier may hold a comma on the JVM and may not on Native, and these are `commonTest`, so they build for every target. Sixteen names across seven cordn/contextvm test files, all of which arrived with #4201 and none of which had ever been compiled for a native target. Reworded rather than stripped: "a failed negotiation, not a downgrade" becomes "rather than a downgrade" and the three "needs kind N, an e tag, and text" names become "with an e tag and text", so each still reads as the sentence it was. Hyphens and apostrophes are fine on Native and are left alone. Checked the whole of commonTest for the rest of the illegal set as well (. ; : [ ] / < > \) — the commas were all of it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- .../quartz/contextvm/cep08Payments/PaymentTest.kt | 2 +- .../cep22OversizedTransfer/OversizedTransferTest.kt | 2 +- .../quartz/contextvm/core/CvmMessageEventTest.kt | 4 ++-- .../quartz/cordn/appEncryptedMedia/CordnBlobUploadTest.kt | 6 +++--- .../cordn/appEncryptedMedia/CordnMediaEncryptionTest.kt | 6 +++--- .../cordn/spec02Envelopes/CordnAnnotationIndexTest.kt | 4 ++-- .../quartz/cordn/spec02Envelopes/CordnMessageKindsTest.kt | 8 ++++---- 7 files changed, 16 insertions(+), 16 deletions(-) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/contextvm/cep08Payments/PaymentTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/contextvm/cep08Payments/PaymentTest.kt index da76d4711c..2225f18361 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/contextvm/cep08Payments/PaymentTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/contextvm/cep08Payments/PaymentTest.kt @@ -184,7 +184,7 @@ class PaymentTest { } @Test - fun `CVM-8-22 a server that ignores the request is a failed negotiation, not a downgrade`() { + fun `CVM-8-22 a server that ignores the request is a failed negotiation rather than a downgrade`() { val session = PaymentSession(requested = PaymentInteraction.EXPLICIT_GATING) session.observeServerTags(emptyArray()) assertTrue(session.negotiationFailed, "silent fallback must be visible to the caller") diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/contextvm/cep22OversizedTransfer/OversizedTransferTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/contextvm/cep22OversizedTransfer/OversizedTransferTest.kt index eb1f15a2e2..dfb9dc02a2 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/contextvm/cep22OversizedTransfer/OversizedTransferTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/contextvm/cep22OversizedTransfer/OversizedTransferTest.kt @@ -76,7 +76,7 @@ class OversizedTransferTest { } @Test - fun `CVM-22-02 assembles out-of-order chunks by progress, not arrival order`() { + fun `CVM-22-02 assembles out-of-order chunks by progress rather than arrival order`() { // Relays may reorder. `progress` is the canonical assembly index. val frames = sender().frame(token, payload) val start = frames.first() diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/contextvm/core/CvmMessageEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/contextvm/core/CvmMessageEventTest.kt index 61cbd5490e..3ce2747447 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/contextvm/core/CvmMessageEventTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/contextvm/core/CvmMessageEventTest.kt @@ -43,7 +43,7 @@ class CvmMessageEventTest { private val ping = JsonRpcRequest(JsonRpcId.Num(1), "ping") @Test - fun `CVM-CORE-02 content is a stringified JSON-RPC message, not an embedded object`() { + fun `CVM-CORE-02 content is a stringified JSON-RPC message rather than an embedded object`() { val template = CvmMessageEvent.build(ping, serverPubKey) // The spec's examples print `content` unstringified for readability, @@ -91,7 +91,7 @@ class CvmMessageEventTest { } @Test - fun `CVM-CORE-06 kind 25910 is ephemeral, so delivery has no replay`() { + fun `CVM-CORE-06 kind 25910 is ephemeral so delivery has no replay`() { // Consequence, not decoration: relays do not retain this kind, so a // subscription must be live before the peer publishes. The transport's // request API is built around this and the property is worth pinning. diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cordn/appEncryptedMedia/CordnBlobUploadTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cordn/appEncryptedMedia/CordnBlobUploadTest.kt index 2852d6c080..7d1be0ed02 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cordn/appEncryptedMedia/CordnBlobUploadTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cordn/appEncryptedMedia/CordnBlobUploadTest.kt @@ -44,7 +44,7 @@ class CordnBlobUploadTest { private val sealed = CordnMediaEncryption.encrypt(file, key, "image/jpeg", "holiday.jpg") @Test - fun `the host is given the ciphertext, and its hash names the blob`() { + fun `the host is given the ciphertext and its hash names the blob`() { val blob = CordnBlobUpload.of(sealed) // Blossom addresses a blob by the hash of what it stores. @@ -65,7 +65,7 @@ class CordnBlobUploadTest { } @Test - fun `the declared type is opaque, never the real one`() { + fun `the declared type is opaque rather than the real one`() { val blob = CordnBlobUpload.of(sealed) assertEquals("application/octet-stream", blob.contentType) @@ -93,7 +93,7 @@ class CordnBlobUploadTest { } @Test - fun `upload, never the media endpoint`() { + fun `upload rather than the media endpoint`() { // `/media` asks the server to re-encode. Re-encoding ciphertext // destroys it, so an account with "optimize uploads" on would break // every attachment and only the recipient would find out. diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cordn/appEncryptedMedia/CordnMediaEncryptionTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cordn/appEncryptedMedia/CordnMediaEncryptionTest.kt index 817de45134..572c412b28 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cordn/appEncryptedMedia/CordnMediaEncryptionTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cordn/appEncryptedMedia/CordnMediaEncryptionTest.kt @@ -69,7 +69,7 @@ class CordnMediaEncryptionTest { } @Test - fun `renaming a file breaks it, because the name is authenticated`() { + fun `renaming a file breaks it because the name is authenticated`() { val sealed = roundTrip(name = "photo.jpg") assertFailsWith<Exception> { @@ -180,7 +180,7 @@ class CordnMediaEncryptionTest { } @Test - fun `the display hints round-trip, including the waveform`() { + fun `the display hints round-trip including the waveform`() { // The recorder measures amplitudes and the composer preview already // draws them; without somewhere to put them they were dropped at upload // and the sender's own voice note came back bar-less. @@ -207,7 +207,7 @@ class CordnMediaEncryptionTest { } @Test - fun `a hint that is absent or malformed costs the hint, not the attachment`() { + fun `a hint that is absent or malformed costs the hint rather than the attachment`() { // Every one of these is optional, so a reader that cannot make sense of // one must still be able to fetch and open the file. val tag = CordnMediaTag.build(roundTrip(), url = "https://b.example.com/b") diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cordn/spec02Envelopes/CordnAnnotationIndexTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cordn/spec02Envelopes/CordnAnnotationIndexTest.kt index 24d5ce7ef4..6dc636a727 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cordn/spec02Envelopes/CordnAnnotationIndexTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cordn/spec02Envelopes/CordnAnnotationIndexTest.kt @@ -73,7 +73,7 @@ class CordnAnnotationIndexTest { } @Test - fun `only the author may edit, and the newest edit wins`() { + fun `only the author may edit and the newest edit wins`() { val note = message(alice, CordnMessageKinds.TEXT, "original", createdAt = 100) val tags = CordnMessageReferences.editTags(note.asTarget()) @@ -177,7 +177,7 @@ class CordnAnnotationIndexTest { } @Test - fun `any member may pin, and the last write wins`() { + fun `any member may pin and the last write wins`() { val note = message(alice, CordnMessageKinds.TEXT, "important", createdAt = 100) val t = note.asTarget() diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cordn/spec02Envelopes/CordnMessageKindsTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cordn/spec02Envelopes/CordnMessageKindsTest.kt index 8cc2c492f0..3acef99dd3 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cordn/spec02Envelopes/CordnMessageKindsTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cordn/spec02Envelopes/CordnMessageKindsTest.kt @@ -64,7 +64,7 @@ class CordnMessageKindsTest { } @Test - fun `the numbers are cordn-web's, and differ from Marmot's on purpose`() { + fun `the numbers are cordn-web's and differ from Marmot's on purpose`() { // Pinned because they are the interop surface with the only other cordn // client, and because two of them disagree with Marmot (1009 edit, no // pin) by decision rather than accident. @@ -170,7 +170,7 @@ class CordnMessageKindsTest { // ---- inbound ------------------------------------------------------- @Test - fun `a reaction needs kind 7, e p k, and content`() { + fun `a reaction needs kind 7 with e p k and content`() { val good = CordnMessageReferences.reactionTags(target()) assertNotNull(CordnMessageReferences.reaction(7, "+", good)) @@ -210,7 +210,7 @@ class CordnMessageKindsTest { } @Test - fun `an edit needs kind 1010, an e tag, and text`() { + fun `an edit needs kind 1010 with an e tag and text`() { val tags = CordnMessageReferences.editTags(target()) assertEquals("1".repeat(64), assertNotNull(CordnMessageReferences.edit(1010, "new", tags)).targetId) @@ -230,7 +230,7 @@ class CordnMessageKindsTest { } @Test - fun `a pin needs kind 1011, an e tag, and a known op`() { + fun `a pin needs kind 1011 with an e tag and a known op`() { assertEquals( PinOp.ADD, assertNotNull(CordnMessageReferences.pin(1011, CordnMessageReferences.pinTags(target(), PinOp.ADD))).op, From 08169f8497690818c8817f221d1471f353ea8bc5 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sat, 26 Sep 2026 21:12:49 +0000 Subject: [PATCH 46/50] refactor(commons): move the napplet wire protocol and scheduled posts to commonMain Tier 4 of the commons migration sweep: two jvmAndroid groups that were each pinned by a single JVM API. Napplet: NappletProtocolJson (and NappletIdentityWatch, NappletRequestRouter, which only waited on it) used java.util.Base64 and System.currentTimeMillis. Now kotlin.io.encoding.Base64 and TimeUtils.now(). The decoder uses PaddingOption.PRESENT_OPTIONAL because java.util.Base64.getDecoder() accepted unpadded input; a test pins that. Scheduled posts: ScheduledPostStore was pinned by java.io.File and a POSIX chmod. It now takes an okio Path + FileSystem (plus a String constructor, the FileSystemNip95BlobStore shape, so callers need no okio). Owner-only permissions are a new expect fun restrictFileToOwner: Files.setPosix... on jvmAndroid, chmod(0600) on iOS. The temp-file swap is FileSystem.atomicMove, which replaces an existing target everywhere, so the rename/delete/rename fallback is gone. The work gate, publisher and notifier came along. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UjQQN9CgWHVtNCSWqnKoqY --- .../com/vitorpamplona/amethyst/AppModules.kt | 2 +- .../napplet/NappletProtocolJsonTest.kt | 7 ++ .../ScheduledPostWorkGateTest.kt | 2 +- .../commons/napplet/NappletIdentityWatch.kt | 0 .../commons/napplet/NappletRequestRouter.kt | 0 .../napplet/protocol/NappletProtocolJson.kt | 20 +++-- .../scheduledposts/ScheduledPostNotifier.kt | 0 .../scheduledposts/ScheduledPostPublisher.kt | 0 .../scheduledposts/ScheduledPostStore.kt | 78 +++++++++---------- .../scheduledposts/ScheduledPostWorkGate.kt | 0 .../amethyst/commons/util/OwnerOnlyFile.kt | 34 ++++++++ .../commons/util/OwnerOnlyFile.ios.kt | 40 ++++++++++ .../commons/util/OwnerOnlyFile.jvmAndroid.kt | 42 ++++++++++ .../scheduledposts/ScheduledPostStoreTest.kt | 2 +- .../DesktopScheduledPostStore.kt | 2 +- .../HeadlessScheduledPublish.kt | 2 +- 16 files changed, 175 insertions(+), 56 deletions(-) rename commons/src/{jvmAndroid => commonMain}/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletIdentityWatch.kt (100%) rename commons/src/{jvmAndroid => commonMain}/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletRequestRouter.kt (100%) rename commons/src/{jvmAndroid => commonMain}/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletProtocolJson.kt (97%) rename commons/src/{jvmAndroid => commonMain}/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostNotifier.kt (100%) rename commons/src/{jvmAndroid => commonMain}/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostPublisher.kt (100%) rename commons/src/{jvmAndroid => commonMain}/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostStore.kt (88%) rename commons/src/{jvmAndroid => commonMain}/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostWorkGate.kt (100%) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/OwnerOnlyFile.kt create mode 100644 commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/util/OwnerOnlyFile.ios.kt create mode 100644 commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/OwnerOnlyFile.jvmAndroid.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 3e0c5f6a6e..934226f2c8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -1154,7 +1154,7 @@ class AppModules( // Local store for posts the user has scheduled to publish later. Backed by a // single JSON file under the app's private filesDir; read by ScheduledPostWorker. val scheduledPostStore = - ScheduledPostStore(File(appContext.filesDir, ScheduledPostStore.FILE_NAME)) + ScheduledPostStore(File(appContext.filesDir, ScheduledPostStore.FILE_NAME).path) // Organizes cache clearing val trimmingService by diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/NappletProtocolJsonTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/NappletProtocolJsonTest.kt index 1f2422de4b..93399e85a2 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/NappletProtocolJsonTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/NappletProtocolJsonTest.kt @@ -184,6 +184,13 @@ class NappletProtocolJsonTest { assertEquals("Hi", up.bytes.decodeToString()) } + @Test + fun uploadAcceptsUnpaddedBase64() { + // java.util.Base64 decoded "SGk" (no '=') as "Hi"; the commonMain decoder must too. + val up = NappletProtocolJson.decodeRequest("""{"type":"upload.upload","request":{"dataBase64":"SGk"}}""") as NappletRequest.UploadBlob + assertEquals("Hi", up.bytes.decodeToString()) + } + @Test fun unknownTypeDecodesToNull() { assertNull(NappletProtocolJson.decodeRequest("""{"type":"inc.emit","id":"1"}""")) diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorkGateTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorkGateTest.kt index e346274630..d3028575ea 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorkGateTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorkGateTest.kt @@ -57,7 +57,7 @@ class ScheduledPostWorkGateTest { decisions.clear() } - private fun newStore() = ScheduledPostStore(file) + private fun newStore() = ScheduledPostStore(file.path) private fun TestScope.startGate(store: ScheduledPostStore) = ScheduledPostWorkGate( diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletIdentityWatch.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletIdentityWatch.kt similarity index 100% rename from commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletIdentityWatch.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletIdentityWatch.kt diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletRequestRouter.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletRequestRouter.kt similarity index 100% rename from commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletRequestRouter.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletRequestRouter.kt diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletProtocolJson.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletProtocolJson.kt similarity index 97% rename from commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletProtocolJson.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletProtocolJson.kt index 027cae99df..af171f6188 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletProtocolJson.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletProtocolJson.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.commons.napplet.protocol import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonObject @@ -37,7 +38,7 @@ import kotlinx.serialization.json.long import kotlinx.serialization.json.put import kotlinx.serialization.json.putJsonArray import kotlinx.serialization.json.putJsonObject -import java.util.Base64 +import kotlin.io.encoding.Base64 /** * Marshals the KMP-pure [NappletRequest] / [NappletResponse] types to and from the wire the @@ -45,10 +46,10 @@ import java.util.Base64 * (`@napplet/shim` / `@napplet/nap`): requests are `{ "type": "<domain>.<action>", "id", ...fields }` * and replies are `{ "type": "<domain>.<action>.result", "id", ...fields }`. * - * Lives in `commons/jvmAndroid` (not in any single front end) because the wire contract is identical + * Lives in `commons/commonMain` (not in any single front end) because the wire contract is identical * across hosts: the Android `:napplet` WebView host and a future desktop host both marshal through - * here. It depends only on `quartz` (Event/Filter), kotlinx.serialization, and `java.util.Base64` - * (available on Android API 26+ and the JVM) — no platform-UI or process APIs. + * here. It depends only on `quartz` (Event/Filter), kotlinx.serialization and `kotlin.io.encoding` + * — no platform-UI or process APIs. * * This is the only place the boundary parses untrusted applet input, so it is deliberately * strict: an unrecognized `type` decodes to `null` (the broker denies it) and a malformed/short @@ -58,6 +59,9 @@ import java.util.Base64 object NappletProtocolJson { private val json = Json { ignoreUnknownKeys = true } + // Accepts unpadded input, as java.util.Base64's decoder did before this moved to commonMain. + private val lenientBase64 = Base64.withPadding(Base64.PaddingOption.PRESENT_OPTIONAL) + /** The `type` discriminant of a request envelope, used to build the matching `.result` type. */ fun readType(envelopeJson: String): String? = json.parseToJsonElement(envelopeJson).jsonObject.str("type") @@ -166,7 +170,7 @@ object NappletProtocolJson { kind = t.kindOf(), tags = decodeTags(t), content = t.str("content") ?: "", - createdAt = t["created_at"]?.jsonPrimitive?.long ?: (System.currentTimeMillis() / 1000), + createdAt = t["created_at"]?.jsonPrimitive?.long ?: TimeUtils.now(), ) } // NIP-07 nip44.encrypt/decrypt: crypto only, no publish. `peer` is the counterparty @@ -194,7 +198,7 @@ object NappletProtocolJson { // The Blob in `request.data` is inlined as base64 `request.dataBase64` by shell.html. val request = o.getValue("request").jsonObject NappletRequest.UploadBlob( - bytes = Base64.getDecoder().decode(request.req("dataBase64")), + bytes = lenientBase64.decode(request.req("dataBase64")), contentType = request.str("mimeType") ?: "application/octet-stream", filename = request.str("filename"), ) @@ -275,7 +279,7 @@ object NappletProtocolJson { } is NappletResponse.Bytes -> { put("ok", true) - put("bytes", Base64.getEncoder().encodeToString(response.bytes)) + put("bytes", Base64.encode(response.bytes)) put("mime", response.contentType) } is NappletResponse.ResourceInfo -> { @@ -301,7 +305,7 @@ object NappletProtocolJson { put("url", item.url) put("ok", item.resource != null) item.resource?.let { - put("bytes", Base64.getEncoder().encodeToString(it.bytes)) + put("bytes", Base64.encode(it.bytes)) put("mime", it.contentType) } item.error?.let { put("error", it) } diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostNotifier.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostNotifier.kt similarity index 100% rename from commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostNotifier.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostNotifier.kt diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostPublisher.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostPublisher.kt similarity index 100% rename from commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostPublisher.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostPublisher.kt diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostStore.kt similarity index 88% rename from commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostStore.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostStore.kt index d19e8e7a6c..ec34e46725 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostStore.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostStore.kt @@ -20,21 +20,31 @@ */ package com.vitorpamplona.amethyst.commons.scheduledposts +import com.vitorpamplona.amethyst.commons.util.platformFileSystem +import com.vitorpamplona.amethyst.commons.util.restrictFileToOwner import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.serialization.json.Json -import java.io.File -import java.nio.file.Files -import java.nio.file.attribute.PosixFilePermission +import okio.FileSystem +import okio.Path +import okio.Path.Companion.toPath class ScheduledPostStore( - private val storageFile: File, - private val nowSec: () -> Long = { System.currentTimeMillis() / 1000 }, + private val storageFile: Path, + private val nowSec: () -> Long = { TimeUtils.now() }, + private val fileSystem: FileSystem = platformFileSystem, ) { + /** Takes the platform's own path string, so callers need no okio of their own. */ + constructor( + storagePath: String, + nowSec: () -> Long = { TimeUtils.now() }, + ) : this(storagePath.toPath(), nowSec) + /** * `encodeDefaults = true` so this writes the same bytes Jackson did — kotlinx * omits a value equal to its default, which would silently drop `"version":1` @@ -214,7 +224,7 @@ class ScheduledPostStore( */ suspend fun publishNow( id: String, - nowSec: Long = System.currentTimeMillis() / 1000, + nowSec: Long = TimeUtils.now(), ): Boolean = mutex.withLock { ensureLoaded() @@ -336,8 +346,8 @@ class ScheduledPostStore( private fun reloadFromDiskLocked() { val fromDisk = try { - if (storageFile.exists() && storageFile.length() > 0) { - json.decodeFromString<ScheduledPostFile>(storageFile.readText()).posts.toMutableList() + if (hasContent()) { + json.decodeFromString<ScheduledPostFile>(readText()).posts.toMutableList() } else { // File vanished — treat as no external state; keep current in-memory. return @@ -354,8 +364,8 @@ class ScheduledPostStore( if (loaded) return posts = try { - if (storageFile.exists() && storageFile.length() > 0) { - json.decodeFromString<ScheduledPostFile>(storageFile.readText()).posts.toMutableList() + if (hasContent()) { + json.decodeFromString<ScheduledPostFile>(readText()).posts.toMutableList() } else { mutableListOf() } @@ -367,7 +377,7 @@ class ScheduledPostStore( // Secure a pre-existing file up front (an older build may have left it at the // 0644 umask default) so it's owner-only even if nothing mutates the store // this session. - if (storageFile.exists()) restrictToOwner(storageFile) + if (fileSystem.exists(storageFile)) restrictFileToOwner(storageFile, TAG) var dirty = purgeStale(nowSec()) // Recover claims stranded by a crash-mid-publish so they aren't lost forever. if (recoverStuckClaimsLocked(nowSec())) dirty = true @@ -412,48 +422,30 @@ class ScheduledPostStore( private fun persist() { val snapshot = posts.toList() _flow.value = snapshot - storageFile.parentFile?.mkdirs() - val tmp = File(storageFile.parentFile, storageFile.name + ".tmp") + val tmp = "$storageFile.tmp".toPath() try { - tmp.writeText(json.encodeToString(ScheduledPostFile(version = 1, posts = snapshot))) + storageFile.parent?.let { fileSystem.createDirectories(it) } + fileSystem.write(tmp) { writeUtf8(json.encodeToString(ScheduledPostFile(version = 1, posts = snapshot))) } // Restrict to owner-only BEFORE the rename so the store is never briefly // world-readable. It holds pre-signed events + the account's pubkey, which // must not leak to other local users on a shared machine. - restrictToOwner(tmp) - if (!tmp.renameTo(storageFile)) { - if (!storageFile.delete()) { - Log.w(TAG) { "Failed to delete existing $storageFile before rename retry" } - } - if (!tmp.renameTo(storageFile)) { - Log.e(TAG) { "Failed to rename $tmp to $storageFile" } - if (!tmp.delete()) { - Log.w(TAG) { "Failed to clean up temp file $tmp after rename failure" } - } - } - } + restrictFileToOwner(tmp, TAG) + // Replaces an existing target, including on Windows, where a plain rename + // onto an existing file fails. + fileSystem.atomicMove(tmp, storageFile) } catch (e: Exception) { Log.e(TAG, "Failed to persist scheduled posts to $storageFile", e) - if (!tmp.delete()) { - Log.w(TAG) { "Failed to clean up temp file $tmp after persist exception" } + try { + fileSystem.delete(tmp) + } catch (e: Exception) { + Log.w(TAG) { "Failed to clean up temp file $tmp after persist exception: ${e.message}" } } } } - /** - * Best-effort chmod to `0600` (owner read/write only). No-op on filesystems - * without POSIX permissions (e.g. Windows), where confidentiality relies on the - * per-user home directory instead. Never throws. - */ - private fun restrictToOwner(file: File) { - try { - Files.setPosixFilePermissions( - file.toPath(), - setOf(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE), - ) - } catch (e: Exception) { - Log.w(TAG) { "Could not restrict permissions on $file: ${e.message}" } - } - } + private fun hasContent(): Boolean = (fileSystem.metadataOrNull(storageFile)?.size ?: 0L) > 0L + + private fun readText(): String = fileSystem.read(storageFile) { readUtf8() } companion object { private const val TAG = "ScheduledPostStore" diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostWorkGate.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostWorkGate.kt similarity index 100% rename from commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostWorkGate.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostWorkGate.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/OwnerOnlyFile.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/OwnerOnlyFile.kt new file mode 100644 index 0000000000..05934c55ca --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/OwnerOnlyFile.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.util + +import okio.Path + +/** + * Restricts the file at [path] to owner read/write (`0600`), best-effort. Never throws. + * + * Silent where the filesystem has no POSIX permissions (Windows), where the user profile's + * ACLs apply instead; logs under [tag] when a POSIX filesystem refuses. + */ +expect fun restrictFileToOwner( + path: Path, + tag: String, +) diff --git a/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/util/OwnerOnlyFile.ios.kt b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/util/OwnerOnlyFile.ios.kt new file mode 100644 index 0000000000..42b3c0c516 --- /dev/null +++ b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/util/OwnerOnlyFile.ios.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.util + +import com.vitorpamplona.quartz.utils.Log +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.convert +import okio.Path +import platform.posix.S_IRUSR +import platform.posix.S_IWUSR +import platform.posix.chmod +import platform.posix.errno + +@OptIn(ExperimentalForeignApi::class) +actual fun restrictFileToOwner( + path: Path, + tag: String, +) { + if (chmod(path.toString(), (S_IRUSR or S_IWUSR).convert()) != 0) { + Log.w(tag) { "Could not restrict permissions on $path: errno $errno" } + } +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/OwnerOnlyFile.jvmAndroid.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/OwnerOnlyFile.jvmAndroid.kt new file mode 100644 index 0000000000..5d4d16c3b7 --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/OwnerOnlyFile.jvmAndroid.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.util + +import com.vitorpamplona.quartz.utils.Log +import okio.Path +import java.nio.file.Files +import java.nio.file.attribute.PosixFilePermission + +actual fun restrictFileToOwner( + path: Path, + tag: String, +) { + try { + Files.setPosixFilePermissions( + path.toNioPath(), + setOf(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE), + ) + } catch (_: UnsupportedOperationException) { + // Windows: no POSIX permissions; the user profile's NTFS ACLs apply instead. + } catch (e: Exception) { + Log.w(tag) { "Could not restrict permissions on $path: ${e.message}" } + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostStoreTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostStoreTest.kt index 6033715624..bf0fdb92e2 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostStoreTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostStoreTest.kt @@ -47,7 +47,7 @@ class ScheduledPostStoreTest { file = File(temp.root, "scheduled_posts.json") } - private fun newStore(now: () -> Long = { System.currentTimeMillis() / 1000 }) = ScheduledPostStore(file, now) + private fun newStore(now: () -> Long = { System.currentTimeMillis() / 1000 }) = ScheduledPostStore(file.path, now) private fun samplePost( id: String = "id-1", diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/scheduledposts/DesktopScheduledPostStore.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/scheduledposts/DesktopScheduledPostStore.kt index ebd05606b5..96ccc66328 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/scheduledposts/DesktopScheduledPostStore.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/scheduledposts/DesktopScheduledPostStore.kt @@ -52,7 +52,7 @@ object DesktopScheduledPostStore { if (file.exists()) { setOwnerOnly(file, PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE) } - return ScheduledPostStore(file) + return ScheduledPostStore(file.path) } private fun setDirPermissions(dir: File) = diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/scheduledposts/HeadlessScheduledPublish.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/scheduledposts/HeadlessScheduledPublish.kt index 9c5fb8ee77..49b554ba66 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/scheduledposts/HeadlessScheduledPublish.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/scheduledposts/HeadlessScheduledPublish.kt @@ -82,7 +82,7 @@ fun runHeadlessPublish(): Int { } private fun headlessDrain(log: (String) -> Unit): Int { - val store = ScheduledPostStore(File(scheduledDir(), ScheduledPostStore.FILE_NAME)) + val store = ScheduledPostStore(File(scheduledDir(), ScheduledPostStore.FILE_NAME).path) // Build a plain OkHttp client for websockets — no Tor, no proxy. Headless mode // reads no settings, so we cannot know Tor preferences; scheduled posts that From 150177f5a0220bf6e84d23e6f839186cc989f8b4 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sat, 26 Sep 2026 21:13:01 +0000 Subject: [PATCH 47/50] fix(strings): move the cordn and backup strings to commonsUI, and write the rule down The 2026-09-22 move left Android res/ with only the ~190 strings a platform API reads synchronously. The cordn UI and the backup-conflict review then added 447 keys back to it. All of them move with tools/strings-migrate (1,131 elements across the locales that translate them), and the 18 files that read them now use Res.string/Res.plurals. The parts that were not a rename: - stringRes(context, ...) in the suspend attachment/voice senders becomes loadStringRes; adminFailureText becomes suspend (its only caller is already inside scope.launch). - The backup review held labels as @StringRes Int (DiffGroup.label, the ReviewRow *Res fields, eventTypeName/Explainer, the tile/hero/lane params). They are StringResource / PluralStringResource now. - rememberPresentation resolved labels inside remember {} through LocalResources. Compose resources have no blocking accessor, so the 16 labels presentationOf writes into item text are resolved in composition into a map, and that map is the remember key. - androidx pluralStringResource -> the compose-resources one. The rule is now in .claude/CLAUDE.md ("Strings"), commonsUI/ARCHITECTURE.md and the android-expert and find-missing-translations skills: new strings go in commonsUI composeResources, Android-only screens included. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UjQQN9CgWHVtNCSWqnKoqY --- .claude/CLAUDE.md | 42 ++ .claude/skills/android-expert/SKILL.md | 21 +- .../skills/find-missing-translations/SKILL.md | 7 + .../loggedIn/backups/BackupConflictCards.kt | 131 ++-- .../backups/BackupConflictEventViews.kt | 197 +++--- .../backups/BackupConflictListViews.kt | 156 +++-- .../backups/BackupConflictPresentation.kt | 328 ++++++---- .../backups/BackupConflictReviewScreen.kt | 86 ++- .../chats/cordnGroup/CordnComposer.kt | 5 +- .../cordnGroup/CordnCreateGroupScreen.kt | 149 +++-- .../cordnGroup/CordnCreateMembersScreen.kt | 58 +- .../chats/cordnGroup/CordnGroupChatScreen.kt | 78 ++- .../chats/cordnGroup/CordnGroupInfoScreen.kt | 161 +++-- .../chats/cordnGroup/CordnGroupListScreen.kt | 26 +- .../cordnGroup/CordnInvitationsScreen.kt | 51 +- .../chats/cordnGroup/CordnMessageRow.kt | 61 +- .../settings/cordn/CordnBackupScreen.kt | 45 +- .../settings/cordn/CordnCoordinatorsScreen.kt | 114 ++-- .../settings/cordn/CordnKeyPackagesScreen.kt | 50 +- .../settings/cordn/CordnLinkScreen.kt | 16 +- .../settings/cordn/CordnMigrateScreen.kt | 7 +- .../src/main/res/values-hi-rIN/strings.xml | 318 ---------- .../src/main/res/values-hu-rHU/strings.xml | 318 ---------- .../src/main/res/values-pl-rPL/strings.xml | 378 ------------ amethyst/src/main/res/values/strings.xml | 570 ------------------ commonsUI/ARCHITECTURE.md | 5 +- .../values-hi-rIN/strings.xml | 318 ++++++++++ .../values-hu-rHU/strings.xml | 318 ++++++++++ .../values-pl-rPL/strings.xml | 378 ++++++++++++ .../composeResources/values/strings.xml | 570 ++++++++++++++++++ 30 files changed, 2777 insertions(+), 2185 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 6038af56e7..886a0dfe3e 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -285,6 +285,48 @@ Quartz uses expect/actual for platform-specific implementations (e.g. crypto backed by `secp256k1-kmp-jni-android` on Android and `secp256k1-kmp-jni-jvm` on JVM). See `/kotlin-multiplatform` for the expect/actual and source-set patterns. +## Strings + +**MANDATORY: every new user-visible string goes in `commonsUI` Compose resources**, +`commonsUI/src/commonMain/composeResources/values/strings.xml` — **never** in +`amethyst/src/main/res/values/strings.xml`, even for an Android-only screen. A +screen cannot move to `commonsUI` (or be rendered by Desktop) while its labels +live in Android `res/`, and every key added there has to be migrated back out +later. (The 2026-09-22 move emptied Android `res/` down to ~190 keys; the next two +features put 447 back.) + +- Reference it as `Res.string.my_key` / `Res.plurals.my_key`, importing + `com.vitorpamplona.amethyst.commons.resources.Res` **and** the per-key accessor + `com.vitorpamplona.amethyst.commons.resources.my_key` (Compose resources generates + each key as an extension property). +- Read it with `stringRes(Res.string.x)` / `pluralStringRes(Res.plurals.x, n, …)` + in composition — the app-side `ui.stringRes` overloads delegate to the + commonsUI bridge (`commons/ui/StringRes.kt`), so one import serves both kinds. +- Outside composition there is **no blocking accessor**: use `loadStringRes(…)` + from a coroutine (or make the function `suspend`), or resolve the label in + composition and pass it down as a `String`. A `runBlocking` bridge is ruled out. +- Hold a string reference as `StringResource` / `PluralStringResource`, not a + `@StringRes Int`. +- Format arguments must be positional (`%1$s`, `%2$d`) — compose-resources does + not format bare `%s`/`%d`. +- Escaping is not Android's: write `'` and `"` bare (no `\'`), and never wrap a + value in quotes to keep its whitespace. `.claude/hooks/compose_escaping_check.py` + checks this before a push. + +The **only** strings that stay in Android `res/` are the ones a platform API +reads synchronously under a deadline, where a `suspend` read cannot run: a +foreground service's `startForeground` notification, a notification channel +created during service startup, PiP `RemoteAction`s, napplet capability labels +read in the sandbox process, and `AndroidManifest.xml` / `res/xml` references. +Most of those are *copies* of a key that also lives in `commonsUI`. See +`commons/plans/2026-09-22-strings-to-compose-resources.md` ("the +synchronous-platform tier"). If you think a new string belongs in that tier, +say why in the PR. + +To move keys that already landed in Android `res/`, use +`tools/strings-migrate/migrate.py <keys…>`. It moves every locale byte-for-byte, +so Crowdin sees a pure move. Then repoint `R.string.x` → `Res.string.x`. + ## Icons The Material Symbols font bundled at diff --git a/.claude/skills/android-expert/SKILL.md b/.claude/skills/android-expert/SKILL.md index e2483221d0..19b79ec6a1 100644 --- a/.claude/skills/android-expert/SKILL.md +++ b/.claude/skills/android-expert/SKILL.md @@ -875,18 +875,33 @@ fun ProfileScreen( ### 3. Resource Access +**Strings do not go in `amethyst/src/main/res`.** New user-visible strings live in +`commonsUI/src/commonMain/composeResources/values/strings.xml` and are read as +`Res.string.x`, even on an Android-only screen. See "Strings" in `.claude/CLAUDE.md` +for the rule and the small platform tier that is the only exception. + ```kotlin +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.button_clicked +import com.vitorpamplona.amethyst.commons.resources.button_label +import com.vitorpamplona.amethyst.commons.ui.loadStringRes +import com.vitorpamplona.amethyst.ui.stringRes + @Composable fun LocalizedButton() { val context = LocalContext.current + val scope = rememberCoroutineScope() Button( onClick = { - val message = context.getString(R.string.button_clicked) - Toast.makeText(context, message, Toast.LENGTH_SHORT).show() + // No blocking accessor outside composition: read it from a coroutine, + // or resolve it in composition and capture the String. + scope.launch { + Toast.makeText(context, loadStringRes(Res.string.button_clicked), Toast.LENGTH_SHORT).show() + } } ) { - Text(stringResource(R.string.button_label)) + Text(stringRes(Res.string.button_label)) } } ``` diff --git a/.claude/skills/find-missing-translations/SKILL.md b/.claude/skills/find-missing-translations/SKILL.md index b41533cf3e..7547c5c46d 100644 --- a/.claude/skills/find-missing-translations/SKILL.md +++ b/.claude/skills/find-missing-translations/SKILL.md @@ -26,6 +26,13 @@ There are two separate `strings.xml` trees, each with its own default `values/` | **amethyst** (Android app) | `amethyst/src/main/res/values/strings.xml` | `amethyst/src/main/res/values-<locale>/strings.xml` | | **commonsUI** (KMP Compose resources, shared by Android + Desktop) | `commonsUI/src/commonMain/composeResources/values/strings.xml` | `commonsUI/src/commonMain/composeResources/values-<locale>/strings.xml` | +**New keys go in the `commonsUI` tree, always.** The amethyst tree is frozen at +the small synchronous-platform tier (foreground-service and notification-channel +text, PiP actions, napplet capability labels, manifest references). If you find +a new feature key in it, it belongs in `commonsUI`. Move it with +`tools/strings-migrate/migrate.py` rather than translating it where it is. See +"Strings" in `.claude/CLAUDE.md`. + The `commonsUI` tree appeared when shared event-renderer composables were extracted out of `amethyst/` into `commons/` — now `commonsUI/` since the UI split (Compose Multiplatform `stringResource`). It is **not** a copy of the amethyst tree — the vast majority of its keys are commons-only; only a small handful overlap. Every diff/count/translate command below works on either tree by swapping the base path — **run the whole technique once per tree** and report them separately (each maps to its own Crowdin file, so the counts should reconcile against two different Crowdin UI numbers). **Locale-qualifier caveat:** `commons` uses the same region-qualified locale dirs as amethyst for our four targets (`values-cs`, `values-de-rDE`, `values-sv-rSE`, `values-pt-rBR`), but the *full* set of locale dirs differs between trees. Enumerate `values-*` under each tree's own base rather than assuming they match. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictCards.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictCards.kt index bb977a4184..769c911ae4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictCards.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictCards.kt @@ -43,17 +43,57 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.backups.BackupEventType import com.vitorpamplona.amethyst.commons.model.backups.ReplaceableBackupConflict import com.vitorpamplona.amethyst.commons.model.navigation.Route +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.backup_card_accept +import com.vitorpamplona.amethyst.commons.resources.backup_card_and_added +import com.vitorpamplona.amethyst.commons.resources.backup_card_and_blocked +import com.vitorpamplona.amethyst.commons.resources.backup_card_and_followed +import com.vitorpamplona.amethyst.commons.resources.backup_card_and_joined +import com.vitorpamplona.amethyst.commons.resources.backup_card_and_muted +import com.vitorpamplona.amethyst.commons.resources.backup_card_app_settings +import com.vitorpamplona.amethyst.commons.resources.backup_card_concord_emptied +import com.vitorpamplona.amethyst.commons.resources.backup_card_concord_rewritten +import com.vitorpamplona.amethyst.commons.resources.backup_card_dm_relays_lost +import com.vitorpamplona.amethyst.commons.resources.backup_card_feeds_removed +import com.vitorpamplona.amethyst.commons.resources.backup_card_follows_dropped +import com.vitorpamplona.amethyst.commons.resources.backup_card_hashtags_unfollowed +import com.vitorpamplona.amethyst.commons.resources.backup_card_indexer_relays_lost +import com.vitorpamplona.amethyst.commons.resources.backup_card_key_package_relays_lost +import com.vitorpamplona.amethyst.commons.resources.backup_card_leave_chats +import com.vitorpamplona.amethyst.commons.resources.backup_card_leave_communities +import com.vitorpamplona.amethyst.commons.resources.backup_card_leave_groups +import com.vitorpamplona.amethyst.commons.resources.backup_card_leave_rooms +import com.vitorpamplona.amethyst.commons.resources.backup_card_mutes_visible +import com.vitorpamplona.amethyst.commons.resources.backup_card_nip65_lost +import com.vitorpamplona.amethyst.commons.resources.backup_card_nutzap_key_removed +import com.vitorpamplona.amethyst.commons.resources.backup_card_nutzap_key_replaced +import com.vitorpamplona.amethyst.commons.resources.backup_card_nutzap_mints_removed +import com.vitorpamplona.amethyst.commons.resources.backup_card_offers_removed +import com.vitorpamplona.amethyst.commons.resources.backup_card_payment_targets_removed +import com.vitorpamplona.amethyst.commons.resources.backup_card_places_unfollowed +import com.vitorpamplona.amethyst.commons.resources.backup_card_private_outbox_lost +import com.vitorpamplona.amethyst.commons.resources.backup_card_profile_lost_fields +import com.vitorpamplona.amethyst.commons.resources.backup_card_relay_feeds_lost +import com.vitorpamplona.amethyst.commons.resources.backup_card_search_relays_lost +import com.vitorpamplona.amethyst.commons.resources.backup_card_trust_services_changed +import com.vitorpamplona.amethyst.commons.resources.backup_card_trusted_lost +import com.vitorpamplona.amethyst.commons.resources.backup_card_unblocked +import com.vitorpamplona.amethyst.commons.resources.backup_card_wallet_emptied +import com.vitorpamplona.amethyst.commons.resources.backup_card_wallet_rewritten +import com.vitorpamplona.amethyst.commons.resources.backup_conflict_count_added +import com.vitorpamplona.amethyst.commons.resources.backup_conflict_count_changed +import com.vitorpamplona.amethyst.commons.resources.backup_conflict_count_removed +import com.vitorpamplona.amethyst.commons.resources.backup_conflict_title +import com.vitorpamplona.amethyst.commons.resources.backup_review_changed_by_other_app import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.note.timeAgoNoDot @@ -79,6 +119,9 @@ import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListDiff import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataDiff import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListDiff import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListDiff +import org.jetbrains.compose.resources.PluralStringResource +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.pluralStringResource /** * Cards at the top of Home's feed, one per open [ReplaceableBackupConflict]: the user @@ -125,34 +168,34 @@ private fun headlineOf( when (diff) { // Counted, not "shrank": a version that drops 3 follows and adds 100 still raises // a conflict, and calling that a shrink contradicts the card's own bar. - is ContactListDiff -> plural(R.plurals.backup_card_follows_dropped, diff.follows.removed.size) - is MuteListDiff -> plural(R.plurals.backup_card_mutes_visible, diff.publicMutes.removed.size) - is MetadataDiff -> plural(R.plurals.backup_card_profile_lost_fields, counts.removed) - is AdvertisedRelayListDiff -> plural(R.plurals.backup_card_nip65_lost, diff.relays.removed.size) + is ContactListDiff -> plural(Res.plurals.backup_card_follows_dropped, diff.follows.removed.size) + is MuteListDiff -> plural(Res.plurals.backup_card_mutes_visible, diff.publicMutes.removed.size) + is MetadataDiff -> plural(Res.plurals.backup_card_profile_lost_fields, counts.removed) + is AdvertisedRelayListDiff -> plural(Res.plurals.backup_card_nip65_lost, diff.relays.removed.size) is RelayListDiff -> relayListHeadline(conflict.eventType, diff.relays.removed.size) - is ChannelListDiff -> plural(R.plurals.backup_card_leave_chats, diff.channels.removed.size) - is CommunityListDiff -> plural(R.plurals.backup_card_leave_communities, diff.communities.removed.size) - is EphemeralChatListDiff -> plural(R.plurals.backup_card_leave_rooms, diff.rooms.removed.size) - is SimpleGroupListDiff -> plural(R.plurals.backup_card_leave_groups, diff.groups.removed.size) - is FavoriteAlgoFeedsListDiff -> plural(R.plurals.backup_card_feeds_removed, diff.feeds.removed.size) - is HashtagListDiff -> plural(R.plurals.backup_card_hashtags_unfollowed, diff.hashtags.removed.size) - is GeohashListDiff -> plural(R.plurals.backup_card_places_unfollowed, diff.geohashes.removed.size) - is TrustProviderListDiff -> plural(R.plurals.backup_card_trust_services_changed, diff.providers.removed.size + diff.providers.changed.size) + is ChannelListDiff -> plural(Res.plurals.backup_card_leave_chats, diff.channels.removed.size) + is CommunityListDiff -> plural(Res.plurals.backup_card_leave_communities, diff.communities.removed.size) + is EphemeralChatListDiff -> plural(Res.plurals.backup_card_leave_rooms, diff.rooms.removed.size) + is SimpleGroupListDiff -> plural(Res.plurals.backup_card_leave_groups, diff.groups.removed.size) + is FavoriteAlgoFeedsListDiff -> plural(Res.plurals.backup_card_feeds_removed, diff.feeds.removed.size) + is HashtagListDiff -> plural(Res.plurals.backup_card_hashtags_unfollowed, diff.hashtags.removed.size) + is GeohashListDiff -> plural(Res.plurals.backup_card_places_unfollowed, diff.geohashes.removed.size) + is TrustProviderListDiff -> plural(Res.plurals.backup_card_trust_services_changed, diff.providers.removed.size + diff.providers.changed.size) is NutzapInfoDiff -> when { - diff.p2pkPubkey?.after == null && diff.p2pkPubkey != null -> stringRes(R.string.backup_card_nutzap_key_removed) - diff.p2pkPubkey != null -> stringRes(R.string.backup_card_nutzap_key_replaced) - else -> plural(R.plurals.backup_card_nutzap_mints_removed, diff.mints.removed.size) + diff.p2pkPubkey?.after == null && diff.p2pkPubkey != null -> stringRes(Res.string.backup_card_nutzap_key_removed) + diff.p2pkPubkey != null -> stringRes(Res.string.backup_card_nutzap_key_replaced) + else -> plural(Res.plurals.backup_card_nutzap_mints_removed, diff.mints.removed.size) } - is PaymentTargetsDiff -> plural(R.plurals.backup_card_payment_targets_removed, diff.targets.removed.size) - is Bolt12OfferListDiff -> plural(R.plurals.backup_card_offers_removed, diff.offers.removed.size) - is CashuWalletDiff -> encryptedHeadline(R.string.backup_card_wallet_emptied, R.string.backup_card_wallet_rewritten, diff.wallet) - is ConcordCommunityListDiff -> encryptedHeadline(R.string.backup_card_concord_emptied, R.string.backup_card_concord_rewritten, diff.communities) - is AppSpecificDataDiff -> stringRes(R.string.backup_card_app_settings) + is PaymentTargetsDiff -> plural(Res.plurals.backup_card_payment_targets_removed, diff.targets.removed.size) + is Bolt12OfferListDiff -> plural(Res.plurals.backup_card_offers_removed, diff.offers.removed.size) + is CashuWalletDiff -> encryptedHeadline(Res.string.backup_card_wallet_emptied, Res.string.backup_card_wallet_rewritten, diff.wallet) + is ConcordCommunityListDiff -> encryptedHeadline(Res.string.backup_card_concord_emptied, Res.string.backup_card_concord_rewritten, diff.communities) + is AppSpecificDataDiff -> stringRes(Res.string.backup_card_app_settings) else -> null } // A headline counting removed items reads wrong when only encrypted items were lost. - if (specific.isNullOrEmpty()) return stringRes(R.string.backup_conflict_title, stringRes(eventTypeName(conflict.eventType))) + if (specific.isNullOrEmpty()) return stringRes(Res.string.backup_conflict_title, stringRes(eventTypeName(conflict.eventType))) // Headlines lead with the loss; what the other app added to the same list is said too, // so a list that dropped 3 and gained 40 doesn't read as if it only shrank. The wording // follows the headline's: following people, muting, joining chats, blocking relays. @@ -160,11 +203,11 @@ private fun headlineOf( if (added <= 0) return specific val tail = when { - diff is ContactListDiff -> R.plurals.backup_card_and_followed - diff is MuteListDiff -> R.plurals.backup_card_and_muted - diff is ChannelListDiff || diff is CommunityListDiff || diff is EphemeralChatListDiff || diff is SimpleGroupListDiff -> R.plurals.backup_card_and_joined - conflict.eventType == BackupEventType.BLOCKED_RELAYS -> R.plurals.backup_card_and_blocked - else -> R.plurals.backup_card_and_added + diff is ContactListDiff -> Res.plurals.backup_card_and_followed + diff is MuteListDiff -> Res.plurals.backup_card_and_muted + diff is ChannelListDiff || diff is CommunityListDiff || diff is EphemeralChatListDiff || diff is SimpleGroupListDiff -> Res.plurals.backup_card_and_joined + conflict.eventType == BackupEventType.BLOCKED_RELAYS -> Res.plurals.backup_card_and_blocked + else -> Res.plurals.backup_card_and_added } return pluralStringResource(tail, added, specific, added) } @@ -208,7 +251,7 @@ private fun relayListHasHeadline(type: BackupEventType): Boolean = /** The count's plural, or "" when nothing of that kind was removed. */ @Composable private fun plural( - id: Int, + id: PluralStringResource, count: Int, ): String = if (count > 0) pluralStringResource(id, count, count) else "" @@ -218,21 +261,21 @@ private fun relayListHeadline( removed: Int, ): String = when (type) { - BackupEventType.DM_RELAYS -> plural(R.plurals.backup_card_dm_relays_lost, removed) - BackupEventType.KEY_PACKAGE_RELAYS -> plural(R.plurals.backup_card_key_package_relays_lost, removed) - BackupEventType.SEARCH_RELAYS -> plural(R.plurals.backup_card_search_relays_lost, removed) - BackupEventType.INDEXER_RELAYS -> plural(R.plurals.backup_card_indexer_relays_lost, removed) - BackupEventType.RELAY_FEEDS -> plural(R.plurals.backup_card_relay_feeds_lost, removed) - BackupEventType.PRIVATE_OUTBOX_RELAYS -> plural(R.plurals.backup_card_private_outbox_lost, removed) - BackupEventType.TRUSTED_RELAYS -> plural(R.plurals.backup_card_trusted_lost, removed) - BackupEventType.BLOCKED_RELAYS -> plural(R.plurals.backup_card_unblocked, removed) + BackupEventType.DM_RELAYS -> plural(Res.plurals.backup_card_dm_relays_lost, removed) + BackupEventType.KEY_PACKAGE_RELAYS -> plural(Res.plurals.backup_card_key_package_relays_lost, removed) + BackupEventType.SEARCH_RELAYS -> plural(Res.plurals.backup_card_search_relays_lost, removed) + BackupEventType.INDEXER_RELAYS -> plural(Res.plurals.backup_card_indexer_relays_lost, removed) + BackupEventType.RELAY_FEEDS -> plural(Res.plurals.backup_card_relay_feeds_lost, removed) + BackupEventType.PRIVATE_OUTBOX_RELAYS -> plural(Res.plurals.backup_card_private_outbox_lost, removed) + BackupEventType.TRUSTED_RELAYS -> plural(Res.plurals.backup_card_trusted_lost, removed) + BackupEventType.BLOCKED_RELAYS -> plural(Res.plurals.backup_card_unblocked, removed) else -> "" } @Composable private fun encryptedHeadline( - emptied: Int, - rewritten: Int, + emptied: StringResource, + rewritten: StringResource, change: ContentChange, ): String = if (change == ContentChange.CLEARED) stringRes(emptied) else stringRes(rewritten) @@ -262,7 +305,7 @@ private fun LeadConflictCard( Column(Modifier.weight(1f)) { Text(headlineOf(conflict, counts), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold, maxLines = 3, overflow = TextOverflow.Ellipsis) Text( - stringRes(R.string.backup_review_changed_by_other_app, timeAgoNoDot(conflict.cause.createdAt)), + stringRes(Res.string.backup_review_changed_by_other_app, timeAgoNoDot(conflict.cause.createdAt)), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.placeholderText, ) @@ -296,9 +339,9 @@ private fun LeadConflictCard( private fun CountChips(counts: ConflictCounts) { val tones = conflictTones() Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - if (counts.removed > 0) StatusTag(stringRes(R.string.backup_conflict_count_removed, counts.removed.toString()), tones.removed) - if (counts.added > 0) StatusTag(stringRes(R.string.backup_conflict_count_added, counts.added.toString()), tones.added) - if (counts.changed > 0) StatusTag(stringRes(R.string.backup_conflict_count_changed, counts.changed.toString()), tones.changed) + if (counts.removed > 0) StatusTag(stringRes(Res.string.backup_conflict_count_removed, counts.removed.toString()), tones.removed) + if (counts.added > 0) StatusTag(stringRes(Res.string.backup_conflict_count_added, counts.added.toString()), tones.added) + if (counts.changed > 0) StatusTag(stringRes(Res.string.backup_conflict_count_changed, counts.changed.toString()), tones.changed) } } @@ -342,6 +385,6 @@ private fun AcceptButton(onAccept: () -> Unit) { modifier = Modifier.height(36.dp), contentPadding = PaddingValues(horizontal = 14.dp), ) { - Text(stringRes(R.string.backup_card_accept), fontWeight = FontWeight.SemiBold) + Text(stringRes(Res.string.backup_card_accept), fontWeight = FontWeight.SemiBold) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictEventViews.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictEventViews.kt index 419bd3b2a2..1916f09286 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictEventViews.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictEventViews.kt @@ -62,7 +62,6 @@ import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.backups.ReplaceableBackupConflict @@ -70,6 +69,67 @@ import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.model.navigation.Route import com.vitorpamplona.amethyst.commons.model.navigation.routeFor import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.backup_entry_hashtag +import com.vitorpamplona.amethyst.commons.resources.backup_entry_mint +import com.vitorpamplona.amethyst.commons.resources.backup_entry_nutzap_key +import com.vitorpamplona.amethyst.commons.resources.backup_entry_person +import com.vitorpamplona.amethyst.commons.resources.backup_entry_profile_field +import com.vitorpamplona.amethyst.commons.resources.backup_entry_thread +import com.vitorpamplona.amethyst.commons.resources.backup_entry_word +import com.vitorpamplona.amethyst.commons.resources.backup_profile_field_about +import com.vitorpamplona.amethyst.commons.resources.backup_profile_field_banner +import com.vitorpamplona.amethyst.commons.resources.backup_profile_field_birthday +import com.vitorpamplona.amethyst.commons.resources.backup_profile_field_bot +import com.vitorpamplona.amethyst.commons.resources.backup_profile_field_clink_offer +import com.vitorpamplona.amethyst.commons.resources.backup_profile_field_display_name +import com.vitorpamplona.amethyst.commons.resources.backup_profile_field_lud06 +import com.vitorpamplona.amethyst.commons.resources.backup_profile_field_lud16 +import com.vitorpamplona.amethyst.commons.resources.backup_profile_field_name +import com.vitorpamplona.amethyst.commons.resources.backup_profile_field_nip05 +import com.vitorpamplona.amethyst.commons.resources.backup_profile_field_picture +import com.vitorpamplona.amethyst.commons.resources.backup_profile_field_pronouns +import com.vitorpamplona.amethyst.commons.resources.backup_profile_field_website +import com.vitorpamplona.amethyst.commons.resources.backup_review_demoted_tag +import com.vitorpamplona.amethyst.commons.resources.backup_review_dropped_count +import com.vitorpamplona.amethyst.commons.resources.backup_review_dropped_tag +import com.vitorpamplona.amethyst.commons.resources.backup_review_gone +import com.vitorpamplona.amethyst.commons.resources.backup_review_inbox +import com.vitorpamplona.amethyst.commons.resources.backup_review_inbox_explainer +import com.vitorpamplona.amethyst.commons.resources.backup_review_joined +import com.vitorpamplona.amethyst.commons.resources.backup_review_kept_count +import com.vitorpamplona.amethyst.commons.resources.backup_review_key_removed +import com.vitorpamplona.amethyst.commons.resources.backup_review_key_replaced +import com.vitorpamplona.amethyst.commons.resources.backup_review_key_warning +import com.vitorpamplona.amethyst.commons.resources.backup_review_left +import com.vitorpamplona.amethyst.commons.resources.backup_review_left_tag +import com.vitorpamplona.amethyst.commons.resources.backup_review_names_and_more +import com.vitorpamplona.amethyst.commons.resources.backup_review_new +import com.vitorpamplona.amethyst.commons.resources.backup_review_new_count +import com.vitorpamplona.amethyst.commons.resources.backup_review_new_tag +import com.vitorpamplona.amethyst.commons.resources.backup_review_newly_muted +import com.vitorpamplona.amethyst.commons.resources.backup_review_not_set +import com.vitorpamplona.amethyst.commons.resources.backup_review_nothing_here +import com.vitorpamplona.amethyst.commons.resources.backup_review_outbox +import com.vitorpamplona.amethyst.commons.resources.backup_review_outbox_explainer +import com.vitorpamplona.amethyst.commons.resources.backup_review_private_added_body +import com.vitorpamplona.amethyst.commons.resources.backup_review_private_added_title +import com.vitorpamplona.amethyst.commons.resources.backup_review_private_changed_body +import com.vitorpamplona.amethyst.commons.resources.backup_review_private_changed_title +import com.vitorpamplona.amethyst.commons.resources.backup_review_private_wiped_body +import com.vitorpamplona.amethyst.commons.resources.backup_review_private_wiped_title +import com.vitorpamplona.amethyst.commons.resources.backup_review_renamed +import com.vitorpamplona.amethyst.commons.resources.backup_review_renamed_tag +import com.vitorpamplona.amethyst.commons.resources.backup_review_saved +import com.vitorpamplona.amethyst.commons.resources.backup_review_search_people +import com.vitorpamplona.amethyst.commons.resources.backup_review_tab_dropped +import com.vitorpamplona.amethyst.commons.resources.backup_review_tab_edited +import com.vitorpamplona.amethyst.commons.resources.backup_review_tab_new +import com.vitorpamplona.amethyst.commons.resources.backup_review_units_tag +import com.vitorpamplona.amethyst.commons.resources.backup_review_unmuted +import com.vitorpamplona.amethyst.commons.resources.backup_review_unmuted_explainer +import com.vitorpamplona.amethyst.commons.resources.backup_review_was_named +import com.vitorpamplona.amethyst.commons.resources.backup_review_words_and_hashtags import com.vitorpamplona.amethyst.commons.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.screen.LocalDisplaySettings @@ -111,6 +171,7 @@ import com.vitorpamplona.quartz.nip61Nutzaps.info.tags.NutzapMintTag import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListDiff import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayType +import org.jetbrains.compose.resources.StringResource private const val GRID_COLUMNS = 4 private const val PEOPLE_PREVIEW = 6 @@ -168,7 +229,7 @@ private fun LazyListScope.followListItems( item(key = "follow-hero", contentType = "hero") { val tones = conflictTones() Column(Pad.padding(top = 8.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { - HeroCounts(stringRes(R.string.backup_review_saved), saved, stringRes(R.string.backup_review_new), new) + HeroCounts(stringRes(Res.string.backup_review_saved), saved, stringRes(Res.string.backup_review_new), new) SplitBar( listOf( BarSegment(saved - dropped, tones.kept), @@ -177,9 +238,9 @@ private fun LazyListScope.followListItems( ), ) Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) { - LegendItem(tones.kept, stringRes(R.string.backup_review_kept_count, (saved - dropped).toString())) - LegendItem(tones.removed, stringRes(R.string.backup_review_dropped_count, dropped.toString())) - LegendItem(tones.added, stringRes(R.string.backup_review_new_count, gained.toString())) + LegendItem(tones.kept, stringRes(Res.string.backup_review_kept_count, (saved - dropped).toString())) + LegendItem(tones.removed, stringRes(Res.string.backup_review_dropped_count, dropped.toString())) + LegendItem(tones.added, stringRes(Res.string.backup_review_new_count, gained.toString())) } } } @@ -188,9 +249,9 @@ private fun LazyListScope.followListItems( ReviewTabs( labels = listOf( - stringRes(R.string.backup_review_tab_dropped, dropped.toString()), - stringRes(R.string.backup_review_tab_new, gained.toString()), - stringRes(R.string.backup_review_tab_edited, edited.toString()), + stringRes(Res.string.backup_review_tab_dropped, dropped.toString()), + stringRes(Res.string.backup_review_tab_new, gained.toString()), + stringRes(Res.string.backup_review_tab_edited, edited.toString()), ), selected = ui.tab, onSelect = { @@ -223,7 +284,7 @@ private fun LazyListScope.followListItems( if (filtered.isEmpty()) { item(key = "follow-empty", contentType = "note") { - DetailText(stringRes(R.string.backup_review_nothing_here), Pad.padding(vertical = 24.dp)) + DetailText(stringRes(Res.string.backup_review_nothing_here), Pad.padding(vertical = 24.dp)) } } } @@ -359,7 +420,7 @@ private fun PeopleSearch( singleLine = true, shape = RoundedCornerShape(14.dp), leadingIcon = { Icon(symbol = MaterialSymbols.Search, contentDescription = null) }, - placeholder = { Text(stringRes(R.string.backup_review_search_people, count.toString())) }, + placeholder = { Text(stringRes(Res.string.backup_review_search_people, count.toString())) }, ) } @@ -388,15 +449,15 @@ private fun LazyListScope.muteListItems( Icon(symbol = MaterialSymbols.Shield, contentDescription = null, tint = tones.removed, modifier = Modifier.size(44.dp)) Column { Text( - stringRes(R.string.backup_review_unmuted, removed.size.toString()), + stringRes(Res.string.backup_review_unmuted, removed.size.toString()), fontSize = 30.sp, lineHeight = 32.sp, fontWeight = FontWeight.Bold, color = tones.removed, ) - Text(stringRes(R.string.backup_review_unmuted_explainer), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.placeholderText) + Text(stringRes(Res.string.backup_review_unmuted_explainer), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.placeholderText) if (added.isNotEmpty()) { - Text(stringRes(R.string.backup_review_newly_muted, added.size.toString()), style = MaterialTheme.typography.bodySmall, color = tones.added) + Text(stringRes(Res.string.backup_review_newly_muted, added.size.toString()), style = MaterialTheme.typography.bodySmall, color = tones.added) } } } @@ -406,17 +467,17 @@ private fun LazyListScope.muteListItems( item(key = "mute-tiles", contentType = "tiles") { val tones = conflictTones() Row(Pad.padding(top = 12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { - StatTile("−${people.size}", stringRes(R.string.backup_entry_person), tones.removed, Modifier.weight(1f)) - StatTile("−${words.size}", stringRes(R.string.backup_entry_word), tones.removed, Modifier.weight(1f)) - StatTile("−${hashtags.size}", stringRes(R.string.backup_entry_hashtag), tones.removed, Modifier.weight(1f)) - StatTile("−${threads.size}", stringRes(R.string.backup_entry_thread), tones.removed, Modifier.weight(1f)) + StatTile("−${people.size}", stringRes(Res.string.backup_entry_person), tones.removed, Modifier.weight(1f)) + StatTile("−${words.size}", stringRes(Res.string.backup_entry_word), tones.removed, Modifier.weight(1f)) + StatTile("−${hashtags.size}", stringRes(Res.string.backup_entry_hashtag), tones.removed, Modifier.weight(1f)) + StatTile("−${threads.size}", stringRes(Res.string.backup_entry_thread), tones.removed, Modifier.weight(1f)) } } if (people.isNotEmpty()) { item(key = "mute-people", contentType = "facepile") { Column(Pad.padding(top = 18.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - SectionCaption(stringRes(R.string.backup_entry_person)) + SectionCaption(stringRes(Res.string.backup_entry_person)) FacePile(people, accountViewModel) { ui.showAllPeople = !ui.showAllPeople } } } @@ -427,7 +488,7 @@ private fun LazyListScope.muteListItems( item(key = "mute-words", contentType = "pills") { val tones = conflictTones() Column(Pad.padding(top = 18.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - SectionCaption(stringRes(R.string.backup_review_words_and_hashtags)) + SectionCaption(stringRes(Res.string.backup_review_words_and_hashtags)) FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { (words.map { "\"$it\"" } + hashtags).forEach { StruckPill(it, tones.removed) } } @@ -436,7 +497,7 @@ private fun LazyListScope.muteListItems( } if (threads.isNotEmpty()) { - item(key = "mute-threads-caption", contentType = "caption") { SectionCaption(stringRes(R.string.backup_entry_thread), Pad.padding(top = 18.dp)) } + item(key = "mute-threads-caption", contentType = "caption") { SectionCaption(stringRes(Res.string.backup_entry_thread), Pad.padding(top = 18.dp)) } items(threads, key = { "mute-thread-$it" }, contentType = { "note" }) { id -> LoadNote(id) { note -> if (note != null) { @@ -463,7 +524,7 @@ private fun FacePile( val names = people.take(2).map { observedName(it, accountViewModel) } val summary = if (people.size > 2) { - stringRes(R.string.backup_review_names_and_more, names.joinToString(", "), (people.size - 2).toString()) + stringRes(Res.string.backup_review_names_and_more, names.joinToString(", "), (people.size - 2).toString()) } else { names.joinToString(", ") } @@ -564,9 +625,9 @@ internal fun PrivateItemsCard( val tones = conflictTones() val (title, body) = when (change) { - ContentChange.CLEARED -> R.string.backup_review_private_wiped_title to R.string.backup_review_private_wiped_body - ContentChange.ADDED -> R.string.backup_review_private_added_title to R.string.backup_review_private_added_body - else -> R.string.backup_review_private_changed_title to R.string.backup_review_private_changed_body + ContentChange.CLEARED -> Res.string.backup_review_private_wiped_title to Res.string.backup_review_private_wiped_body + ContentChange.ADDED -> Res.string.backup_review_private_added_title to Res.string.backup_review_private_added_body + else -> Res.string.backup_review_private_changed_title to Res.string.backup_review_private_changed_body } Row( modifier @@ -592,28 +653,28 @@ internal fun PrivateItemsCard( // --------------------------------------------------------------------------------------- private class ProfileField( - val labelRes: Int, + val labelRes: StringResource, val change: ValueChange<String>, val isImage: Boolean = false, ) private fun profileFields(diff: MetadataDiff): List<ProfileField> = listOfNotNull( - diff.picture?.let { ProfileField(R.string.backup_profile_field_picture, it, isImage = true) }, - diff.banner?.let { ProfileField(R.string.backup_profile_field_banner, it, isImage = true) }, - diff.name?.let { ProfileField(R.string.backup_profile_field_name, it) }, - diff.displayName?.let { ProfileField(R.string.backup_profile_field_display_name, it) }, - diff.about?.let { ProfileField(R.string.backup_profile_field_about, it) }, - diff.nip05?.let { ProfileField(R.string.backup_profile_field_nip05, it) }, - diff.website?.let { ProfileField(R.string.backup_profile_field_website, it) }, - diff.lud16?.let { ProfileField(R.string.backup_profile_field_lud16, it) }, - diff.lud06?.let { ProfileField(R.string.backup_profile_field_lud06, it) }, - diff.clinkOffer?.let { ProfileField(R.string.backup_profile_field_clink_offer, it) }, - diff.pronouns?.let { ProfileField(R.string.backup_profile_field_pronouns, it) }, - diff.bot?.let { ProfileField(R.string.backup_profile_field_bot, ValueChange(it.before?.toString(), it.after?.toString())) }, + diff.picture?.let { ProfileField(Res.string.backup_profile_field_picture, it, isImage = true) }, + diff.banner?.let { ProfileField(Res.string.backup_profile_field_banner, it, isImage = true) }, + diff.name?.let { ProfileField(Res.string.backup_profile_field_name, it) }, + diff.displayName?.let { ProfileField(Res.string.backup_profile_field_display_name, it) }, + diff.about?.let { ProfileField(Res.string.backup_profile_field_about, it) }, + diff.nip05?.let { ProfileField(Res.string.backup_profile_field_nip05, it) }, + diff.website?.let { ProfileField(Res.string.backup_profile_field_website, it) }, + diff.lud16?.let { ProfileField(Res.string.backup_profile_field_lud16, it) }, + diff.lud06?.let { ProfileField(Res.string.backup_profile_field_lud06, it) }, + diff.clinkOffer?.let { ProfileField(Res.string.backup_profile_field_clink_offer, it) }, + diff.pronouns?.let { ProfileField(Res.string.backup_profile_field_pronouns, it) }, + diff.bot?.let { ProfileField(Res.string.backup_profile_field_bot, ValueChange(it.before?.toString(), it.after?.toString())) }, diff.birthday?.let { ProfileField( - R.string.backup_profile_field_birthday, + Res.string.backup_profile_field_birthday, ValueChange( it.before?.let { b -> listOfNotNull(b.year, b.month, b.day).joinToString("-") }, it.after?.let { a -> listOfNotNull(a.year, a.month, a.day).joinToString("-") }, @@ -636,8 +697,8 @@ private fun LazyListScope.profileItems( item(key = "profile-cards", contentType = "profile-cards") { val tones = conflictTones() Row(Pad.padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(10.dp)) { - MiniProfileCard(saved, pubKey, stringRes(R.string.backup_review_saved), MaterialTheme.colorScheme.outlineVariant, accountViewModel, Modifier.weight(1f)) - MiniProfileCard(incoming, pubKey, stringRes(R.string.backup_review_new), tones.removed.copy(alpha = 0.5f), accountViewModel, Modifier.weight(1f)) + MiniProfileCard(saved, pubKey, stringRes(Res.string.backup_review_saved), MaterialTheme.colorScheme.outlineVariant, accountViewModel, Modifier.weight(1f)) + MiniProfileCard(incoming, pubKey, stringRes(Res.string.backup_review_new), tones.removed.copy(alpha = 0.5f), accountViewModel, Modifier.weight(1f)) } } @@ -656,7 +717,7 @@ private fun LazyListScope.profileItems( } } // Linked identities and fields this app doesn't model use the generic sections. - genericDiffItems(buildRows(presentation, skipGroups = setOf(R.string.backup_entry_profile_field)), accountViewModel, nav) + genericDiffItems(buildRows(presentation, skipGroups = setOf(Res.string.backup_entry_profile_field)), accountViewModel, nav) } @Composable @@ -735,10 +796,10 @@ private fun FlowRowDiff( FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(6.dp), itemVerticalAlignment = Alignment.CenterVertically) { if (before != null) DiffChip(clip(before), tones.removed, struck = true) when { - after == null -> StatusTag(stringRes(R.string.backup_review_gone), tones.removed) + after == null -> StatusTag(stringRes(Res.string.backup_review_gone), tones.removed) before == null -> { DiffChip(clip(after), tones.added, struck = false) - StatusTag(stringRes(R.string.backup_review_new_tag), tones.added) + StatusTag(stringRes(Res.string.backup_review_new_tag), tones.added) } else -> { Text("→", color = MaterialTheme.colorScheme.placeholderText) @@ -779,7 +840,7 @@ private fun ImageThumb( contentAlignment = Alignment.Center, ) { if (url == null) { - Text(stringRes(R.string.backup_review_not_set), style = MaterialTheme.typography.labelSmall, color = color) + Text(stringRes(Res.string.backup_review_not_set), style = MaterialTheme.typography.labelSmall, color = color) } else { MyAsyncImage( imageUrl = url, @@ -843,14 +904,14 @@ private fun LazyListScope.nip65Items( val outbox = lane(saved, new, writes) val inbox = lane(saved, new, reads) - laneItems("outbox", stringRes = R.string.backup_review_outbox, subRes = R.string.backup_review_outbox_explainer, rows = outbox, nav = nav) - laneItems("inbox", stringRes = R.string.backup_review_inbox, subRes = R.string.backup_review_inbox_explainer, rows = inbox, nav = nav) + laneItems("outbox", stringRes = Res.string.backup_review_outbox, subRes = Res.string.backup_review_outbox_explainer, rows = outbox, nav = nav) + laneItems("inbox", stringRes = Res.string.backup_review_inbox, subRes = Res.string.backup_review_inbox_explainer, rows = inbox, nav = nav) } private fun LazyListScope.laneItems( key: String, - stringRes: Int, - subRes: Int, + stringRes: StringResource, + subRes: StringResource, rows: List<LaneRow>, nav: INav, ) { @@ -873,9 +934,9 @@ private fun LaneRelayRow( val (color, tag) = when (row.state) { LaneState.KEPT -> tones.kept to null - LaneState.DROPPED -> tones.removed to R.string.backup_review_dropped_tag - LaneState.ADDED -> tones.added to R.string.backup_review_new_tag - LaneState.DEMOTED -> tones.changed to R.string.backup_review_demoted_tag + LaneState.DROPPED -> tones.removed to Res.string.backup_review_dropped_tag + LaneState.ADDED -> tones.added to Res.string.backup_review_new_tag + LaneState.DEMOTED -> tones.changed to Res.string.backup_review_demoted_tag } Row( Pad @@ -933,12 +994,12 @@ private fun LazyListScope.nutzapItems( ) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { Icon(symbol = MaterialSymbols.Key, contentDescription = null, tint = tones.changed) - Text(stringRes(if (key.after == null) R.string.backup_review_key_removed else R.string.backup_review_key_replaced), style = MaterialTheme.typography.titleSmall) + Text(stringRes(if (key.after == null) Res.string.backup_review_key_removed else Res.string.backup_review_key_replaced), style = MaterialTheme.typography.titleSmall) } Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) { - KeyColumn(key.before, stringRes(R.string.backup_review_saved), tones.kept) + KeyColumn(key.before, stringRes(Res.string.backup_review_saved), tones.kept) Text("→", fontSize = 24.sp, color = MaterialTheme.colorScheme.placeholderText, modifier = Modifier.padding(horizontal = 18.dp)) - KeyColumn(key.after, stringRes(R.string.backup_review_new), tones.changed) + KeyColumn(key.after, stringRes(Res.string.backup_review_new), tones.changed) } if (key.after != null) { Row( @@ -950,7 +1011,7 @@ private fun LazyListScope.nutzapItems( horizontalArrangement = Arrangement.spacedBy(8.dp), ) { Icon(symbol = MaterialSymbols.Warning, contentDescription = null, tint = tones.changed, modifier = Modifier.size(18.dp)) - Text(stringRes(R.string.backup_review_key_warning), style = MaterialTheme.typography.bodySmall, color = tones.changed) + Text(stringRes(Res.string.backup_review_key_warning), style = MaterialTheme.typography.bodySmall, color = tones.changed) } } } @@ -976,10 +1037,10 @@ private fun LazyListScope.nutzapItems( } + diff.mints.added.map { Triple(it.mintUrl, it.units.joinToString(), LaneState.ADDED) } if (mintRows.isNotEmpty()) { - item(key = "nutzap-mints", contentType = "caption") { SectionCaption(stringRes(R.string.backup_entry_mint), Pad.padding(top = 18.dp, bottom = 6.dp)) } + item(key = "nutzap-mints", contentType = "caption") { SectionCaption(stringRes(Res.string.backup_entry_mint), Pad.padding(top = 18.dp, bottom = 6.dp)) } items(mintRows, key = { "mint-" + it.first }, contentType = { "mint" }) { (url, units, state) -> MintRow(url, units, state) } } - genericDiffItems(buildRows(presentation, skipGroups = setOf(R.string.backup_entry_mint, R.string.backup_entry_nutzap_key)), accountViewModel, nav) + genericDiffItems(buildRows(presentation, skipGroups = setOf(Res.string.backup_entry_mint, Res.string.backup_entry_nutzap_key)), accountViewModel, nav) } @Composable @@ -993,7 +1054,7 @@ private fun KeyColumn( KeyFingerprint(hex, color) } else { Box(Modifier.size(96.dp).clip(RoundedCornerShape(18.dp)).border(2.dp, color, RoundedCornerShape(18.dp)), contentAlignment = Alignment.Center) { - Text(stringRes(R.string.backup_review_not_set), style = MaterialTheme.typography.labelSmall, color = color) + Text(stringRes(Res.string.backup_review_not_set), style = MaterialTheme.typography.labelSmall, color = color) } } StatusTag(label, color) @@ -1010,9 +1071,9 @@ private fun MintRow( val (color, tag) = when (state) { LaneState.KEPT -> MaterialTheme.colorScheme.onSurface to null - LaneState.DROPPED -> tones.removed to R.string.backup_review_dropped_tag - LaneState.ADDED -> tones.added to R.string.backup_review_new_tag - LaneState.DEMOTED -> tones.changed to R.string.backup_review_units_tag + LaneState.DROPPED -> tones.removed to Res.string.backup_review_dropped_tag + LaneState.ADDED -> tones.added to Res.string.backup_review_new_tag + LaneState.DEMOTED -> tones.changed to Res.string.backup_review_units_tag } Row( Pad @@ -1083,21 +1144,21 @@ private fun LazyListScope.groupItems( StatTile( diff.groups.removed.size .toString(), - stringRes(R.string.backup_review_left), + stringRes(Res.string.backup_review_left), tones.removed, Modifier.weight(1f), ) StatTile( diff.groups.added.size .toString(), - stringRes(R.string.backup_review_joined), + stringRes(Res.string.backup_review_joined), tones.added, Modifier.weight(1f), ) StatTile( diff.groups.changed.size .toString(), - stringRes(R.string.backup_review_renamed), + stringRes(Res.string.backup_review_renamed), tones.changed, Modifier.weight(1f), ) @@ -1165,7 +1226,7 @@ private fun GroupTileContent( Column { Text(name, style = MaterialTheme.typography.titleSmall, maxLines = 1, overflow = TextOverflow.Ellipsis) Text( - tile.oldName?.let { stringRes(R.string.backup_review_was_named, it) } ?: tile.group.relayUrl + tile.oldName?.let { stringRes(Res.string.backup_review_was_named, it) } ?: tile.group.relayUrl .removePrefix("wss://") .removeSuffix("/"), style = MaterialTheme.typography.labelSmall, @@ -1176,9 +1237,9 @@ private fun GroupTileContent( } } when (tile.state) { - LaneState.DROPPED -> StatusTag(stringRes(R.string.backup_review_left_tag), tones.removed, Modifier.align(Alignment.TopEnd)) - LaneState.ADDED -> StatusTag(stringRes(R.string.backup_review_new_tag), tones.added, Modifier.align(Alignment.TopEnd)) - LaneState.DEMOTED -> StatusTag(stringRes(R.string.backup_review_renamed_tag), tones.changed, Modifier.align(Alignment.TopEnd)) + LaneState.DROPPED -> StatusTag(stringRes(Res.string.backup_review_left_tag), tones.removed, Modifier.align(Alignment.TopEnd)) + LaneState.ADDED -> StatusTag(stringRes(Res.string.backup_review_new_tag), tones.added, Modifier.align(Alignment.TopEnd)) + LaneState.DEMOTED -> StatusTag(stringRes(Res.string.backup_review_renamed_tag), tones.changed, Modifier.align(Alignment.TopEnd)) LaneState.KEPT -> {} } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictListViews.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictListViews.kt index 55473cce27..b2e854c06b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictListViews.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictListViews.kt @@ -48,13 +48,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols @@ -63,6 +61,53 @@ import com.vitorpamplona.amethyst.commons.model.backups.ReplaceableBackupConflic import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.model.navigation.Route import com.vitorpamplona.amethyst.commons.model.navigation.routeFor +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.backup_review_allowed_tag +import com.vitorpamplona.amethyst.commons.resources.backup_review_concord_body +import com.vitorpamplona.amethyst.commons.resources.backup_review_demoted_tag +import com.vitorpamplona.amethyst.commons.resources.backup_review_dropped_tag +import com.vitorpamplona.amethyst.commons.resources.backup_review_encrypted_added +import com.vitorpamplona.amethyst.commons.resources.backup_review_encrypted_changed +import com.vitorpamplona.amethyst.commons.resources.backup_review_encrypted_cleared +import com.vitorpamplona.amethyst.commons.resources.backup_review_feed +import com.vitorpamplona.amethyst.commons.resources.backup_review_hashtags_you_follow +import com.vitorpamplona.amethyst.commons.resources.backup_review_joined +import com.vitorpamplona.amethyst.commons.resources.backup_review_kept +import com.vitorpamplona.amethyst.commons.resources.backup_review_left +import com.vitorpamplona.amethyst.commons.resources.backup_review_left_tag +import com.vitorpamplona.amethyst.commons.resources.backup_review_listed_tag +import com.vitorpamplona.amethyst.commons.resources.backup_review_moved_tag +import com.vitorpamplona.amethyst.commons.resources.backup_review_new +import com.vitorpamplona.amethyst.commons.resources.backup_review_new_count_caption +import com.vitorpamplona.amethyst.commons.resources.backup_review_new_tag +import com.vitorpamplona.amethyst.commons.resources.backup_review_newly_blocked +import com.vitorpamplona.amethyst.commons.resources.backup_review_no_longer_blocked +import com.vitorpamplona.amethyst.commons.resources.backup_review_offer +import com.vitorpamplona.amethyst.commons.resources.backup_review_offers_gone +import com.vitorpamplona.amethyst.commons.resources.backup_review_payment_targets_gone +import com.vitorpamplona.amethyst.commons.resources.backup_review_payments_explainer +import com.vitorpamplona.amethyst.commons.resources.backup_review_places_gone +import com.vitorpamplona.amethyst.commons.resources.backup_review_places_you_follow +import com.vitorpamplona.amethyst.commons.resources.backup_review_receiving_tag +import com.vitorpamplona.amethyst.commons.resources.backup_review_saved +import com.vitorpamplona.amethyst.commons.resources.backup_review_searching_tag +import com.vitorpamplona.amethyst.commons.resources.backup_review_service +import com.vitorpamplona.amethyst.commons.resources.backup_review_service_followers +import com.vitorpamplona.amethyst.commons.resources.backup_review_service_rank +import com.vitorpamplona.amethyst.commons.resources.backup_review_service_topics +import com.vitorpamplona.amethyst.commons.resources.backup_review_services_changed +import com.vitorpamplona.amethyst.commons.resources.backup_review_services_changed_explainer +import com.vitorpamplona.amethyst.commons.resources.backup_review_still_blocked +import com.vitorpamplona.amethyst.commons.resources.backup_review_storing_tag +import com.vitorpamplona.amethyst.commons.resources.backup_review_topics_gone +import com.vitorpamplona.amethyst.commons.resources.backup_review_trust_warning +import com.vitorpamplona.amethyst.commons.resources.backup_review_trusted_tag +import com.vitorpamplona.amethyst.commons.resources.backup_review_unblocked +import com.vitorpamplona.amethyst.commons.resources.backup_review_unblocked_explainer +import com.vitorpamplona.amethyst.commons.resources.backup_review_untrusted_tag +import com.vitorpamplona.amethyst.commons.resources.backup_review_wallet_body +import com.vitorpamplona.amethyst.commons.resources.backup_type_communities +import com.vitorpamplona.amethyst.commons.resources.backup_type_public_chats import com.vitorpamplona.amethyst.commons.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.screen.LocalDisplaySettings @@ -118,6 +163,9 @@ import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEve import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListDiff import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListEvent +import org.jetbrains.compose.resources.PluralStringResource +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.pluralStringResource /** * Per-event layouts for the account's other lists: each relay list framed by what it is @@ -142,8 +190,8 @@ internal fun LazyListScope.listDiffItems( is TrustProviderListDiff -> trustProviderItems(diff, conflict, accountViewModel, nav) is PaymentTargetsDiff -> paymentTargetItems(diff, conflict) is Bolt12OfferListDiff -> offerItems(diff, conflict) - is CashuWalletDiff -> encryptedOnlyItems(MaterialSymbols.AccountBalanceWallet, diff.wallet, R.string.backup_review_wallet_body) - is ConcordCommunityListDiff -> encryptedOnlyItems(MaterialSymbols.Groups, diff.communities, R.string.backup_review_concord_body) + is CashuWalletDiff -> encryptedOnlyItems(MaterialSymbols.AccountBalanceWallet, diff.wallet, Res.string.backup_review_wallet_body) + is ConcordCommunityListDiff -> encryptedOnlyItems(MaterialSymbols.Groups, diff.communities, Res.string.backup_review_concord_body) else -> return false } return true @@ -193,15 +241,15 @@ private fun fateColor(fate: ItemFate): Color { private fun <T> LazyListScope.countTiles( items: List<Pair<T, ItemFate>>, - removedCaption: Int, - addedCaption: Int, + removedCaption: StringResource, + addedCaption: StringResource, ) { item(key = "count-tiles", contentType = "tiles") { val tones = conflictTones() Row(Pad.padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { StatTile(items.count { it.second == ItemFate.DROPPED }.toString(), stringRes(removedCaption), tones.removed, Modifier.weight(1f)) StatTile(items.count { it.second == ItemFate.ADDED }.toString(), stringRes(addedCaption), tones.added, Modifier.weight(1f)) - StatTile(items.count { it.second == ItemFate.KEPT || it.second == ItemFate.CHANGED }.toString(), stringRes(R.string.backup_review_kept), tones.kept, Modifier.weight(1f)) + StatTile(items.count { it.second == ItemFate.KEPT || it.second == ItemFate.CHANGED }.toString(), stringRes(Res.string.backup_review_kept), tones.kept, Modifier.weight(1f)) } } } @@ -218,20 +266,20 @@ private fun LazyListScope.privateItemsCard(change: ContentChange) { /** How a relay list's rows are presented: its icon and what a kept or dropped relay is called. */ private class RelayFraming( val icon: MaterialSymbol, - val keptTag: Int, - val droppedTag: Int = R.string.backup_review_dropped_tag, + val keptTag: StringResource, + val droppedTag: StringResource = Res.string.backup_review_dropped_tag, ) private fun framingOf(type: BackupEventType): RelayFraming = when (type) { - BackupEventType.DM_RELAYS -> RelayFraming(MaterialSymbols.Mail, R.string.backup_review_receiving_tag) - BackupEventType.KEY_PACKAGE_RELAYS -> RelayFraming(MaterialSymbols.Key, R.string.backup_review_listed_tag) - BackupEventType.SEARCH_RELAYS -> RelayFraming(MaterialSymbols.Search, R.string.backup_review_searching_tag) - BackupEventType.INDEXER_RELAYS -> RelayFraming(MaterialSymbols.Dns, R.string.backup_review_listed_tag) - BackupEventType.RELAY_FEEDS -> RelayFraming(MaterialSymbols.CellTower, R.string.backup_review_listed_tag) - BackupEventType.PRIVATE_OUTBOX_RELAYS -> RelayFraming(MaterialSymbols.Lock, R.string.backup_review_storing_tag) - BackupEventType.TRUSTED_RELAYS -> RelayFraming(MaterialSymbols.Shield, R.string.backup_review_trusted_tag, R.string.backup_review_untrusted_tag) - else -> RelayFraming(MaterialSymbols.Dns, R.string.backup_review_listed_tag) + BackupEventType.DM_RELAYS -> RelayFraming(MaterialSymbols.Mail, Res.string.backup_review_receiving_tag) + BackupEventType.KEY_PACKAGE_RELAYS -> RelayFraming(MaterialSymbols.Key, Res.string.backup_review_listed_tag) + BackupEventType.SEARCH_RELAYS -> RelayFraming(MaterialSymbols.Search, Res.string.backup_review_searching_tag) + BackupEventType.INDEXER_RELAYS -> RelayFraming(MaterialSymbols.Dns, Res.string.backup_review_listed_tag) + BackupEventType.RELAY_FEEDS -> RelayFraming(MaterialSymbols.CellTower, Res.string.backup_review_listed_tag) + BackupEventType.PRIVATE_OUTBOX_RELAYS -> RelayFraming(MaterialSymbols.Lock, Res.string.backup_review_storing_tag) + BackupEventType.TRUSTED_RELAYS -> RelayFraming(MaterialSymbols.Shield, Res.string.backup_review_trusted_tag, Res.string.backup_review_untrusted_tag) + else -> RelayFraming(MaterialSymbols.Dns, Res.string.backup_review_listed_tag) } private fun savedRelaysOf(event: Event): List<NormalizedRelayUrl> = @@ -277,8 +325,8 @@ private fun RelayFateRow( when (fate) { ItemFate.KEPT -> framing.keptTag ItemFate.DROPPED -> framing.droppedTag - ItemFate.ADDED -> R.string.backup_review_new_tag - ItemFate.CHANGED -> R.string.backup_review_demoted_tag + ItemFate.ADDED -> Res.string.backup_review_new_tag + ItemFate.CHANGED -> Res.string.backup_review_demoted_tag } Row( Pad @@ -320,14 +368,14 @@ private fun LazyListScope.blockedRelayItems( Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp)) { Icon(symbol = MaterialSymbols.LockOpen, contentDescription = null, tint = tones.changed, modifier = Modifier.size(44.dp)) Column { - Text(stringRes(R.string.backup_review_unblocked, unblocked.size.toString()), fontSize = 30.sp, lineHeight = 32.sp, fontWeight = FontWeight.Bold, color = tones.changed) - Text(stringRes(R.string.backup_review_unblocked_explainer), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.placeholderText) + Text(stringRes(Res.string.backup_review_unblocked, unblocked.size.toString()), fontSize = 30.sp, lineHeight = 32.sp, fontWeight = FontWeight.Bold, color = tones.changed) + Text(stringRes(Res.string.backup_review_unblocked_explainer), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.placeholderText) } } } } if (unblocked.isNotEmpty()) { - item(key = "unblocked-caption", contentType = "caption") { SectionCaption(stringRes(R.string.backup_review_no_longer_blocked), Pad.padding(top = 18.dp)) } + item(key = "unblocked-caption", contentType = "caption") { SectionCaption(stringRes(Res.string.backup_review_no_longer_blocked), Pad.padding(top = 18.dp)) } items(unblocked, key = { "unblocked-" + it.url }, contentType = { "relay-row" }) { url -> val tones = conflictTones() Row( @@ -346,12 +394,12 @@ private fun LazyListScope.blockedRelayItems( Icon(symbol = MaterialSymbols.LockOpen, contentDescription = null, tint = tones.changed, modifier = Modifier.size(18.dp)) } Text(url.url.removePrefix("wss://").removeSuffix("/"), modifier = Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) - StatusTag(stringRes(R.string.backup_review_allowed_tag), tones.changed) + StatusTag(stringRes(Res.string.backup_review_allowed_tag), tones.changed) } } } if (newlyBlocked.isNotEmpty()) { - item(key = "newly-blocked-caption", contentType = "caption") { SectionCaption(stringRes(R.string.backup_review_newly_blocked), Pad.padding(top = 18.dp)) } + item(key = "newly-blocked-caption", contentType = "caption") { SectionCaption(stringRes(Res.string.backup_review_newly_blocked), Pad.padding(top = 18.dp)) } items(newlyBlocked, key = { "newly-blocked-" + it.url }, contentType = { "relay-row" }) { url -> RelayFateRow(url, ItemFate.ADDED, framingOf(BackupEventType.BLOCKED_RELAYS), nav) } @@ -359,7 +407,7 @@ private fun LazyListScope.blockedRelayItems( if (stillBlocked.isNotEmpty()) { item(key = "still-blocked", contentType = "chips") { Column(Pad.padding(top = 18.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - SectionCaption(stringRes(R.string.backup_review_still_blocked)) + SectionCaption(stringRes(Res.string.backup_review_still_blocked)) FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { stillBlocked.forEach { url -> Row( @@ -392,7 +440,7 @@ private fun <T> LazyListScope.spaceGrid( key: (T) -> String, content: @Composable (T, ItemFate, Modifier) -> Unit, ) { - countTiles(items, R.string.backup_review_left, R.string.backup_review_joined) + countTiles(items, Res.string.backup_review_left, Res.string.backup_review_joined) items(items.chunked(2), key = { keyPrefix + key(it.first().first) }, contentType = { "space-row" }) { row -> Row(Pad.padding(top = 10.dp).fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) { row.forEach { (item, fate) -> content(item, fate, Modifier.weight(1f)) } @@ -436,9 +484,9 @@ private fun SpaceTile( } val tag = when (fate) { - ItemFate.DROPPED -> R.string.backup_review_left_tag to tones.removed - ItemFate.ADDED -> R.string.backup_review_new_tag to tones.added - ItemFate.CHANGED -> R.string.backup_review_moved_tag to tones.changed + ItemFate.DROPPED -> Res.string.backup_review_left_tag to tones.removed + ItemFate.ADDED -> Res.string.backup_review_new_tag to tones.added + ItemFate.CHANGED -> Res.string.backup_review_moved_tag to tones.changed ItemFate.KEPT -> null } tag?.let { (res, color) -> StatusTag(stringRes(res), color, Modifier.align(Alignment.TopEnd)) } @@ -459,7 +507,7 @@ private fun LazyListScope.publicChatItems( val current = state?.channel ?: channel SpaceTile( name = current.toBestDisplayName(), - subtitle = stringRes(R.string.backup_type_public_chats), + subtitle = stringRes(Res.string.backup_type_public_chats), robot = tag.eventId, picture = channel.profilePicture(), fate = fate, @@ -525,7 +573,7 @@ private fun AddressableSpaceTile( } SpaceTile( name = name ?: address.dTag.ifBlank { address.toValue().toShortDisplay() }, - subtitle = stringRes(if (address.kind == AppDefinitionEvent.KIND) R.string.backup_review_feed else R.string.backup_type_communities), + subtitle = stringRes(if (address.kind == AppDefinitionEvent.KIND) Res.string.backup_review_feed else Res.string.backup_type_communities), robot = address.toValue(), picture = picture, fate = fate, @@ -543,7 +591,7 @@ private fun LazyListScope.ephemeralRoomItems( ) { val saved = (conflict.saved as? EphemeralChatListEvent)?.publicRooms().orEmpty() val rooms = fates(saved, diff.rooms) { it } - countTiles(rooms, R.string.backup_review_left, R.string.backup_review_joined) + countTiles(rooms, Res.string.backup_review_left, Res.string.backup_review_joined) items(rooms, key = { "room-" + it.first.toKey() }, contentType = { "room-row" }) { (room, fate) -> RoomRow(room, fate, nav) } privateItemsCard(diff.privateItems) } @@ -582,8 +630,8 @@ private fun RoomRow( ) } when (fate) { - ItemFate.DROPPED -> StatusTag(stringRes(R.string.backup_review_left_tag), color) - ItemFate.ADDED -> StatusTag(stringRes(R.string.backup_review_new_tag), color) + ItemFate.DROPPED -> StatusTag(stringRes(Res.string.backup_review_left_tag), color) + ItemFate.ADDED -> StatusTag(stringRes(Res.string.backup_review_new_tag), color) else -> {} } } @@ -602,11 +650,11 @@ private fun LazyListScope.topicItems( nav: INav, ) { val topics = fates(saved, diff) { it.lowercase() } - countTiles(topics, if (isPlace) R.string.backup_review_places_gone else R.string.backup_review_topics_gone, R.string.backup_review_new_count_caption) + countTiles(topics, if (isPlace) Res.string.backup_review_places_gone else Res.string.backup_review_topics_gone, Res.string.backup_review_new_count_caption) item(key = "topic-cloud", contentType = "cloud") { val tones = conflictTones() Column(Pad.padding(top = 18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { - SectionCaption(stringRes(if (isPlace) R.string.backup_review_places_you_follow else R.string.backup_review_hashtags_you_follow)) + SectionCaption(stringRes(if (isPlace) Res.string.backup_review_places_you_follow else Res.string.backup_review_hashtags_you_follow)) FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp), itemVerticalAlignment = Alignment.CenterVertically) { topics.forEach { (topic, fate) -> val color = fateColor(fate) @@ -706,15 +754,15 @@ private fun LazyListScope.trustProviderItems( Icon(symbol = MaterialSymbols.Group, contentDescription = null, tint = tones.changed) } Column { - Text(stringRes(R.string.backup_review_services_changed, affected.toString(), services.size.toString()), fontSize = 24.sp, lineHeight = 28.sp, fontWeight = FontWeight.Bold) - Text(stringRes(R.string.backup_review_services_changed_explainer), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.placeholderText) + Text(stringRes(Res.string.backup_review_services_changed, affected.toString(), services.size.toString()), fontSize = 24.sp, lineHeight = 28.sp, fontWeight = FontWeight.Bold) + Text(stringRes(Res.string.backup_review_services_changed_explainer), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.placeholderText) } } } } item(key = "trust-header", contentType = "table-header") { Row(Pad.padding(top = 16.dp, bottom = 4.dp).padding(horizontal = 12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { - listOf(R.string.backup_review_service, R.string.backup_review_saved, R.string.backup_review_new).forEachIndexed { index, res -> + listOf(Res.string.backup_review_service, Res.string.backup_review_saved, Res.string.backup_review_new).forEachIndexed { index, res -> Text(stringRes(res).uppercase(), Modifier.weight(if (index == 0) 1.1f else 1f), style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.placeholderText) } } @@ -732,7 +780,7 @@ private fun LazyListScope.trustProviderItems( horizontalArrangement = Arrangement.spacedBy(8.dp), ) { Icon(symbol = MaterialSymbols.Warning, contentDescription = null, tint = tones.changed, modifier = Modifier.size(18.dp)) - Text(stringRes(R.string.backup_review_trust_warning), style = MaterialTheme.typography.bodySmall, color = tones.changed) + Text(stringRes(Res.string.backup_review_trust_warning), style = MaterialTheme.typography.bodySmall, color = tones.changed) } } privateItemsCard(diff.privateItems) @@ -771,9 +819,9 @@ private fun ServiceTableRow( @Composable private fun serviceLabel(type: String): String = when (type) { - "rank" -> stringRes(R.string.backup_review_service_rank) - "followers" -> stringRes(R.string.backup_review_service_followers) - "t" -> stringRes(R.string.backup_review_service_topics) + "rank" -> stringRes(Res.string.backup_review_service_rank) + "followers" -> stringRes(Res.string.backup_review_service_followers) + "t" -> stringRes(Res.string.backup_review_service_topics) else -> type.replace('_', ' ').replaceFirstChar { it.uppercase() } } @@ -826,7 +874,7 @@ private fun LazyListScope.paymentTargetItems( ) { val saved = (conflict.saved as? PaymentTargetsEvent)?.paymentTargets().orEmpty() val targets = fates(saved, diff.targets) { it } - moneyHero(targets.count { it.second == ItemFate.DROPPED }, R.plurals.backup_review_payment_targets_gone) + moneyHero(targets.count { it.second == ItemFate.DROPPED }, Res.plurals.backup_review_payment_targets_gone) items(targets, key = { "target-" + it.first.type + ":" + it.first.authority }, contentType = { "money-row" }) { (target, fate) -> PaymentTargetRow(target, fate) } } @@ -836,15 +884,15 @@ private fun LazyListScope.offerItems( ) { val saved = (conflict.saved as? Bolt12OfferListEvent)?.offers().orEmpty() val offers = fates(saved, diff.offers) { it } - moneyHero(offers.count { it.second == ItemFate.DROPPED }, R.plurals.backup_review_offers_gone) + moneyHero(offers.count { it.second == ItemFate.DROPPED }, Res.plurals.backup_review_offers_gone) items(offers, key = { "offer-" + it.first }, contentType = { "money-row" }) { (offer, fate) -> - MoneyRow(stringRes(R.string.backup_review_offer), offer.toShortDisplay(prefixSize = 4), fate) + MoneyRow(stringRes(Res.string.backup_review_offer), offer.toShortDisplay(prefixSize = 4), fate) } } private fun LazyListScope.moneyHero( dropped: Int, - headline: Int, + headline: PluralStringResource, ) { item(key = "money-hero", contentType = "hero") { val tones = conflictTones() @@ -856,7 +904,7 @@ private fun LazyListScope.moneyHero( } Column { Text(pluralStringResource(headline, dropped, dropped), fontSize = 24.sp, lineHeight = 28.sp, fontWeight = FontWeight.Bold, color = color) - Text(stringRes(R.string.backup_review_payments_explainer), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.placeholderText) + Text(stringRes(Res.string.backup_review_payments_explainer), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.placeholderText) } } } @@ -902,8 +950,8 @@ private fun MoneyRow( ) } when (fate) { - ItemFate.DROPPED -> StatusTag(stringRes(R.string.backup_review_dropped_tag), color) - ItemFate.ADDED -> StatusTag(stringRes(R.string.backup_review_new_tag), color) + ItemFate.DROPPED -> StatusTag(stringRes(Res.string.backup_review_dropped_tag), color) + ItemFate.ADDED -> StatusTag(stringRes(Res.string.backup_review_new_tag), color) else -> {} } } @@ -916,15 +964,15 @@ private fun MoneyRow( private fun LazyListScope.encryptedOnlyItems( icon: MaterialSymbol, change: ContentChange, - body: Int, + body: StringResource, ) { item(key = "encrypted-hero", contentType = "hero") { val tones = conflictTones() val (color, headline) = when (change) { - ContentChange.CLEARED -> tones.removed to R.string.backup_review_encrypted_cleared - ContentChange.ADDED -> tones.added to R.string.backup_review_encrypted_added - else -> tones.changed to R.string.backup_review_encrypted_changed + ContentChange.CLEARED -> tones.removed to Res.string.backup_review_encrypted_cleared + ContentChange.ADDED -> tones.added to Res.string.backup_review_encrypted_added + else -> tones.changed to Res.string.backup_review_encrypted_changed } Column(Pad.padding(top = 8.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) { TintedPanel(color.copy(alpha = 0.12f)) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictPresentation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictPresentation.kt index 58195a5d31..ff28dfa6c3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictPresentation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictPresentation.kt @@ -23,9 +23,98 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.backups import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.remember -import androidx.compose.ui.platform.LocalResources -import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.model.backups.BackupEventType +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.backup_conflict_relay_read_only +import com.vitorpamplona.amethyst.commons.resources.backup_conflict_relay_read_write +import com.vitorpamplona.amethyst.commons.resources.backup_conflict_relay_write_only +import com.vitorpamplona.amethyst.commons.resources.backup_entry_algo_feed +import com.vitorpamplona.amethyst.commons.resources.backup_entry_chat_room +import com.vitorpamplona.amethyst.commons.resources.backup_entry_community +import com.vitorpamplona.amethyst.commons.resources.backup_entry_hashtag +import com.vitorpamplona.amethyst.commons.resources.backup_entry_identity_claim +import com.vitorpamplona.amethyst.commons.resources.backup_entry_location +import com.vitorpamplona.amethyst.commons.resources.backup_entry_mint +import com.vitorpamplona.amethyst.commons.resources.backup_entry_nutzap_key +import com.vitorpamplona.amethyst.commons.resources.backup_entry_offer +import com.vitorpamplona.amethyst.commons.resources.backup_entry_other +import com.vitorpamplona.amethyst.commons.resources.backup_entry_payment_target +import com.vitorpamplona.amethyst.commons.resources.backup_entry_person +import com.vitorpamplona.amethyst.commons.resources.backup_entry_profile_field +import com.vitorpamplona.amethyst.commons.resources.backup_entry_public_chat +import com.vitorpamplona.amethyst.commons.resources.backup_entry_relay +import com.vitorpamplona.amethyst.commons.resources.backup_entry_relay_group +import com.vitorpamplona.amethyst.commons.resources.backup_entry_thread +import com.vitorpamplona.amethyst.commons.resources.backup_entry_trust_provider +import com.vitorpamplona.amethyst.commons.resources.backup_entry_word +import com.vitorpamplona.amethyst.commons.resources.backup_profile_field_about +import com.vitorpamplona.amethyst.commons.resources.backup_profile_field_banner +import com.vitorpamplona.amethyst.commons.resources.backup_profile_field_birthday +import com.vitorpamplona.amethyst.commons.resources.backup_profile_field_bot +import com.vitorpamplona.amethyst.commons.resources.backup_profile_field_clink_offer +import com.vitorpamplona.amethyst.commons.resources.backup_profile_field_display_name +import com.vitorpamplona.amethyst.commons.resources.backup_profile_field_lud06 +import com.vitorpamplona.amethyst.commons.resources.backup_profile_field_lud16 +import com.vitorpamplona.amethyst.commons.resources.backup_profile_field_name +import com.vitorpamplona.amethyst.commons.resources.backup_profile_field_nip05 +import com.vitorpamplona.amethyst.commons.resources.backup_profile_field_picture +import com.vitorpamplona.amethyst.commons.resources.backup_profile_field_pronouns +import com.vitorpamplona.amethyst.commons.resources.backup_profile_field_website +import com.vitorpamplona.amethyst.commons.resources.backup_type_app_settings +import com.vitorpamplona.amethyst.commons.resources.backup_type_app_settings_explainer +import com.vitorpamplona.amethyst.commons.resources.backup_type_blocked_relays +import com.vitorpamplona.amethyst.commons.resources.backup_type_blocked_relays_explainer +import com.vitorpamplona.amethyst.commons.resources.backup_type_bolt12_offers +import com.vitorpamplona.amethyst.commons.resources.backup_type_bolt12_offers_explainer +import com.vitorpamplona.amethyst.commons.resources.backup_type_cashu_wallet +import com.vitorpamplona.amethyst.commons.resources.backup_type_cashu_wallet_explainer +import com.vitorpamplona.amethyst.commons.resources.backup_type_communities +import com.vitorpamplona.amethyst.commons.resources.backup_type_communities_explainer +import com.vitorpamplona.amethyst.commons.resources.backup_type_concord_communities +import com.vitorpamplona.amethyst.commons.resources.backup_type_concord_communities_explainer +import com.vitorpamplona.amethyst.commons.resources.backup_type_dm_relays +import com.vitorpamplona.amethyst.commons.resources.backup_type_dm_relays_explainer +import com.vitorpamplona.amethyst.commons.resources.backup_type_ephemeral_chats +import com.vitorpamplona.amethyst.commons.resources.backup_type_ephemeral_chats_explainer +import com.vitorpamplona.amethyst.commons.resources.backup_type_favorite_algo_feeds +import com.vitorpamplona.amethyst.commons.resources.backup_type_favorite_algo_feeds_explainer +import com.vitorpamplona.amethyst.commons.resources.backup_type_follow_list +import com.vitorpamplona.amethyst.commons.resources.backup_type_follow_list_explainer +import com.vitorpamplona.amethyst.commons.resources.backup_type_geohashes +import com.vitorpamplona.amethyst.commons.resources.backup_type_geohashes_explainer +import com.vitorpamplona.amethyst.commons.resources.backup_type_hashtags +import com.vitorpamplona.amethyst.commons.resources.backup_type_hashtags_explainer +import com.vitorpamplona.amethyst.commons.resources.backup_type_indexer_relays +import com.vitorpamplona.amethyst.commons.resources.backup_type_indexer_relays_explainer +import com.vitorpamplona.amethyst.commons.resources.backup_type_key_package_relays +import com.vitorpamplona.amethyst.commons.resources.backup_type_key_package_relays_explainer +import com.vitorpamplona.amethyst.commons.resources.backup_type_mute_list +import com.vitorpamplona.amethyst.commons.resources.backup_type_mute_list_explainer +import com.vitorpamplona.amethyst.commons.resources.backup_type_nutzap_info +import com.vitorpamplona.amethyst.commons.resources.backup_type_nutzap_info_explainer +import com.vitorpamplona.amethyst.commons.resources.backup_type_other +import com.vitorpamplona.amethyst.commons.resources.backup_type_other_explainer +import com.vitorpamplona.amethyst.commons.resources.backup_type_outbox_inbox_relays +import com.vitorpamplona.amethyst.commons.resources.backup_type_outbox_inbox_relays_explainer +import com.vitorpamplona.amethyst.commons.resources.backup_type_payment_targets +import com.vitorpamplona.amethyst.commons.resources.backup_type_payment_targets_explainer +import com.vitorpamplona.amethyst.commons.resources.backup_type_private_outbox_relays +import com.vitorpamplona.amethyst.commons.resources.backup_type_private_outbox_relays_explainer +import com.vitorpamplona.amethyst.commons.resources.backup_type_profile +import com.vitorpamplona.amethyst.commons.resources.backup_type_profile_explainer +import com.vitorpamplona.amethyst.commons.resources.backup_type_public_chats +import com.vitorpamplona.amethyst.commons.resources.backup_type_public_chats_explainer +import com.vitorpamplona.amethyst.commons.resources.backup_type_relay_feeds +import com.vitorpamplona.amethyst.commons.resources.backup_type_relay_feeds_explainer +import com.vitorpamplona.amethyst.commons.resources.backup_type_relay_groups +import com.vitorpamplona.amethyst.commons.resources.backup_type_relay_groups_explainer +import com.vitorpamplona.amethyst.commons.resources.backup_type_search_relays +import com.vitorpamplona.amethyst.commons.resources.backup_type_search_relays_explainer +import com.vitorpamplona.amethyst.commons.resources.backup_type_trust_providers +import com.vitorpamplona.amethyst.commons.resources.backup_type_trust_providers_explainer +import com.vitorpamplona.amethyst.commons.resources.backup_type_trusted_relays +import com.vitorpamplona.amethyst.commons.resources.backup_type_trusted_relays_explainer +import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListDiff import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListDiff @@ -61,6 +150,7 @@ import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListDiff import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataDiff import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListDiff import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListDiff +import org.jetbrains.compose.resources.StringResource private const val MAX_VALUE_LENGTH = 80 @@ -168,7 +258,7 @@ sealed interface ReviewItem { /** One labelled group of differences (people, relays, profile fields…) of a conflict. */ @Immutable class DiffGroup( - val label: Int, + val label: StringResource, val removed: List<ReviewItem>, val added: List<ReviewItem>, val changed: List<ReviewItem>, @@ -189,7 +279,7 @@ class DiffPresentation( /** A group from a [ListDiff] of an event's parsed items. */ private fun <T> listGroup( - label: Int, + label: StringResource, diff: ListDiff<T>, item: (T) -> ReviewItem, change: (ItemChange<T>) -> ReviewItem, @@ -197,7 +287,7 @@ private fun <T> listGroup( /** A group whose items can't change, only be added or removed (hashtags, plain relays…). */ private fun <T> listGroup( - label: Int, + label: StringResource, diff: ListDiff<T>, item: (T) -> ReviewItem, ) = listGroup(label, diff, item) { item(it.after) } @@ -212,7 +302,7 @@ private inline fun <reified T> ListDiff<*>.only() = /** A group from single-valued fields: a field set only after is added, only before is removed. */ private fun fieldGroup( - label: Int, + label: StringResource, fields: List<Pair<String, ValueChange<String>?>>, ): DiffGroup { val removed = mutableListOf<ReviewItem>() @@ -242,14 +332,38 @@ private fun arrow( */ @Composable fun rememberPresentation(diff: EventDiff): DiffPresentation { - val resources = LocalResources.current - return remember(diff, resources) { presentationOf(diff) { resources.getString(it) } } + // Compose resources resolve only in composition (or suspended), so the few labels + // [presentationOf] writes into item text are resolved here and handed in. The map + // compares by value, so a recomposition with the same locale keeps the cached result. + val labels = PRESENTATION_LABELS.associateWith { stringRes(it) } + return remember(diff, labels) { presentationOf(diff) { labels.getValue(it) } } } +/** Every label [presentationOf] resolves through its `str` parameter. */ +private val PRESENTATION_LABELS = + listOf( + Res.string.backup_profile_field_name, + Res.string.backup_profile_field_display_name, + Res.string.backup_profile_field_about, + Res.string.backup_profile_field_picture, + Res.string.backup_profile_field_banner, + Res.string.backup_profile_field_website, + Res.string.backup_profile_field_nip05, + Res.string.backup_profile_field_lud16, + Res.string.backup_profile_field_lud06, + Res.string.backup_profile_field_clink_offer, + Res.string.backup_profile_field_pronouns, + Res.string.backup_profile_field_birthday, + Res.string.backup_profile_field_bot, + Res.string.backup_conflict_relay_read_write, + Res.string.backup_conflict_relay_read_only, + Res.string.backup_conflict_relay_write_only, + ) + /** Turns each event's own diff into typed, labelled groups. [str] resolves the labels. */ fun presentationOf( diff: EventDiff, - str: (Int) -> String, + str: (StringResource) -> String, ): DiffPresentation { val (groups, content) = when (diff) { @@ -258,31 +372,31 @@ fun presentationOf( val bot = diff.bot?.let { ValueChange(it.before?.toString(), it.after?.toString()) } listOf( fieldGroup( - R.string.backup_entry_profile_field, + Res.string.backup_entry_profile_field, listOf( - str(R.string.backup_profile_field_name) to diff.name, - str(R.string.backup_profile_field_display_name) to diff.displayName, - str(R.string.backup_profile_field_about) to diff.about, - str(R.string.backup_profile_field_picture) to diff.picture, - str(R.string.backup_profile_field_banner) to diff.banner, - str(R.string.backup_profile_field_website) to diff.website, - str(R.string.backup_profile_field_nip05) to diff.nip05, - str(R.string.backup_profile_field_lud16) to diff.lud16, - str(R.string.backup_profile_field_lud06) to diff.lud06, - str(R.string.backup_profile_field_clink_offer) to diff.clinkOffer, - str(R.string.backup_profile_field_pronouns) to diff.pronouns, - str(R.string.backup_profile_field_birthday) to birthday, - str(R.string.backup_profile_field_bot) to bot, + str(Res.string.backup_profile_field_name) to diff.name, + str(Res.string.backup_profile_field_display_name) to diff.displayName, + str(Res.string.backup_profile_field_about) to diff.about, + str(Res.string.backup_profile_field_picture) to diff.picture, + str(Res.string.backup_profile_field_banner) to diff.banner, + str(Res.string.backup_profile_field_website) to diff.website, + str(Res.string.backup_profile_field_nip05) to diff.nip05, + str(Res.string.backup_profile_field_lud16) to diff.lud16, + str(Res.string.backup_profile_field_lud06) to diff.lud06, + str(Res.string.backup_profile_field_clink_offer) to diff.clinkOffer, + str(Res.string.backup_profile_field_pronouns) to diff.pronouns, + str(Res.string.backup_profile_field_birthday) to birthday, + str(Res.string.backup_profile_field_bot) to bot, ), ), listGroup( - R.string.backup_entry_identity_claim, + Res.string.backup_entry_identity_claim, diff.identityClaims, { ReviewItem.Text(it.platformIdentity()) }, { ReviewItem.Text(it.after.platformIdentity(), arrow(it.before.proof, it.after.proof)) }, ), listGroup( - R.string.backup_entry_other, + Res.string.backup_entry_other, diff.otherFields, { ReviewItem.Text(it.first, clip(it.second)) }, { ReviewItem.Text(it.after.first, clip(it.before.second) + " → " + clip(it.after.second)) }, @@ -292,7 +406,7 @@ fun presentationOf( is ContactListDiff -> listOf( listGroup( - R.string.backup_entry_person, + Res.string.backup_entry_person, diff.follows, { ReviewItem.Person(it.pubKey, it.petname) }, { @@ -308,32 +422,32 @@ fun presentationOf( ) to ContentChange.NONE is MuteListDiff -> listOf( - listGroup(R.string.backup_entry_person, diff.publicMutes.only<UserTag>(), { ReviewItem.Person(it.pubKey) }), - listGroup(R.string.backup_entry_word, diff.publicMutes.only<WordTag>(), { ReviewItem.Text("\"" + it.word + "\"") }), - listGroup(R.string.backup_entry_hashtag, diff.publicMutes.only<HashtagTag>(), { ReviewItem.Text("#" + it.hashtag) }), - listGroup(R.string.backup_entry_thread, diff.publicMutes.only<EventTag>(), { ReviewItem.Thread(it.eventId) }), + listGroup(Res.string.backup_entry_person, diff.publicMutes.only<UserTag>(), { ReviewItem.Person(it.pubKey) }), + listGroup(Res.string.backup_entry_word, diff.publicMutes.only<WordTag>(), { ReviewItem.Text("\"" + it.word + "\"") }), + listGroup(Res.string.backup_entry_hashtag, diff.publicMutes.only<HashtagTag>(), { ReviewItem.Text("#" + it.hashtag) }), + listGroup(Res.string.backup_entry_thread, diff.publicMutes.only<EventTag>(), { ReviewItem.Thread(it.eventId) }), ) to diff.privateItems is AdvertisedRelayListDiff -> { val types = mapOf( - AdvertisedRelayType.BOTH to str(R.string.backup_conflict_relay_read_write), - AdvertisedRelayType.READ to str(R.string.backup_conflict_relay_read_only), - AdvertisedRelayType.WRITE to str(R.string.backup_conflict_relay_write_only), + AdvertisedRelayType.BOTH to str(Res.string.backup_conflict_relay_read_write), + AdvertisedRelayType.READ to str(Res.string.backup_conflict_relay_read_only), + AdvertisedRelayType.WRITE to str(Res.string.backup_conflict_relay_write_only), ) listOf( listGroup( - R.string.backup_entry_relay, + Res.string.backup_entry_relay, diff.relays, { ReviewItem.Relay(it.relayUrl, types[it.type]) }, { ReviewItem.Relay(it.after.relayUrl, arrow(types[it.before.type], types[it.after.type])) }, ), ) to ContentChange.NONE } - is RelayListDiff -> listOf(listGroup(R.string.backup_entry_relay, diff.relays, { ReviewItem.Relay(it) })) to diff.privateRelays + is RelayListDiff -> listOf(listGroup(Res.string.backup_entry_relay, diff.relays, { ReviewItem.Relay(it) })) to diff.privateRelays is ChannelListDiff -> listOf( listGroup( - R.string.backup_entry_public_chat, + Res.string.backup_entry_public_chat, diff.channels, { ReviewItem.PublicChat(it.eventId) }, { ReviewItem.PublicChat(it.after.eventId, arrow(it.before.relay?.url, it.after.relay?.url)) }, @@ -342,28 +456,28 @@ fun presentationOf( is CommunityListDiff -> listOf( listGroup( - R.string.backup_entry_community, + Res.string.backup_entry_community, diff.communities, { ReviewItem.Addressable(it.address) }, { ReviewItem.Addressable(it.after.address, arrow(it.before.relayHint?.url, it.after.relayHint?.url)) }, ), ) to diff.privateItems - is HashtagListDiff -> listOf(listGroup(R.string.backup_entry_hashtag, diff.hashtags, { ReviewItem.Text("#$it") })) to diff.privateItems - is GeohashListDiff -> listOf(listGroup(R.string.backup_entry_location, diff.geohashes, { ReviewItem.Text(it) })) to diff.privateItems + is HashtagListDiff -> listOf(listGroup(Res.string.backup_entry_hashtag, diff.hashtags, { ReviewItem.Text("#$it") })) to diff.privateItems + is GeohashListDiff -> listOf(listGroup(Res.string.backup_entry_location, diff.geohashes, { ReviewItem.Text(it) })) to diff.privateItems is FavoriteAlgoFeedsListDiff -> listOf( listGroup( - R.string.backup_entry_algo_feed, + Res.string.backup_entry_algo_feed, diff.feeds, { ReviewItem.Addressable(it.address) }, { ReviewItem.Addressable(it.after.address, arrow(it.before.relayHint?.url, it.after.relayHint?.url)) }, ), ) to diff.privateItems - is EphemeralChatListDiff -> listOf(listGroup(R.string.backup_entry_chat_room, diff.rooms, { ReviewItem.ChatRoom(it) })) to diff.privateItems + is EphemeralChatListDiff -> listOf(listGroup(Res.string.backup_entry_chat_room, diff.rooms, { ReviewItem.ChatRoom(it) })) to diff.privateItems is SimpleGroupListDiff -> listOf( listGroup( - R.string.backup_entry_relay_group, + Res.string.backup_entry_relay_group, diff.groups, { ReviewItem.Text(it.name ?: it.groupId, it.relayUrl) }, { ReviewItem.Text(it.after.name ?: it.after.groupId, arrow(it.before.name, it.after.name)) }, @@ -372,7 +486,7 @@ fun presentationOf( is TrustProviderListDiff -> listOf( listGroup( - R.string.backup_entry_trust_provider, + Res.string.backup_entry_trust_provider, diff.providers, { ReviewItem.Person(it.pubkey, it.service.type) }, { ReviewItem.Person(it.after.pubkey, it.after.service.type + ": " + arrow(it.before.relayUrl.url, it.after.relayUrl.url)) }, @@ -381,19 +495,19 @@ fun presentationOf( is NutzapInfoDiff -> listOf( listGroup( - R.string.backup_entry_mint, + Res.string.backup_entry_mint, diff.mints, { ReviewItem.Text(it.mintUrl, it.units.joinToString().ifEmpty { null }) }, { ReviewItem.Text(it.after.mintUrl, arrow(it.before.units.joinToString(), it.after.units.joinToString())) }, ), - listGroup(R.string.backup_entry_relay, diff.relays, { ReviewItem.Relay(it) }), + listGroup(Res.string.backup_entry_relay, diff.relays, { ReviewItem.Relay(it) }), nutzapKeyGroup(diff.p2pkPubkey), ) to ContentChange.NONE is PaymentTargetsDiff -> listOf( - listGroup(R.string.backup_entry_payment_target, diff.targets, { ReviewItem.Text(it.type, clip(it.authority)) }), + listGroup(Res.string.backup_entry_payment_target, diff.targets, { ReviewItem.Text(it.type, clip(it.authority)) }), ) to ContentChange.NONE - is Bolt12OfferListDiff -> listOf(listGroup(R.string.backup_entry_offer, diff.offers, { ReviewItem.Text(clip(it)) })) to ContentChange.NONE + is Bolt12OfferListDiff -> listOf(listGroup(Res.string.backup_entry_offer, diff.offers, { ReviewItem.Text(clip(it)) })) to ContentChange.NONE is CashuWalletDiff -> emptyList<DiffGroup>() to diff.wallet is ConcordCommunityListDiff -> emptyList<DiffGroup>() to diff.communities is AppSpecificDataDiff -> emptyList<DiffGroup>() to diff.data @@ -404,13 +518,13 @@ fun presentationOf( private fun nutzapKeyGroup(change: ValueChange<HexKey>?): DiffGroup { val none = emptyList<ReviewItem>() - if (change == null) return DiffGroup(R.string.backup_entry_nutzap_key, none, none, none) + if (change == null) return DiffGroup(Res.string.backup_entry_nutzap_key, none, none, none) val before = change.before val after = change.after return when { - before != null && after == null -> DiffGroup(R.string.backup_entry_nutzap_key, listOf(ReviewItem.Text(before)), none, none) - before == null && after != null -> DiffGroup(R.string.backup_entry_nutzap_key, none, listOf(ReviewItem.Text(after)), none) - else -> DiffGroup(R.string.backup_entry_nutzap_key, none, none, listOf(ReviewItem.Text(arrow(before, after)))) + before != null && after == null -> DiffGroup(Res.string.backup_entry_nutzap_key, listOf(ReviewItem.Text(before)), none, none) + before == null && after != null -> DiffGroup(Res.string.backup_entry_nutzap_key, none, listOf(ReviewItem.Text(after)), none) + else -> DiffGroup(Res.string.backup_entry_nutzap_key, none, none, listOf(ReviewItem.Text(arrow(before, after)))) } } @@ -422,64 +536,64 @@ fun clip(text: String?): String { return if (oneLine.length > MAX_VALUE_LENGTH) oneLine.take(MAX_VALUE_LENGTH) + "…" else oneLine } -fun eventTypeName(type: BackupEventType): Int = +fun eventTypeName(type: BackupEventType): StringResource = when (type) { - BackupEventType.PROFILE -> R.string.backup_type_profile - BackupEventType.FOLLOW_LIST -> R.string.backup_type_follow_list - BackupEventType.MUTE_LIST -> R.string.backup_type_mute_list - BackupEventType.OUTBOX_INBOX_RELAYS -> R.string.backup_type_outbox_inbox_relays - BackupEventType.DM_RELAYS -> R.string.backup_type_dm_relays - BackupEventType.KEY_PACKAGE_RELAYS -> R.string.backup_type_key_package_relays - BackupEventType.SEARCH_RELAYS -> R.string.backup_type_search_relays - BackupEventType.INDEXER_RELAYS -> R.string.backup_type_indexer_relays - BackupEventType.RELAY_FEEDS -> R.string.backup_type_relay_feeds - BackupEventType.BLOCKED_RELAYS -> R.string.backup_type_blocked_relays - BackupEventType.TRUSTED_RELAYS -> R.string.backup_type_trusted_relays - BackupEventType.PRIVATE_OUTBOX_RELAYS -> R.string.backup_type_private_outbox_relays - BackupEventType.APP_SETTINGS -> R.string.backup_type_app_settings - BackupEventType.PUBLIC_CHATS -> R.string.backup_type_public_chats - BackupEventType.COMMUNITIES -> R.string.backup_type_communities - BackupEventType.HASHTAGS -> R.string.backup_type_hashtags - BackupEventType.GEOHASHES -> R.string.backup_type_geohashes - BackupEventType.FAVORITE_ALGO_FEEDS -> R.string.backup_type_favorite_algo_feeds - BackupEventType.EPHEMERAL_CHATS -> R.string.backup_type_ephemeral_chats - BackupEventType.RELAY_GROUPS -> R.string.backup_type_relay_groups - BackupEventType.CONCORD_COMMUNITIES -> R.string.backup_type_concord_communities - BackupEventType.TRUST_PROVIDERS -> R.string.backup_type_trust_providers - BackupEventType.CASHU_WALLET -> R.string.backup_type_cashu_wallet - BackupEventType.NUTZAP_INFO -> R.string.backup_type_nutzap_info - BackupEventType.PAYMENT_TARGETS -> R.string.backup_type_payment_targets - BackupEventType.BOLT12_OFFERS -> R.string.backup_type_bolt12_offers - BackupEventType.OTHER -> R.string.backup_type_other + BackupEventType.PROFILE -> Res.string.backup_type_profile + BackupEventType.FOLLOW_LIST -> Res.string.backup_type_follow_list + BackupEventType.MUTE_LIST -> Res.string.backup_type_mute_list + BackupEventType.OUTBOX_INBOX_RELAYS -> Res.string.backup_type_outbox_inbox_relays + BackupEventType.DM_RELAYS -> Res.string.backup_type_dm_relays + BackupEventType.KEY_PACKAGE_RELAYS -> Res.string.backup_type_key_package_relays + BackupEventType.SEARCH_RELAYS -> Res.string.backup_type_search_relays + BackupEventType.INDEXER_RELAYS -> Res.string.backup_type_indexer_relays + BackupEventType.RELAY_FEEDS -> Res.string.backup_type_relay_feeds + BackupEventType.BLOCKED_RELAYS -> Res.string.backup_type_blocked_relays + BackupEventType.TRUSTED_RELAYS -> Res.string.backup_type_trusted_relays + BackupEventType.PRIVATE_OUTBOX_RELAYS -> Res.string.backup_type_private_outbox_relays + BackupEventType.APP_SETTINGS -> Res.string.backup_type_app_settings + BackupEventType.PUBLIC_CHATS -> Res.string.backup_type_public_chats + BackupEventType.COMMUNITIES -> Res.string.backup_type_communities + BackupEventType.HASHTAGS -> Res.string.backup_type_hashtags + BackupEventType.GEOHASHES -> Res.string.backup_type_geohashes + BackupEventType.FAVORITE_ALGO_FEEDS -> Res.string.backup_type_favorite_algo_feeds + BackupEventType.EPHEMERAL_CHATS -> Res.string.backup_type_ephemeral_chats + BackupEventType.RELAY_GROUPS -> Res.string.backup_type_relay_groups + BackupEventType.CONCORD_COMMUNITIES -> Res.string.backup_type_concord_communities + BackupEventType.TRUST_PROVIDERS -> Res.string.backup_type_trust_providers + BackupEventType.CASHU_WALLET -> Res.string.backup_type_cashu_wallet + BackupEventType.NUTZAP_INFO -> Res.string.backup_type_nutzap_info + BackupEventType.PAYMENT_TARGETS -> Res.string.backup_type_payment_targets + BackupEventType.BOLT12_OFFERS -> Res.string.backup_type_bolt12_offers + BackupEventType.OTHER -> Res.string.backup_type_other } -fun eventTypeExplainer(type: BackupEventType): Int = +fun eventTypeExplainer(type: BackupEventType): StringResource = when (type) { - BackupEventType.PROFILE -> R.string.backup_type_profile_explainer - BackupEventType.FOLLOW_LIST -> R.string.backup_type_follow_list_explainer - BackupEventType.MUTE_LIST -> R.string.backup_type_mute_list_explainer - BackupEventType.OUTBOX_INBOX_RELAYS -> R.string.backup_type_outbox_inbox_relays_explainer - BackupEventType.DM_RELAYS -> R.string.backup_type_dm_relays_explainer - BackupEventType.KEY_PACKAGE_RELAYS -> R.string.backup_type_key_package_relays_explainer - BackupEventType.SEARCH_RELAYS -> R.string.backup_type_search_relays_explainer - BackupEventType.INDEXER_RELAYS -> R.string.backup_type_indexer_relays_explainer - BackupEventType.RELAY_FEEDS -> R.string.backup_type_relay_feeds_explainer - BackupEventType.BLOCKED_RELAYS -> R.string.backup_type_blocked_relays_explainer - BackupEventType.TRUSTED_RELAYS -> R.string.backup_type_trusted_relays_explainer - BackupEventType.PRIVATE_OUTBOX_RELAYS -> R.string.backup_type_private_outbox_relays_explainer - BackupEventType.APP_SETTINGS -> R.string.backup_type_app_settings_explainer - BackupEventType.PUBLIC_CHATS -> R.string.backup_type_public_chats_explainer - BackupEventType.COMMUNITIES -> R.string.backup_type_communities_explainer - BackupEventType.HASHTAGS -> R.string.backup_type_hashtags_explainer - BackupEventType.GEOHASHES -> R.string.backup_type_geohashes_explainer - BackupEventType.FAVORITE_ALGO_FEEDS -> R.string.backup_type_favorite_algo_feeds_explainer - BackupEventType.EPHEMERAL_CHATS -> R.string.backup_type_ephemeral_chats_explainer - BackupEventType.RELAY_GROUPS -> R.string.backup_type_relay_groups_explainer - BackupEventType.CONCORD_COMMUNITIES -> R.string.backup_type_concord_communities_explainer - BackupEventType.TRUST_PROVIDERS -> R.string.backup_type_trust_providers_explainer - BackupEventType.CASHU_WALLET -> R.string.backup_type_cashu_wallet_explainer - BackupEventType.NUTZAP_INFO -> R.string.backup_type_nutzap_info_explainer - BackupEventType.PAYMENT_TARGETS -> R.string.backup_type_payment_targets_explainer - BackupEventType.BOLT12_OFFERS -> R.string.backup_type_bolt12_offers_explainer - BackupEventType.OTHER -> R.string.backup_type_other_explainer + BackupEventType.PROFILE -> Res.string.backup_type_profile_explainer + BackupEventType.FOLLOW_LIST -> Res.string.backup_type_follow_list_explainer + BackupEventType.MUTE_LIST -> Res.string.backup_type_mute_list_explainer + BackupEventType.OUTBOX_INBOX_RELAYS -> Res.string.backup_type_outbox_inbox_relays_explainer + BackupEventType.DM_RELAYS -> Res.string.backup_type_dm_relays_explainer + BackupEventType.KEY_PACKAGE_RELAYS -> Res.string.backup_type_key_package_relays_explainer + BackupEventType.SEARCH_RELAYS -> Res.string.backup_type_search_relays_explainer + BackupEventType.INDEXER_RELAYS -> Res.string.backup_type_indexer_relays_explainer + BackupEventType.RELAY_FEEDS -> Res.string.backup_type_relay_feeds_explainer + BackupEventType.BLOCKED_RELAYS -> Res.string.backup_type_blocked_relays_explainer + BackupEventType.TRUSTED_RELAYS -> Res.string.backup_type_trusted_relays_explainer + BackupEventType.PRIVATE_OUTBOX_RELAYS -> Res.string.backup_type_private_outbox_relays_explainer + BackupEventType.APP_SETTINGS -> Res.string.backup_type_app_settings_explainer + BackupEventType.PUBLIC_CHATS -> Res.string.backup_type_public_chats_explainer + BackupEventType.COMMUNITIES -> Res.string.backup_type_communities_explainer + BackupEventType.HASHTAGS -> Res.string.backup_type_hashtags_explainer + BackupEventType.GEOHASHES -> Res.string.backup_type_geohashes_explainer + BackupEventType.FAVORITE_ALGO_FEEDS -> Res.string.backup_type_favorite_algo_feeds_explainer + BackupEventType.EPHEMERAL_CHATS -> Res.string.backup_type_ephemeral_chats_explainer + BackupEventType.RELAY_GROUPS -> Res.string.backup_type_relay_groups_explainer + BackupEventType.CONCORD_COMMUNITIES -> Res.string.backup_type_concord_communities_explainer + BackupEventType.TRUST_PROVIDERS -> Res.string.backup_type_trust_providers_explainer + BackupEventType.CASHU_WALLET -> Res.string.backup_type_cashu_wallet_explainer + BackupEventType.NUTZAP_INFO -> Res.string.backup_type_nutzap_info_explainer + BackupEventType.PAYMENT_TARGETS -> Res.string.backup_type_payment_targets_explainer + BackupEventType.BOLT12_OFFERS -> Res.string.backup_type_bolt12_offers_explainer + BackupEventType.OTHER -> Res.string.backup_type_other_explainer } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictReviewScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictReviewScreen.kt index 8b5d7b0150..6497b54846 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictReviewScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictReviewScreen.kt @@ -59,11 +59,34 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.model.backups.BackupEventType import com.vitorpamplona.amethyst.commons.model.backups.ReplaceableBackupConflict import com.vitorpamplona.amethyst.commons.model.navigation.Route import com.vitorpamplona.amethyst.commons.model.navigation.routeFor +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.backup_action_block_again +import com.vitorpamplona.amethyst.commons.resources.backup_action_keep_count +import com.vitorpamplona.amethyst.commons.resources.backup_action_rejoin +import com.vitorpamplona.amethyst.commons.resources.backup_action_remute +import com.vitorpamplona.amethyst.commons.resources.backup_action_restore_count +import com.vitorpamplona.amethyst.commons.resources.backup_action_restore_feeds +import com.vitorpamplona.amethyst.commons.resources.backup_action_restore_places +import com.vitorpamplona.amethyst.commons.resources.backup_action_restore_profile +import com.vitorpamplona.amethyst.commons.resources.backup_action_restore_providers +import com.vitorpamplona.amethyst.commons.resources.backup_action_restore_relays +import com.vitorpamplona.amethyst.commons.resources.backup_action_restore_topics +import com.vitorpamplona.amethyst.commons.resources.backup_action_restore_wallet +import com.vitorpamplona.amethyst.commons.resources.backup_conflict_added +import com.vitorpamplona.amethyst.commons.resources.backup_conflict_changed +import com.vitorpamplona.amethyst.commons.resources.backup_conflict_keep_new +import com.vitorpamplona.amethyst.commons.resources.backup_conflict_private_added +import com.vitorpamplona.amethyst.commons.resources.backup_conflict_private_changed +import com.vitorpamplona.amethyst.commons.resources.backup_conflict_private_cleared +import com.vitorpamplona.amethyst.commons.resources.backup_conflict_removed +import com.vitorpamplona.amethyst.commons.resources.backup_conflict_restore_mine +import com.vitorpamplona.amethyst.commons.resources.backup_review_changed_by_other_app +import com.vitorpamplona.amethyst.commons.resources.backup_review_title_cleared +import com.vitorpamplona.amethyst.commons.resources.backup_review_title_updated import com.vitorpamplona.amethyst.commons.ui.insets.imePaddingSafe import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton @@ -97,22 +120,23 @@ import com.vitorpamplona.quartz.nip60Cashu.wallet.CashuWalletDiff import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListDiff import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListDiff import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListDiff +import org.jetbrains.compose.resources.StringResource /** A row of the review list, flattened so hundreds of entries scroll lazily. */ @Immutable internal sealed interface ReviewRow { class Section( - val titleRes: Int, + val titleRes: StringResource, val count: Int, val tone: Tone, ) : ReviewRow class Note( - val textRes: Int, + val textRes: StringResource, ) : ReviewRow class Group( - val labelRes: Int, + val labelRes: StringResource, ) : ReviewRow class Entry( @@ -124,15 +148,15 @@ internal enum class Tone { REMOVED, ADDED, CHANGED } internal fun buildRows( presentation: DiffPresentation, - skipGroups: Set<Int> = emptySet(), + skipGroups: Set<StringResource> = emptySet(), ): List<ReviewRow> { val rows = mutableListOf<ReviewRow>() fun section( - titleRes: Int, + titleRes: StringResource, count: Int, tone: Tone, - contentNote: Int?, + contentNote: StringResource?, items: (DiffGroup) -> List<ReviewItem>, ) { if (count == 0 && contentNote == null) return @@ -149,9 +173,9 @@ internal fun buildRows( } val content = presentation.content - section(R.string.backup_conflict_removed, presentation.removedCount, Tone.REMOVED, R.string.backup_conflict_private_cleared.takeIf { content == ContentChange.CLEARED }) { it.removed } - section(R.string.backup_conflict_added, presentation.addedCount, Tone.ADDED, R.string.backup_conflict_private_added.takeIf { content == ContentChange.ADDED }) { it.added } - section(R.string.backup_conflict_changed, presentation.changedCount, Tone.CHANGED, R.string.backup_conflict_private_changed.takeIf { content == ContentChange.CHANGED }) { it.changed } + section(Res.string.backup_conflict_removed, presentation.removedCount, Tone.REMOVED, Res.string.backup_conflict_private_cleared.takeIf { content == ContentChange.CLEARED }) { it.removed } + section(Res.string.backup_conflict_added, presentation.addedCount, Tone.ADDED, Res.string.backup_conflict_private_added.takeIf { content == ContentChange.ADDED }) { it.added } + section(Res.string.backup_conflict_changed, presentation.changedCount, Tone.CHANGED, Res.string.backup_conflict_private_changed.takeIf { content == ContentChange.CHANGED }) { it.changed } return rows } @@ -268,7 +292,7 @@ internal fun LazyListScope.genericDiffItems( } @Composable -internal fun GroupLabel(labelRes: Int) { +internal fun GroupLabel(labelRes: StringResource) { Text( text = stringRes(labelRes), style = MaterialTheme.typography.labelLarge, @@ -339,7 +363,7 @@ internal fun eventTitle(conflict: ReplaceableBackupConflict): String { val name = stringRes(eventTypeName(conflict.eventType)).replaceFirstChar { it.uppercase() } // Says what happened to it, not just what it is: "Community list updated". val emptied = conflict.incoming.tags.isEmpty() && conflict.incoming.content.isEmpty() - return stringRes(if (emptied) R.string.backup_review_title_cleared else R.string.backup_review_title_updated, name) + return stringRes(if (emptied) Res.string.backup_review_title_cleared else Res.string.backup_review_title_updated, name) } /** The two choices, worded for what they do to this event: "Keep 120" / "Restore 523". */ @@ -348,48 +372,48 @@ private fun actionLabels(conflict: ReplaceableBackupConflict): Pair<String, Stri when (val diff = conflict.diff) { is ContactListDiff -> { val (saved, new) = conflict.followCounts - stringRes(R.string.backup_action_keep_count, new.toString()) to stringRes(R.string.backup_action_restore_count, saved.toString()) + stringRes(Res.string.backup_action_keep_count, new.toString()) to stringRes(Res.string.backup_action_restore_count, saved.toString()) } is MuteListDiff -> { val removed = diff.publicMutes.removed.size - stringRes(R.string.backup_conflict_keep_new) to - if (removed > 0) stringRes(R.string.backup_action_remute, removed.toString()) else stringRes(R.string.backup_conflict_restore_mine) + stringRes(Res.string.backup_conflict_keep_new) to + if (removed > 0) stringRes(Res.string.backup_action_remute, removed.toString()) else stringRes(Res.string.backup_conflict_restore_mine) } is SimpleGroupListDiff -> { val removed = diff.groups.removed.size - stringRes(R.string.backup_conflict_keep_new) to - if (removed > 0) stringRes(R.string.backup_action_rejoin, removed.toString()) else stringRes(R.string.backup_conflict_restore_mine) + stringRes(Res.string.backup_conflict_keep_new) to + if (removed > 0) stringRes(Res.string.backup_action_rejoin, removed.toString()) else stringRes(Res.string.backup_conflict_restore_mine) } - is MetadataDiff -> stringRes(R.string.backup_conflict_keep_new) to stringRes(R.string.backup_action_restore_profile) + is MetadataDiff -> stringRes(Res.string.backup_conflict_keep_new) to stringRes(Res.string.backup_action_restore_profile) is RelayListDiff -> if (conflict.eventType == BackupEventType.BLOCKED_RELAYS && diff.relays.removed.isNotEmpty()) { - stringRes(R.string.backup_conflict_keep_new) to + stringRes(Res.string.backup_conflict_keep_new) to stringRes( - R.string.backup_action_block_again, + Res.string.backup_action_block_again, diff.relays.removed.size .toString(), ) } else { - stringRes(R.string.backup_conflict_keep_new) to stringRes(R.string.backup_action_restore_relays) + stringRes(Res.string.backup_conflict_keep_new) to stringRes(Res.string.backup_action_restore_relays) } - is AdvertisedRelayListDiff -> stringRes(R.string.backup_conflict_keep_new) to stringRes(R.string.backup_action_restore_relays) + is AdvertisedRelayListDiff -> stringRes(Res.string.backup_conflict_keep_new) to stringRes(Res.string.backup_action_restore_relays) is ChannelListDiff -> rejoinLabels(diff.channels.removed.size) is CommunityListDiff -> rejoinLabels(diff.communities.removed.size) is EphemeralChatListDiff -> rejoinLabels(diff.rooms.removed.size) - is FavoriteAlgoFeedsListDiff -> stringRes(R.string.backup_conflict_keep_new) to stringRes(R.string.backup_action_restore_feeds) - is HashtagListDiff -> stringRes(R.string.backup_conflict_keep_new) to stringRes(R.string.backup_action_restore_topics) + is FavoriteAlgoFeedsListDiff -> stringRes(Res.string.backup_conflict_keep_new) to stringRes(Res.string.backup_action_restore_feeds) + is HashtagListDiff -> stringRes(Res.string.backup_conflict_keep_new) to stringRes(Res.string.backup_action_restore_topics) // Geohashes are places, not topics: the screen counts "places gone" under "Followed // locations", so the button has to agree with the page it sits on. - is GeohashListDiff -> stringRes(R.string.backup_conflict_keep_new) to stringRes(R.string.backup_action_restore_places) - is TrustProviderListDiff -> stringRes(R.string.backup_conflict_keep_new) to stringRes(R.string.backup_action_restore_providers) - is CashuWalletDiff -> stringRes(R.string.backup_conflict_keep_new) to stringRes(R.string.backup_action_restore_wallet) - else -> stringRes(R.string.backup_conflict_keep_new) to stringRes(R.string.backup_conflict_restore_mine) + is GeohashListDiff -> stringRes(Res.string.backup_conflict_keep_new) to stringRes(Res.string.backup_action_restore_places) + is TrustProviderListDiff -> stringRes(Res.string.backup_conflict_keep_new) to stringRes(Res.string.backup_action_restore_providers) + is CashuWalletDiff -> stringRes(Res.string.backup_conflict_keep_new) to stringRes(Res.string.backup_action_restore_wallet) + else -> stringRes(Res.string.backup_conflict_keep_new) to stringRes(Res.string.backup_conflict_restore_mine) } @Composable private fun rejoinLabels(removed: Int): Pair<String, String> = - stringRes(R.string.backup_conflict_keep_new) to - if (removed > 0) stringRes(R.string.backup_action_rejoin, removed.toString()) else stringRes(R.string.backup_conflict_restore_mine) + stringRes(Res.string.backup_conflict_keep_new) to + if (removed > 0) stringRes(Res.string.backup_action_rejoin, removed.toString()) else stringRes(Res.string.backup_conflict_restore_mine) /** When the other app changed it, and what the event is for. */ @Composable @@ -399,7 +423,7 @@ private fun BackupConflictIntro( ) { Column(modifier, verticalArrangement = Arrangement.spacedBy(2.dp)) { Text( - text = stringRes(R.string.backup_review_changed_by_other_app, timeAgoNoDot(conflict.cause.createdAt)), + text = stringRes(Res.string.backup_review_changed_by_other_app, timeAgoNoDot(conflict.cause.createdAt)), style = MaterialTheme.typography.labelLarge, color = conflictTones().removed, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnComposer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnComposer.kt index 0e19b487cb..c892f3d020 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnComposer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnComposer.kt @@ -42,12 +42,13 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp -import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.chats.ui.ThinSendButton import com.vitorpamplona.amethyst.commons.model.cordnGroups.CordnGroupChatroom import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiSuggestionState import com.vitorpamplona.amethyst.commons.nip30CustomEmojis.ui.ShowEmojiSuggestionList +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.cordn_composer_hint import com.vitorpamplona.amethyst.commons.ui.text.currentWord import com.vitorpamplona.amethyst.commons.ui.text.replaceCurrentWord import com.vitorpamplona.amethyst.commons.ui.theme.EditFieldBorder @@ -233,7 +234,7 @@ internal fun CordnComposer( shape = EditFieldBorder, placeholder = { Text( - text = stringRes(R.string.cordn_composer_hint), + text = stringRes(Res.string.cordn_composer_hint), color = MaterialTheme.colorScheme.placeholderText, ) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnCreateGroupScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnCreateGroupScreen.kt index 6deedde2d4..0b78b0ea5e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnCreateGroupScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnCreateGroupScreen.kt @@ -55,11 +55,9 @@ import androidx.compose.runtime.rememberCoroutineScope 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.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.cordn.CoordinatorConfig import com.vitorpamplona.amethyst.commons.cordn.DiscoveredCoordinator import com.vitorpamplona.amethyst.commons.cordn.GroupExposure @@ -69,6 +67,55 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.navigation.Route import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.back +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_discover_failed +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_discover_none +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_discover_seen +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_discover_seen_on +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_discover_unheard +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_hide_older +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_relays_more +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_show_all +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_show_fewer +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_show_older +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_stale_note +import com.vitorpamplona.amethyst.commons.resources.cordn_create_action +import com.vitorpamplona.amethyst.commons.resources.cordn_create_action_invite +import com.vitorpamplona.amethyst.commons.resources.cordn_create_admin_count +import com.vitorpamplona.amethyst.commons.resources.cordn_create_admin_only_me_on +import com.vitorpamplona.amethyst.commons.resources.cordn_create_coordinator_change +import com.vitorpamplona.amethyst.commons.resources.cordn_create_coordinator_discover +import com.vitorpamplona.amethyst.commons.resources.cordn_create_coordinator_hide +import com.vitorpamplona.amethyst.commons.resources.cordn_create_coordinator_new +import com.vitorpamplona.amethyst.commons.resources.cordn_create_coordinator_pubkey +import com.vitorpamplona.amethyst.commons.resources.cordn_create_coordinator_relays +import com.vitorpamplona.amethyst.commons.resources.cordn_create_coverage_all +import com.vitorpamplona.amethyst.commons.resources.cordn_create_coverage_partial +import com.vitorpamplona.amethyst.commons.resources.cordn_create_coverage_unknown +import com.vitorpamplona.amethyst.commons.resources.cordn_create_description +import com.vitorpamplona.amethyst.commons.resources.cordn_create_egalitarian +import com.vitorpamplona.amethyst.commons.resources.cordn_create_egalitarian_note +import com.vitorpamplona.amethyst.commons.resources.cordn_create_explainer +import com.vitorpamplona.amethyst.commons.resources.cordn_create_failed +import com.vitorpamplona.amethyst.commons.resources.cordn_create_name +import com.vitorpamplona.amethyst.commons.resources.cordn_create_no_coordinator +import com.vitorpamplona.amethyst.commons.resources.cordn_create_outcome_explainer +import com.vitorpamplona.amethyst.commons.resources.cordn_create_outcome_failed +import com.vitorpamplona.amethyst.commons.resources.cordn_create_outcome_link +import com.vitorpamplona.amethyst.commons.resources.cordn_create_outcome_no_welcome +import com.vitorpamplona.amethyst.commons.resources.cordn_create_outcome_open +import com.vitorpamplona.amethyst.commons.resources.cordn_create_outcome_skipped +import com.vitorpamplona.amethyst.commons.resources.cordn_create_outcome_title +import com.vitorpamplona.amethyst.commons.resources.cordn_create_outcome_waiting +import com.vitorpamplona.amethyst.commons.resources.cordn_create_people_none +import com.vitorpamplona.amethyst.commons.resources.cordn_create_step_admins +import com.vitorpamplona.amethyst.commons.resources.cordn_create_step_name +import com.vitorpamplona.amethyst.commons.resources.cordn_create_step_people +import com.vitorpamplona.amethyst.commons.resources.cordn_create_step_where +import com.vitorpamplona.amethyst.commons.resources.cordn_create_title +import com.vitorpamplona.amethyst.commons.resources.cordn_create_unreachable_count +import com.vitorpamplona.amethyst.commons.resources.cordn_group_unavailable +import com.vitorpamplona.amethyst.commons.resources.cordn_group_unavailable_detail +import com.vitorpamplona.amethyst.commons.resources.cordn_member_count import com.vitorpamplona.amethyst.commons.ui.components.EmptyState import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.model.cordn.CordnCoverage @@ -131,7 +178,7 @@ fun CordnCreateGroupScreen( Icon(MaterialSymbols.AutoMirrored.ArrowBack, contentDescription = stringRes(Res.string.back)) } }, - title = { Text(stringRes(R.string.cordn_create_title)) }, + title = { Text(stringRes(Res.string.cordn_create_title)) }, ) }, ) { padding -> @@ -139,8 +186,8 @@ fun CordnCreateGroupScreen( // The app's own empty state, centred and titled, rather than a // sentence stranded in the top-left corner. EmptyState( - title = stringRes(R.string.cordn_group_unavailable), - description = stringRes(R.string.cordn_group_unavailable_detail), + title = stringRes(Res.string.cordn_group_unavailable), + description = stringRes(Res.string.cordn_group_unavailable_detail), modifier = Modifier.padding(padding), ) return@Scaffold @@ -164,14 +211,14 @@ fun CordnCreateGroupScreen( val draft = rememberCordnGroupDraft(accountViewModel) var creation by remember { mutableStateOf<CordnGroupCreation?>(null) } var error by remember { mutableStateOf<String?>(null) } - val failureFallback = stringRes(R.string.cordn_create_failed) + val failureFallback = stringRes(Res.string.cordn_create_failed) var busy by remember { mutableStateOf(false) } // Coordinators this account has never used, from their CEP-6 // announcements. Not added to the account by looking: picking one here // is what commits to it, and `createGroup` opens the session. var discovering by remember { mutableStateOf(false) } - val discoverFailed = stringRes(R.string.cordn_coordinators_discover_failed) + val discoverFailed = stringRes(Res.string.cordn_coordinators_discover_failed) // Offers this account already holds are not offers; they are the choices // above, and listing them twice would let the same coordinator be picked @@ -222,29 +269,29 @@ fun CordnCreateGroupScreen( verticalArrangement = Arrangement.spacedBy(12.dp), ) { Text( - text = stringRes(R.string.cordn_create_explainer), + text = stringRes(Res.string.cordn_create_explainer), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) - StepHeading(1, stringRes(R.string.cordn_create_step_name)) + StepHeading(1, stringRes(Res.string.cordn_create_step_name)) OutlinedTextField( value = draft.name, onValueChange = { draft.name = it }, - label = { Text(stringRes(R.string.cordn_create_name)) }, + label = { Text(stringRes(Res.string.cordn_create_name)) }, singleLine = true, modifier = Modifier.fillMaxWidth(), ) OutlinedTextField( value = draft.description, onValueChange = { draft.description = it }, - label = { Text(stringRes(R.string.cordn_create_description)) }, + label = { Text(stringRes(Res.string.cordn_create_description)) }, singleLine = false, modifier = Modifier.fillMaxWidth(), ) - StepHeading(2, stringRes(R.string.cordn_create_step_people)) + StepHeading(2, stringRes(Res.string.cordn_create_step_people)) RosterSummary( roster = draft.roster, @@ -254,7 +301,7 @@ fun CordnCreateGroupScreen( nav = nav, ) - StepHeading(3, stringRes(R.string.cordn_create_step_where)) + StepHeading(3, stringRes(Res.string.cordn_create_step_where)) // Collapsed to the one coordinator that will be used, because with a // draft.roster in hand there is usually nothing left to decide. The whole @@ -326,9 +373,9 @@ fun CordnCreateGroupScreen( TextButton(onClick = { draft.showAllLive = !draft.showAllLive }) { Text( if (draft.showAllLive) { - stringRes(R.string.cordn_coordinators_show_fewer) + stringRes(Res.string.cordn_coordinators_show_fewer) } else { - stringRes(R.string.cordn_coordinators_show_all, live.size) + stringRes(Res.string.cordn_coordinators_show_all, live.size) }, ) } @@ -338,9 +385,9 @@ fun CordnCreateGroupScreen( TextButton(onClick = { draft.showStale = !draft.showStale }) { Text( if (draft.showStale) { - stringRes(R.string.cordn_coordinators_hide_older) + stringRes(Res.string.cordn_coordinators_hide_older) } else { - stringRes(R.string.cordn_coordinators_show_older, stale.size) + stringRes(Res.string.cordn_coordinators_show_older, stale.size) }, ) } @@ -348,7 +395,7 @@ fun CordnCreateGroupScreen( if (draft.showStale && stale.isNotEmpty()) { Text( - text = stringRes(R.string.cordn_coordinators_stale_note), + text = stringRes(Res.string.cordn_coordinators_stale_note), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -372,7 +419,7 @@ fun CordnCreateGroupScreen( } CoordinatorChoice( - label = stringRes(R.string.cordn_create_coordinator_new), + label = stringRes(Res.string.cordn_create_coordinator_new), pubKey = null, selected = draft.selected == null, onSelect = { @@ -402,20 +449,20 @@ fun CordnCreateGroupScreen( }, enabled = !discovering && !busy, ) { - Text(stringRes(R.string.cordn_create_coordinator_discover)) + Text(stringRes(Res.string.cordn_create_coordinator_discover)) } draft.discovered?.takeIf { offers.isEmpty() }?.let { result -> Text( text = if (result.unreachable.isEmpty()) { - stringRes(R.string.cordn_coordinators_discover_none) + stringRes(Res.string.cordn_coordinators_discover_none) } else { // "Nobody is announcing" and "we were not told" are // different answers and only one of them is final. // Reporting the first for the second sends someone // off to paste a pubkey by hand over a timeout. - stringRes(R.string.cordn_coordinators_discover_unheard, result.unreachable.size) + stringRes(Res.string.cordn_coordinators_discover_unheard, result.unreachable.size) }, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, @@ -433,7 +480,7 @@ fun CordnCreateGroupScreen( draft.userPicked = true error = null }, - label = { Text(stringRes(R.string.cordn_create_coordinator_pubkey)) }, + label = { Text(stringRes(Res.string.cordn_create_coordinator_pubkey)) }, singleLine = true, modifier = Modifier.fillMaxWidth(), ) @@ -444,7 +491,7 @@ fun CordnCreateGroupScreen( draft.userPicked = true error = null }, - label = { Text(stringRes(R.string.cordn_create_coordinator_relays)) }, + label = { Text(stringRes(Res.string.cordn_create_coordinator_relays)) }, // A coordinator has no address beyond its pubkey (§8.5), so // the relays are how it is reached and more than one is // ordinary. One per line rather than comma-separated, @@ -456,7 +503,7 @@ fun CordnCreateGroupScreen( } } - StepHeading(4, stringRes(R.string.cordn_create_step_admins)) + StepHeading(4, stringRes(Res.string.cordn_create_step_admins)) // spec/01.md §5.3: leaving the admin list empty is not "set it up // later", it is choosing draft.egalitarian permanently -- so it is offered @@ -472,15 +519,15 @@ fun CordnCreateGroupScreen( horizontalArrangement = Arrangement.spacedBy(12.dp), ) { Column(Modifier.weight(1f)) { - Text(stringRes(R.string.cordn_create_egalitarian), style = MaterialTheme.typography.bodyMedium) + Text(stringRes(Res.string.cordn_create_egalitarian), style = MaterialTheme.typography.bodyMedium) Text( text = if (draft.egalitarian) { - stringRes(R.string.cordn_create_egalitarian_note) + stringRes(Res.string.cordn_create_egalitarian_note) } else if (draft.coAdmins.isEmpty()) { - stringRes(R.string.cordn_create_admin_only_me_on) + stringRes(Res.string.cordn_create_admin_only_me_on) } else { - pluralStringRes(LocalContext.current, R.plurals.cordn_create_admin_count, draft.coAdmins.size + 1, draft.coAdmins.size + 1) + pluralStringRes(Res.plurals.cordn_create_admin_count, draft.coAdmins.size + 1, draft.coAdmins.size + 1) }, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, @@ -561,9 +608,9 @@ fun CordnCreateGroupScreen( ) { Text( if (draft.roster.isEmpty()) { - stringRes(R.string.cordn_create_action) + stringRes(Res.string.cordn_create_action) } else { - pluralStringRes(LocalContext.current, R.plurals.cordn_create_action_invite, draft.roster.size, draft.roster.size) + pluralStringRes(Res.plurals.cordn_create_action_invite, draft.roster.size, draft.roster.size) }, ) } @@ -691,9 +738,9 @@ private fun resolvedName( @Composable private fun announcedLabel(announcedAt: Long): String = if (TimeUtils.now() - announcedAt > TimeUtils.ONE_MONTH) { - stringRes(R.string.cordn_coordinators_discover_seen_on, timeAgoNoDot(announcedAt).trim()) + stringRes(Res.string.cordn_coordinators_discover_seen_on, timeAgoNoDot(announcedAt).trim()) } else { - stringRes(R.string.cordn_coordinators_discover_seen, timeAgoNoDot(announcedAt).trim()) + stringRes(Res.string.cordn_coordinators_discover_seen, timeAgoNoDot(announcedAt).trim()) } /** The hosts it answers on, the first two and a count of the rest. */ @@ -705,7 +752,7 @@ private fun relayLabel(relays: List<NormalizedRelayUrl>): String? { return if (hosts.size <= 2) { shown } else { - stringRes(R.string.cordn_coordinators_relays_more, shown, hosts.size - 2) + stringRes(Res.string.cordn_coordinators_relays_more, shown, hosts.size - 2) } } @@ -791,7 +838,7 @@ private fun RosterSummary( ) { if (roster.isEmpty()) { Text( - text = stringRes(R.string.cordn_create_people_none), + text = stringRes(Res.string.cordn_create_people_none), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.weight(1f), @@ -804,7 +851,7 @@ private fun RosterSummary( } Column(Modifier.weight(1f)) { Text( - text = pluralStringRes(LocalContext.current, R.plurals.cordn_member_count, roster.size + 1, roster.size + 1), + text = pluralStringRes(Res.plurals.cordn_member_count, roster.size + 1, roster.size + 1), style = MaterialTheme.typography.bodyMedium, ) // Counted over coordinators that answered, so an unreachable one @@ -812,7 +859,7 @@ private fun RosterSummary( val unreachable = roster.count { member -> coverage.values.none { it.answered && member in it.reachable } } if (unreachable > 0 && coverage.values.any { it.answered }) { Text( - text = pluralStringRes(LocalContext.current, R.plurals.cordn_create_unreachable_count, unreachable, unreachable), + text = pluralStringRes(Res.plurals.cordn_create_unreachable_count, unreachable, unreachable), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.error, ) @@ -855,7 +902,7 @@ private fun CoordinatorSummary( ) { if (config == null) { Text( - text = stringRes(R.string.cordn_create_no_coordinator), + text = stringRes(Res.string.cordn_create_no_coordinator), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.weight(1f), @@ -879,9 +926,9 @@ private fun CoordinatorSummary( Text( text = when { - coverage == null || !coverage.answered -> stringRes(R.string.cordn_create_coverage_unknown) - reached == rosterSize -> stringRes(R.string.cordn_create_coverage_all, rosterSize) - else -> stringRes(R.string.cordn_create_coverage_partial, reached ?: 0, rosterSize) + coverage == null || !coverage.answered -> stringRes(Res.string.cordn_create_coverage_unknown) + reached == rosterSize -> stringRes(Res.string.cordn_create_coverage_all, rosterSize) + else -> stringRes(Res.string.cordn_create_coverage_partial, reached ?: 0, rosterSize) }, style = MaterialTheme.typography.labelSmall, color = @@ -899,9 +946,9 @@ private fun CoordinatorSummary( TextButton(onClick = onToggle) { Text( if (expanded) { - stringRes(R.string.cordn_create_coordinator_hide) + stringRes(Res.string.cordn_create_coordinator_hide) } else { - stringRes(R.string.cordn_create_coordinator_change) + stringRes(Res.string.cordn_create_coordinator_change) }, ) } @@ -934,11 +981,11 @@ private fun CordnCreationOutcome( AlertDialog( onDismissRequest = onDismiss, - title = { Text(stringRes(R.string.cordn_create_outcome_title)) }, + title = { Text(stringRes(Res.string.cordn_create_outcome_title)) }, text = { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { Text( - text = stringRes(R.string.cordn_create_outcome_explainer), + text = stringRes(Res.string.cordn_create_outcome_explainer), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -960,10 +1007,10 @@ private fun CordnCreationOutcome( Text( text = when { - outcome == null -> stringRes(R.string.cordn_create_outcome_skipped) - outcome.sent -> stringRes(R.string.cordn_create_outcome_waiting) - outcome.joinedWithoutWelcome -> stringRes(R.string.cordn_create_outcome_no_welcome) - else -> outcome.failure?.message ?: stringRes(R.string.cordn_create_outcome_failed) + outcome == null -> stringRes(Res.string.cordn_create_outcome_skipped) + outcome.sent -> stringRes(Res.string.cordn_create_outcome_waiting) + outcome.joinedWithoutWelcome -> stringRes(Res.string.cordn_create_outcome_no_welcome) + else -> outcome.failure?.message ?: stringRes(Res.string.cordn_create_outcome_failed) }, style = MaterialTheme.typography.labelSmall, color = @@ -979,12 +1026,12 @@ private fun CordnCreationOutcome( // open themselves -- which needs the group to exist, // and now it does. if (outcome == null) { - TextButton(onClick = open) { Text(stringRes(R.string.cordn_create_outcome_link)) } + TextButton(onClick = open) { Text(stringRes(Res.string.cordn_create_outcome_link)) } } } } } }, - confirmButton = { TextButton(onClick = open) { Text(stringRes(R.string.cordn_create_outcome_open)) } }, + confirmButton = { TextButton(onClick = open) { Text(stringRes(Res.string.cordn_create_outcome_open)) } }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnCreateMembersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnCreateMembersScreen.kt index e80829bf90..1a09a1c890 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnCreateMembersScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnCreateMembersScreen.kt @@ -47,15 +47,31 @@ import androidx.compose.runtime.remember 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.unit.dp import androidx.fragment.app.FragmentActivity import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel -import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.cordn_create_add_member +import com.vitorpamplona.amethyst.commons.resources.cordn_create_admin +import com.vitorpamplona.amethyst.commons.resources.cordn_create_people_done +import com.vitorpamplona.amethyst.commons.resources.cordn_create_people_empty +import com.vitorpamplona.amethyst.commons.resources.cordn_create_people_note +import com.vitorpamplona.amethyst.commons.resources.cordn_create_reach_count +import com.vitorpamplona.amethyst.commons.resources.cordn_create_reach_none +import com.vitorpamplona.amethyst.commons.resources.cordn_create_reach_unknown +import com.vitorpamplona.amethyst.commons.resources.cordn_create_step_people +import com.vitorpamplona.amethyst.commons.resources.cordn_create_you +import com.vitorpamplona.amethyst.commons.resources.cordn_group_unavailable +import com.vitorpamplona.amethyst.commons.resources.cordn_group_unavailable_detail +import com.vitorpamplona.amethyst.commons.resources.cordn_info_add_member_none +import com.vitorpamplona.amethyst.commons.resources.cordn_info_add_member_placeholder +import com.vitorpamplona.amethyst.commons.resources.cordn_info_has_key_package +import com.vitorpamplona.amethyst.commons.resources.cordn_info_remove_member +import com.vitorpamplona.amethyst.commons.resources.cordn_member_count import com.vitorpamplona.amethyst.commons.ui.components.EmptyState import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton @@ -101,12 +117,12 @@ fun CordnCreateMembersScreen( val draft = rememberCordnGroupDraft(accountViewModel) Scaffold( - topBar = { TopBarWithBackButton(stringRes(R.string.cordn_create_step_people), nav) }, + topBar = { TopBarWithBackButton(stringRes(Res.string.cordn_create_step_people), nav) }, ) { padding -> if (runtime == null) { EmptyState( - title = stringRes(R.string.cordn_group_unavailable), - description = stringRes(R.string.cordn_group_unavailable_detail), + title = stringRes(Res.string.cordn_group_unavailable), + description = stringRes(Res.string.cordn_group_unavailable_detail), modifier = Modifier.padding(padding), ) return@Scaffold @@ -137,7 +153,7 @@ fun CordnCreateMembersScreen( verticalArrangement = Arrangement.spacedBy(12.dp), ) { Text( - text = stringRes(R.string.cordn_create_people_note), + text = stringRes(Res.string.cordn_create_people_note), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -148,8 +164,8 @@ fun CordnCreateMembersScreen( search = it if (it.length > 2) userSuggestions.processCurrentWord(it) else userSuggestions.reset() }, - label = { Text(stringRes(R.string.cordn_create_add_member)) }, - placeholder = { Text(stringRes(R.string.cordn_info_add_member_placeholder)) }, + label = { Text(stringRes(Res.string.cordn_create_add_member)) }, + placeholder = { Text(stringRes(Res.string.cordn_info_add_member_placeholder)) }, singleLine = true, modifier = Modifier.fillMaxWidth(), ) @@ -168,7 +184,7 @@ fun CordnCreateMembersScreen( modifier = SuggestionListDefaultHeightChat, onEmpty = { Text( - text = stringRes(R.string.cordn_info_add_member_none), + text = stringRes(Res.string.cordn_info_add_member_none), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), @@ -182,7 +198,7 @@ fun CordnCreateMembersScreen( if (user.pubkeyHex in reachable) { Icon( symbol = MaterialSymbols.Key, - contentDescription = stringRes(R.string.cordn_info_has_key_package), + contentDescription = stringRes(Res.string.cordn_info_has_key_package), modifier = Modifier.size(16.dp), tint = MaterialTheme.colorScheme.primary, ) @@ -194,7 +210,7 @@ fun CordnCreateMembersScreen( }) { Icon( symbol = MaterialSymbols.PersonAdd, - contentDescription = stringRes(R.string.cordn_create_add_member), + contentDescription = stringRes(Res.string.cordn_create_add_member), tint = MaterialTheme.colorScheme.primary, ) } @@ -206,7 +222,7 @@ fun CordnCreateMembersScreen( HorizontalDivider() Text( - text = pluralStringRes(LocalContext.current, R.plurals.cordn_member_count, draft.roster.size + 1, draft.roster.size + 1), + text = pluralStringRes(Res.plurals.cordn_member_count, draft.roster.size + 1, draft.roster.size + 1), style = MaterialTheme.typography.titleSmall, ) @@ -230,7 +246,7 @@ fun CordnCreateMembersScreen( if (draft.roster.isEmpty()) { Text( - text = stringRes(R.string.cordn_create_people_empty), + text = stringRes(Res.string.cordn_create_people_empty), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -240,7 +256,7 @@ fun CordnCreateMembersScreen( onClick = { nav.popBack() }, modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), ) { - Text(stringRes(R.string.cordn_create_people_done)) + Text(stringRes(Res.string.cordn_create_people_done)) } } } @@ -263,14 +279,14 @@ private fun CreatorRow( Column(Modifier.weight(1f)) { Text(observeUserNameByHex(me, accountViewModel), style = MaterialTheme.typography.bodyMedium) Text( - text = stringRes(R.string.cordn_create_you), + text = stringRes(Res.string.cordn_create_you), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } if (!egalitarian) { Text( - text = stringRes(R.string.cordn_create_admin), + text = stringRes(Res.string.cordn_create_admin), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary, ) @@ -304,9 +320,9 @@ private fun MemberRow( Text( text = when { - answered == 0 -> stringRes(R.string.cordn_create_reach_unknown) - reaching > 0 -> pluralStringRes(LocalContext.current, R.plurals.cordn_create_reach_count, reaching, reaching) - else -> stringRes(R.string.cordn_create_reach_none) + answered == 0 -> stringRes(Res.string.cordn_create_reach_unknown) + reaching > 0 -> pluralStringRes(Res.plurals.cordn_create_reach_count, reaching, reaching) + else -> stringRes(Res.string.cordn_create_reach_none) }, style = MaterialTheme.typography.labelSmall, color = @@ -323,7 +339,7 @@ private fun MemberRow( if (!egalitarian) { TextButton(onClick = onToggleAdmin) { Text( - text = stringRes(R.string.cordn_create_admin), + text = stringRes(Res.string.cordn_create_admin), style = MaterialTheme.typography.labelMedium, color = if (isAdmin) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -333,7 +349,7 @@ private fun MemberRow( IconButton(onClick = onRemove) { Icon( symbol = MaterialSymbols.PersonRemove, - contentDescription = stringRes(R.string.cordn_info_remove_member), + contentDescription = stringRes(Res.string.cordn_info_remove_member), modifier = Modifier.size(20.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnGroupChatScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnGroupChatScreen.kt index 7e0e2d7a13..b884f56dbd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnGroupChatScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnGroupChatScreen.kt @@ -69,7 +69,6 @@ import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.Amethyst -import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.cordn.CordnGroupManager import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols @@ -78,10 +77,33 @@ import com.vitorpamplona.amethyst.commons.model.navigation.Route import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.back import com.vitorpamplona.amethyst.commons.resources.cancel +import com.vitorpamplona.amethyst.commons.resources.cordn_action_editing +import com.vitorpamplona.amethyst.commons.resources.cordn_action_unpin +import com.vitorpamplona.amethyst.commons.resources.cordn_chat_empty_description +import com.vitorpamplona.amethyst.commons.resources.cordn_chat_empty_title +import com.vitorpamplona.amethyst.commons.resources.cordn_group_info +import com.vitorpamplona.amethyst.commons.resources.cordn_group_unavailable import com.vitorpamplona.amethyst.commons.resources.cordn_group_untitled +import com.vitorpamplona.amethyst.commons.resources.cordn_media_download_failed +import com.vitorpamplona.amethyst.commons.resources.cordn_media_no_server +import com.vitorpamplona.amethyst.commons.resources.cordn_media_unreadable +import com.vitorpamplona.amethyst.commons.resources.cordn_media_upload_failed +import com.vitorpamplona.amethyst.commons.resources.cordn_member_count +import com.vitorpamplona.amethyst.commons.resources.cordn_message_deleted +import com.vitorpamplona.amethyst.commons.resources.cordn_pinned_by +import com.vitorpamplona.amethyst.commons.resources.cordn_pinned_count +import com.vitorpamplona.amethyst.commons.resources.cordn_pinned_next +import com.vitorpamplona.amethyst.commons.resources.cordn_pinned_previous +import com.vitorpamplona.amethyst.commons.resources.cordn_pinned_show_all +import com.vitorpamplona.amethyst.commons.resources.cordn_pinned_title +import com.vitorpamplona.amethyst.commons.resources.cordn_send_failed +import com.vitorpamplona.amethyst.commons.resources.cordn_send_no_session +import com.vitorpamplona.amethyst.commons.resources.cordn_voice_record +import com.vitorpamplona.amethyst.commons.resources.cordn_voice_stop import com.vitorpamplona.amethyst.commons.richtext.EncryptedMediaUrlImage import com.vitorpamplona.amethyst.commons.richtext.EncryptedMediaUrlVideo import com.vitorpamplona.amethyst.commons.ui.components.EmptyState +import com.vitorpamplona.amethyst.commons.ui.loadStringRes import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.theme.FeedPadding import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText @@ -152,7 +174,7 @@ fun CordnGroupChatScreen( // (it was forgotten, or never opened) and not an error to throw at the // user as a blank screen. Column(Modifier.fillMaxSize().padding(24.dp), verticalArrangement = Arrangement.Center) { - Text(stringRes(R.string.cordn_group_unavailable), style = MaterialTheme.typography.bodyLarge) + Text(stringRes(Res.string.cordn_group_unavailable), style = MaterialTheme.typography.bodyLarge) } return } @@ -209,9 +231,9 @@ private fun CordnGroupChat( // and simply never arrived. var sendError by remember { mutableStateOf<String?>(null) } val context = LocalContext.current - val uploadFailed = stringRes(R.string.cordn_media_upload_failed) - val sendFailed = stringRes(R.string.cordn_send_failed) - val noSession = stringRes(R.string.cordn_send_no_session) + val uploadFailed = stringRes(Res.string.cordn_media_upload_failed) + val sendFailed = stringRes(Res.string.cordn_send_failed) + val noSession = stringRes(Res.string.cordn_send_no_session) fun manager() = accountViewModel.account.cordnRuntime @@ -359,8 +381,8 @@ private fun CordnGroupChat( // action that actually needed it. if (rows.isEmpty()) { EmptyState( - title = stringRes(R.string.cordn_chat_empty_title), - description = stringRes(R.string.cordn_chat_empty_description), + title = stringRes(Res.string.cordn_chat_empty_title), + description = stringRes(Res.string.cordn_chat_empty_description), modifier = Modifier.weight(1f), ) } @@ -548,7 +570,7 @@ private fun CordnGroupChat( } if (editing != null) { ComposerBanner( - label = stringRes(R.string.cordn_action_editing), + label = stringRes(Res.string.cordn_action_editing), onCancel = { editing = null room.draft.value = "" @@ -712,7 +734,7 @@ private fun PinnedRibbon( IconButton(onClick = { index = (at - 1 + pinned.size) % pinned.size }, modifier = Modifier.size(28.dp)) { Icon( symbol = MaterialSymbols.AutoMirrored.KeyboardArrowLeft, - contentDescription = stringRes(R.string.cordn_pinned_previous), + contentDescription = stringRes(Res.string.cordn_pinned_previous), modifier = Modifier.size(18.dp), ) } @@ -724,7 +746,7 @@ private fun PinnedRibbon( IconButton(onClick = { index = (at + 1) % pinned.size }, modifier = Modifier.size(28.dp)) { Icon( symbol = MaterialSymbols.AutoMirrored.KeyboardArrowRight, - contentDescription = stringRes(R.string.cordn_pinned_next), + contentDescription = stringRes(Res.string.cordn_pinned_next), modifier = Modifier.size(18.dp), ) } @@ -733,7 +755,7 @@ private fun PinnedRibbon( IconButton(onClick = { showingAll = true }, modifier = Modifier.size(28.dp)) { Icon( symbol = MaterialSymbols.AutoMirrored.List, - contentDescription = stringRes(R.string.cordn_pinned_show_all), + contentDescription = stringRes(Res.string.cordn_pinned_show_all), modifier = Modifier.size(18.dp), ) } @@ -786,7 +808,7 @@ private fun PinnedLine( // points at. text = if (annotations.isDeleted(message.envelope.id)) { - stringRes(R.string.cordn_message_deleted) + stringRes(Res.string.cordn_message_deleted) } else { annotations.contentOf(message.envelope.id).orEmpty() }, @@ -822,7 +844,7 @@ private fun AllPinnedDialog( Row(verticalAlignment = Alignment.CenterVertically) { Icon(MaterialSymbols.PushPin, contentDescription = null, modifier = Modifier.size(18.dp)) Text( - text = stringRes(R.string.cordn_pinned_title), + text = stringRes(Res.string.cordn_pinned_title), modifier = Modifier.padding(start = 8.dp), ) } @@ -830,7 +852,7 @@ private fun AllPinnedDialog( text = { Column { Text( - text = pluralStringRes(LocalContext.current, R.plurals.cordn_pinned_count, pinned.size, pinned.size), + text = pluralStringRes(Res.plurals.cordn_pinned_count, pinned.size, pinned.size), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -861,7 +883,7 @@ private fun AllPinnedDialog( modifier = Modifier.weight(1f).padding(start = 8.dp), ) TextButton(onClick = { onUnpin(message) }) { - Text(stringRes(R.string.cordn_action_unpin), style = MaterialTheme.typography.labelSmall) + Text(stringRes(Res.string.cordn_action_unpin), style = MaterialTheme.typography.labelSmall) } } @@ -869,7 +891,7 @@ private fun AllPinnedDialog( Text( text = stringRes( - R.string.cordn_pinned_by, + Res.string.cordn_pinned_by, observeUserNameByHex(pinnedBy, accountViewModel), ), style = MaterialTheme.typography.labelSmall, @@ -880,7 +902,7 @@ private fun AllPinnedDialog( Text( text = if (annotations.isDeleted(message.envelope.id)) { - stringRes(R.string.cordn_message_deleted) + stringRes(Res.string.cordn_message_deleted) } else { annotations.contentOf(message.envelope.id).orEmpty() }, @@ -944,7 +966,7 @@ private fun CordnChatTopBar( Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis) if (members.isNotEmpty()) { Text( - text = pluralStringRes(LocalContext.current, R.plurals.cordn_member_count, members.size, members.size), + text = pluralStringRes(Res.plurals.cordn_member_count, members.size, members.size), style = MaterialTheme.typography.bodySmall, ) } @@ -963,7 +985,7 @@ private fun CordnChatTopBar( // GroupAdd rather than Info, as in Marmot: the same screen, and the thing // people come to it for is adding someone. IconButton(onClick = onInfo) { - Icon(MaterialSymbols.GroupAdd, contentDescription = stringRes(R.string.cordn_group_info)) + Icon(MaterialSymbols.GroupAdd, contentDescription = stringRes(Res.string.cordn_group_info)) } }, ) @@ -995,10 +1017,10 @@ private suspend fun sendAttachment( // a reason a person can act on. val session = accountViewModel.account.cordnRuntime?.sessionOrNull(room.coordinatorPubKey) - ?: throw CordnAttachmentException(stringRes(context, R.string.cordn_send_no_session)) + ?: throw CordnAttachmentException(loadStringRes(Res.string.cordn_send_no_session)) val group = session.manager.group(room.gid) - ?: throw CordnAttachmentException(stringRes(context, R.string.cordn_send_no_session)) + ?: throw CordnAttachmentException(loadStringRes(Res.string.cordn_send_no_session)) // The gallery inside the dialog can delete what was picked, which leaves an empty // orchestrator rather than a null one. canPost() now refuses that, but indexing it @@ -1052,14 +1074,14 @@ private suspend fun sendAttachment( val name = withContext(Dispatchers.IO) { resolveDisplayName(context, uri) } val bytes = withContext(Dispatchers.IO) { context.contentResolver.openInputStream(finalUri)?.use { it.readBytes() } } - ?: throw CordnAttachmentException(stringRes(context, R.string.cordn_media_unreadable)) + ?: throw CordnAttachmentException(loadStringRes(Res.string.cordn_media_unreadable)) // Null means the chosen host has no base URL, which is a setting the person can // change — the one failure here that is entirely actionable. val tag = CordnMediaService(accountViewModel.account) .upload(group, bytes, mime, name, context, state.selectedServer.baseUrl) - ?: throw CordnAttachmentException(stringRes(context, R.string.cordn_media_no_server)) + ?: throw CordnAttachmentException(loadStringRes(Res.string.cordn_media_no_server)) // Into the room as well, for the same reason every other send is: an // attachment of your own echoes back as an Echo and would otherwise be @@ -1140,7 +1162,7 @@ internal fun CordnAttachment( if (mediaKey == null) { Text( - text = stringRes(R.string.cordn_media_download_failed), + text = stringRes(Res.string.cordn_media_download_failed), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.error, ) @@ -1254,7 +1276,7 @@ internal fun VoiceNoteButton( ) { Icon( symbol = if (recording) MaterialSymbols.Stop else MaterialSymbols.Mic, - contentDescription = stringRes(if (recording) R.string.cordn_voice_stop else R.string.cordn_voice_record), + contentDescription = stringRes(if (recording) Res.string.cordn_voice_stop else Res.string.cordn_voice_record), // Matches the attach icon beside it; red only while recording, // which is the one state worth pulling the eye. tint = if (recording) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.placeholderText, @@ -1284,10 +1306,10 @@ private suspend fun sendVoiceNote( // note that goes nowhere and says nothing is the same bug twice. val session = accountViewModel.account.cordnRuntime?.sessionOrNull(room.coordinatorPubKey) - ?: throw CordnAttachmentException(stringRes(context, R.string.cordn_send_no_session)) + ?: throw CordnAttachmentException(loadStringRes(Res.string.cordn_send_no_session)) val group = session.manager.group(room.gid) - ?: throw CordnAttachmentException(stringRes(context, R.string.cordn_send_no_session)) + ?: throw CordnAttachmentException(loadStringRes(Res.string.cordn_send_no_session)) try { val bytes = withContext(Dispatchers.IO) { recording.file.readBytes() } @@ -1304,7 +1326,7 @@ private suspend fun sendVoiceNote( // is what makes the bubble match the preview. waveform = recording.amplitudes, ) - ?: throw CordnAttachmentException(stringRes(context, R.string.cordn_media_no_server)) + ?: throw CordnAttachmentException(loadStringRes(Res.string.cordn_media_no_server)) // Into the room as well, for the same reason every other send is: an // attachment of your own echoes back as an Echo and would otherwise be // invisible to the person who sent it. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnGroupInfoScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnGroupInfoScreen.kt index 25c90c354e..b08e44b341 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnGroupInfoScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnGroupInfoScreen.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.cordnGroup -import android.content.Context import android.util.Log import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.clickable @@ -58,12 +57,10 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalClipboardManager -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.cordn.CordnGroupException import com.vitorpamplona.amethyst.commons.cordn.GroupExposure import com.vitorpamplona.amethyst.commons.cordn.ui.CordnExposureCard @@ -76,8 +73,58 @@ import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.back import com.vitorpamplona.amethyst.commons.resources.cancel import com.vitorpamplona.amethyst.commons.resources.copy_npub_to_clipboard +import com.vitorpamplona.amethyst.commons.resources.cordn_admin_action_failed +import com.vitorpamplona.amethyst.commons.resources.cordn_admin_coordinator_silent +import com.vitorpamplona.amethyst.commons.resources.cordn_admin_no_key_package +import com.vitorpamplona.amethyst.commons.resources.cordn_admin_no_self_remove +import com.vitorpamplona.amethyst.commons.resources.cordn_admin_no_welcome +import com.vitorpamplona.amethyst.commons.resources.cordn_admin_not_a_member +import com.vitorpamplona.amethyst.commons.resources.cordn_admin_someone +import com.vitorpamplona.amethyst.commons.resources.cordn_admin_wrong_key_package +import com.vitorpamplona.amethyst.commons.resources.cordn_create_description +import com.vitorpamplona.amethyst.commons.resources.cordn_create_name +import com.vitorpamplona.amethyst.commons.resources.cordn_exposure_close +import com.vitorpamplona.amethyst.commons.resources.cordn_exposure_open +import com.vitorpamplona.amethyst.commons.resources.cordn_group_info +import com.vitorpamplona.amethyst.commons.resources.cordn_group_unavailable +import com.vitorpamplona.amethyst.commons.resources.cordn_group_unavailable_detail import com.vitorpamplona.amethyst.commons.resources.cordn_group_untitled +import com.vitorpamplona.amethyst.commons.resources.cordn_info_add_member +import com.vitorpamplona.amethyst.commons.resources.cordn_info_add_member_none +import com.vitorpamplona.amethyst.commons.resources.cordn_info_add_member_note +import com.vitorpamplona.amethyst.commons.resources.cordn_info_add_member_placeholder +import com.vitorpamplona.amethyst.commons.resources.cordn_info_admin_badge +import com.vitorpamplona.amethyst.commons.resources.cordn_info_admins_kept +import com.vitorpamplona.amethyst.commons.resources.cordn_info_coordinator +import com.vitorpamplona.amethyst.commons.resources.cordn_info_coordinator_key +import com.vitorpamplona.amethyst.commons.resources.cordn_info_coordinator_nprofile +import com.vitorpamplona.amethyst.commons.resources.cordn_info_copy_nprofile +import com.vitorpamplona.amethyst.commons.resources.cordn_info_edit_details +import com.vitorpamplona.amethyst.commons.resources.cordn_info_egalitarian +import com.vitorpamplona.amethyst.commons.resources.cordn_info_epoch +import com.vitorpamplona.amethyst.commons.resources.cordn_info_gid +import com.vitorpamplona.amethyst.commons.resources.cordn_info_has_key_package +import com.vitorpamplona.amethyst.commons.resources.cordn_info_members +import com.vitorpamplona.amethyst.commons.resources.cordn_info_remove_confirm_body +import com.vitorpamplona.amethyst.commons.resources.cordn_info_remove_confirm_title +import com.vitorpamplona.amethyst.commons.resources.cordn_info_remove_member +import com.vitorpamplona.amethyst.commons.resources.cordn_info_save +import com.vitorpamplona.amethyst.commons.resources.cordn_info_technical +import com.vitorpamplona.amethyst.commons.resources.cordn_member_count +import com.vitorpamplona.amethyst.commons.resources.cordn_requests_accept +import com.vitorpamplona.amethyst.commons.resources.cordn_requests_admin_only +import com.vitorpamplona.amethyst.commons.resources.cordn_requests_check +import com.vitorpamplona.amethyst.commons.resources.cordn_requests_decline +import com.vitorpamplona.amethyst.commons.resources.cordn_requests_failed +import com.vitorpamplona.amethyst.commons.resources.cordn_requests_none +import com.vitorpamplona.amethyst.commons.resources.cordn_requests_title +import com.vitorpamplona.amethyst.commons.resources.cordn_send_no_session +import com.vitorpamplona.amethyst.commons.resources.cordn_share_copied +import com.vitorpamplona.amethyst.commons.resources.cordn_share_copy +import com.vitorpamplona.amethyst.commons.resources.cordn_share_explainer +import com.vitorpamplona.amethyst.commons.resources.cordn_share_title import com.vitorpamplona.amethyst.commons.ui.components.EmptyState +import com.vitorpamplona.amethyst.commons.ui.loadStringRes import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.theme.SuggestionListDefaultHeightChat import com.vitorpamplona.amethyst.commons.util.toShortDisplay @@ -124,8 +171,8 @@ fun CordnGroupInfoScreen( // The app's own empty state, centred and titled, rather than a // sentence stranded in the top-left corner. EmptyState( - title = stringRes(R.string.cordn_group_unavailable), - description = stringRes(R.string.cordn_group_unavailable_detail), + title = stringRes(Res.string.cordn_group_unavailable), + description = stringRes(Res.string.cordn_group_unavailable_detail), modifier = Modifier.padding(padding), ) } @@ -151,7 +198,7 @@ private fun InfoTopBar( Icon(MaterialSymbols.AutoMirrored.ArrowBack, contentDescription = stringRes(Res.string.back)) } }, - title = { Text(stringRes(R.string.cordn_group_info)) }, + title = { Text(stringRes(Res.string.cordn_group_info)) }, actions = actions, ) } @@ -208,8 +255,8 @@ private fun CordnGroupInfo( DisposableEffect(Unit) { onDispose { userSuggestions.reset() } } - val failed = stringRes(R.string.cordn_admin_action_failed) - val noSession = stringRes(R.string.cordn_send_no_session) + val failed = stringRes(Res.string.cordn_admin_action_failed) + val noSession = stringRes(Res.string.cordn_send_no_session) // Computed, never asserted: hardcoding these made the card look like a // disclosure while reporting the same four values for every group. @@ -236,7 +283,6 @@ private fun CordnGroupInfo( * roster, and a rename stayed the old name. The runtime's own methods pair * each commit with that refresh. */ - val context = LocalContext.current fun runAdmin( who: String? = null, @@ -257,7 +303,7 @@ private fun CordnGroupInfo( // and gids in full, so the screen that had just shown a face // answered with 64 hex characters. Log.w("CordnGroupInfo", "admin action failed in ${room.gid}: ${e.message}", e) - adminError = adminFailureText(context, e, who, failed) + adminError = adminFailureText(e, who, failed) } finally { busy = false } @@ -274,7 +320,7 @@ private fun CordnGroupInfo( IconButton(onClick = { renaming = true }, enabled = !busy) { Icon( symbol = MaterialSymbols.Edit, - contentDescription = stringRes(R.string.cordn_info_edit_details), + contentDescription = stringRes(Res.string.cordn_info_edit_details), ) } } @@ -303,7 +349,7 @@ private fun CordnGroupInfo( } // A subtitle, not a labelled row above a list that is itself the count. Text( - text = pluralStringRes(LocalContext.current, R.plurals.cordn_member_count, members.size, members.size), + text = pluralStringRes(Res.plurals.cordn_member_count, members.size, members.size), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 4.dp), @@ -321,7 +367,7 @@ private fun CordnGroupInfo( // ContextVM addresses it with ordinary p-tags, so it has a kind 0 like // anyone else and the app can already resolve it to a name, an avatar // and a profile to tap through to. - Text(stringRes(R.string.cordn_info_coordinator), style = MaterialTheme.typography.titleMedium) + Text(stringRes(Res.string.cordn_info_coordinator), style = MaterialTheme.typography.titleMedium) Row( Modifier.fillMaxWidth().padding(top = 8.dp), @@ -347,7 +393,7 @@ private fun CordnGroupInfo( IconButton(onClick = { showExposure = true }, enabled = exposure != null) { Icon( symbol = MaterialSymbols.Info, - contentDescription = stringRes(R.string.cordn_exposure_open), + contentDescription = stringRes(Res.string.cordn_exposure_open), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } @@ -355,7 +401,7 @@ private fun CordnGroupInfo( HorizontalDivider(Modifier.padding(vertical = 16.dp)) - Text(stringRes(R.string.cordn_info_members), style = MaterialTheme.typography.titleMedium) + Text(stringRes(Res.string.cordn_info_members), style = MaterialTheme.typography.titleMedium) // Said once, and only when it is true. An empty admin set is not "none // configured" -- spec/01.md makes it permanently egalitarian, so the @@ -364,7 +410,7 @@ private fun CordnGroupInfo( // a count of them adds nothing. if (admins.isEmpty()) { Text( - text = stringRes(R.string.cordn_info_egalitarian), + text = stringRes(Res.string.cordn_info_egalitarian), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 2.dp, bottom = 4.dp), @@ -385,7 +431,7 @@ private fun CordnGroupInfo( ) if (member in admins) { Text( - text = stringRes(R.string.cordn_info_admin_badge), + text = stringRes(Res.string.cordn_info_admin_badge), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.primary, ) @@ -398,7 +444,7 @@ private fun CordnGroupInfo( IconButton(onClick = { removing = member }, enabled = !busy) { Icon( symbol = MaterialSymbols.PersonRemove, - contentDescription = stringRes(R.string.cordn_info_remove_member), + contentDescription = stringRes(Res.string.cordn_info_remove_member), modifier = Modifier.size(20.dp), tint = MaterialTheme.colorScheme.error, ) @@ -476,9 +522,9 @@ private fun CordnGroupInfo( // would be a setting nobody should be nudged into. AlertDialog( onDismissRequest = { removing = null }, - title = { Text(stringRes(R.string.cordn_info_remove_confirm_title)) }, + title = { Text(stringRes(Res.string.cordn_info_remove_confirm_title)) }, text = { - Text(stringRes(R.string.cordn_info_remove_confirm_body, removingName)) + Text(stringRes(Res.string.cordn_info_remove_confirm_body, removingName)) }, confirmButton = { TextButton(onClick = { @@ -486,7 +532,7 @@ private fun CordnGroupInfo( runAdmin(removingName) { it.removeMember(coordinatorPubKey, room.gid, target) } }) { Text( - text = stringRes(R.string.cordn_info_remove_member), + text = stringRes(Res.string.cordn_info_remove_member), color = MaterialTheme.colorScheme.error, ) } @@ -518,7 +564,7 @@ private fun CordnGroupInfo( text = { CordnExposureCard(it) }, confirmButton = { TextButton(onClick = { showExposure = false }) { - Text(stringRes(R.string.cordn_exposure_close)) + Text(stringRes(Res.string.cordn_exposure_close)) } }, ) @@ -577,15 +623,15 @@ private fun CordnAddMember( OutlinedTextField( value = search, onValueChange = onSearchChange, - label = { Text(stringRes(R.string.cordn_info_add_member)) }, - placeholder = { Text(stringRes(R.string.cordn_info_add_member_placeholder)) }, + label = { Text(stringRes(Res.string.cordn_info_add_member)) }, + placeholder = { Text(stringRes(Res.string.cordn_info_add_member_placeholder)) }, singleLine = true, enabled = !busy, modifier = Modifier.fillMaxWidth(), ) Text( - text = stringRes(R.string.cordn_info_add_member_note), + text = stringRes(Res.string.cordn_info_add_member_note), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 4.dp), @@ -601,7 +647,7 @@ private fun CordnAddMember( modifier = SuggestionListDefaultHeightChat, onEmpty = { Text( - text = stringRes(R.string.cordn_info_add_member_none), + text = stringRes(Res.string.cordn_info_add_member_none), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), @@ -612,7 +658,7 @@ private fun CordnAddMember( if (reachable?.contains(user.pubkeyHex) == true) { Icon( symbol = MaterialSymbols.Key, - contentDescription = stringRes(R.string.cordn_info_has_key_package), + contentDescription = stringRes(Res.string.cordn_info_has_key_package), modifier = Modifier.size(16.dp), tint = MaterialTheme.colorScheme.primary, ) @@ -620,7 +666,7 @@ private fun CordnAddMember( IconButton(onClick = { onInvite(user) }) { Icon( symbol = MaterialSymbols.PersonAdd, - contentDescription = stringRes(R.string.cordn_info_add_member), + contentDescription = stringRes(Res.string.cordn_info_add_member), tint = MaterialTheme.colorScheme.primary, ) } @@ -643,20 +689,19 @@ private fun CordnAddMember( * better than silence — and the caller has logged the throwable in full either * way. */ -private fun adminFailureText( - context: Context, +private suspend fun adminFailureText( e: Throwable, who: String?, fallback: String, ): String { - val name = who ?: stringRes(context, R.string.cordn_admin_someone) + val name = who ?: loadStringRes(Res.string.cordn_admin_someone) return when (e) { is CordnGroupException -> when (e.reason) { - CordnGroupException.Reason.NO_KEY_PACKAGE -> stringRes(context, R.string.cordn_admin_no_key_package, name) - CordnGroupException.Reason.WRONG_KEY_PACKAGE_OWNER -> stringRes(context, R.string.cordn_admin_wrong_key_package, name) - CordnGroupException.Reason.NO_WELCOME -> stringRes(context, R.string.cordn_admin_no_welcome, name) - CordnGroupException.Reason.NOT_A_MEMBER -> stringRes(context, R.string.cordn_admin_not_a_member, name) + CordnGroupException.Reason.NO_KEY_PACKAGE -> loadStringRes(Res.string.cordn_admin_no_key_package, name) + CordnGroupException.Reason.WRONG_KEY_PACKAGE_OWNER -> loadStringRes(Res.string.cordn_admin_wrong_key_package, name) + CordnGroupException.Reason.NO_WELCOME -> loadStringRes(Res.string.cordn_admin_no_welcome, name) + CordnGroupException.Reason.NOT_A_MEMBER -> loadStringRes(Res.string.cordn_admin_not_a_member, name) CordnGroupException.Reason.OTHER -> e.message ?: fallback } @@ -664,14 +709,14 @@ private fun adminFailureText( // roster and the button disagreed rather than that anyone tried. is IllegalArgumentException -> if (e.message?.contains("self-removal") == true) { - stringRes(context, R.string.cordn_admin_no_self_remove) + loadStringRes(Res.string.cordn_admin_no_self_remove) } else { e.message ?: fallback } // A coordinator that does not answer is the single commonest failure // here and says nothing useful in its own words. - is TimeoutCancellationException -> stringRes(context, R.string.cordn_admin_coordinator_silent) + is TimeoutCancellationException -> loadStringRes(Res.string.cordn_admin_coordinator_silent) else -> e.message ?: fallback } @@ -701,7 +746,7 @@ private fun TechnicalDetails( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = stringRes(R.string.cordn_info_technical), + text = stringRes(Res.string.cordn_info_technical), style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f), ) @@ -725,22 +770,22 @@ private fun TechnicalDetails( val coordinator = remember(coordinatorPubKey) { LocalCache.getOrCreateUser(coordinatorPubKey) } CopyableKeyRow( - label = stringRes(R.string.cordn_info_coordinator_key), + label = stringRes(Res.string.cordn_info_coordinator_key), shown = coordinator.pubkeyDisplayHex(), copied = coordinator.pubkeyNpub(), copyDescription = stringRes(Res.string.copy_npub_to_clipboard), ) CopyableKeyRow( - label = stringRes(R.string.cordn_info_coordinator_nprofile), + label = stringRes(Res.string.cordn_info_coordinator_nprofile), shown = coordinator.toNProfile().toShortDisplay(6), copied = coordinator.toNProfile(), - copyDescription = stringRes(R.string.cordn_info_copy_nprofile), + copyDescription = stringRes(Res.string.cordn_info_copy_nprofile), ) SelectionContainer { Column { - InfoRow(stringRes(R.string.cordn_info_gid), gid) - InfoRow(stringRes(R.string.cordn_info_epoch), epoch.toString()) + InfoRow(stringRes(Res.string.cordn_info_gid), gid) + InfoRow(stringRes(Res.string.cordn_info_epoch), epoch.toString()) } } } @@ -835,7 +880,7 @@ private fun JoinRequests( ) { val runtime = accountViewModel.account.cordnRuntime ?: return val scope = rememberCoroutineScope() - val failed = stringRes(R.string.cordn_requests_failed) + val failed = stringRes(Res.string.cordn_requests_failed) var requests by remember { mutableStateOf<List<JoinRequest>?>(null) } var busy by remember { mutableStateOf(false) } @@ -853,15 +898,15 @@ private fun JoinRequests( } } - Text(stringRes(R.string.cordn_requests_title), style = MaterialTheme.typography.titleMedium) + Text(stringRes(Res.string.cordn_requests_title), style = MaterialTheme.typography.titleMedium) Text( - text = stringRes(R.string.cordn_requests_admin_only), + text = stringRes(Res.string.cordn_requests_admin_only), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) OutlinedButton(onClick = { scope.launch { reload() } }, enabled = !busy) { - Text(stringRes(R.string.cordn_requests_check)) + Text(stringRes(Res.string.cordn_requests_check)) } error?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error) } @@ -870,7 +915,7 @@ private fun JoinRequests( if (loaded != null && !busy) { if (loaded.isEmpty()) { Text( - text = stringRes(R.string.cordn_requests_none), + text = stringRes(Res.string.cordn_requests_none), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -899,7 +944,7 @@ private fun JoinRequests( reload() } }) { - Text(stringRes(R.string.cordn_requests_accept)) + Text(stringRes(Res.string.cordn_requests_accept)) } TextButton(onClick = { scope.launch { @@ -911,7 +956,7 @@ private fun JoinRequests( reload() } }) { - Text(stringRes(R.string.cordn_requests_decline)) + Text(stringRes(Res.string.cordn_requests_decline)) } } } @@ -949,9 +994,9 @@ private fun ShareGroup( val clipboard = LocalClipboardManager.current var copied by remember { mutableStateOf(false) } - Text(stringRes(R.string.cordn_share_title), style = MaterialTheme.typography.titleMedium) + Text(stringRes(Res.string.cordn_share_title), style = MaterialTheme.typography.titleMedium) Text( - text = stringRes(R.string.cordn_share_explainer), + text = stringRes(Res.string.cordn_share_explainer), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -968,7 +1013,7 @@ private fun ShareGroup( }, modifier = Modifier.padding(top = 8.dp), ) { - Text(stringRes(if (copied) R.string.cordn_share_copied else R.string.cordn_share_copy)) + Text(stringRes(if (copied) Res.string.cordn_share_copied else Res.string.cordn_share_copy)) } } @@ -993,26 +1038,26 @@ private fun EditGroupDetailsDialog( AlertDialog( onDismissRequest = onDismiss, - title = { Text(stringRes(R.string.cordn_info_edit_details)) }, + title = { Text(stringRes(Res.string.cordn_info_edit_details)) }, text = { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { OutlinedTextField( value = draftName, onValueChange = { draftName = it }, - label = { Text(stringRes(R.string.cordn_create_name)) }, + label = { Text(stringRes(Res.string.cordn_create_name)) }, singleLine = true, modifier = Modifier.fillMaxWidth(), ) OutlinedTextField( value = draftDescription, onValueChange = { draftDescription = it }, - label = { Text(stringRes(R.string.cordn_create_description)) }, + label = { Text(stringRes(Res.string.cordn_create_description)) }, singleLine = false, modifier = Modifier.fillMaxWidth(), ) if (admins.isNotEmpty()) { Text( - text = pluralStringRes(LocalContext.current, R.plurals.cordn_info_admins_kept, admins.size, admins.size), + text = pluralStringRes(Res.plurals.cordn_info_admins_kept, admins.size, admins.size), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -1024,7 +1069,7 @@ private fun EditGroupDetailsDialog( onClick = { onSave(draftName.trim(), draftDescription.trim()) }, enabled = draftName.isNotBlank(), ) { - Text(stringRes(R.string.cordn_info_save)) + Text(stringRes(Res.string.cordn_info_save)) } }, dismissButton = { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnGroupListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnGroupListScreen.kt index e7a5defb33..13876ffbe1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnGroupListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnGroupListScreen.kt @@ -41,10 +41,8 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.cordn.CoordinatorHealth import com.vitorpamplona.amethyst.commons.cordn.ui.CoordinatorHealthRow import com.vitorpamplona.amethyst.commons.icons.symbols.Icon @@ -52,6 +50,14 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.cordnGroups.CordnGroupChatroom import com.vitorpamplona.amethyst.commons.model.navigation.Route import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.cordn_group_unavailable +import com.vitorpamplona.amethyst.commons.resources.cordn_group_unavailable_detail +import com.vitorpamplona.amethyst.commons.resources.cordn_groups_count +import com.vitorpamplona.amethyst.commons.resources.cordn_groups_down_note +import com.vitorpamplona.amethyst.commons.resources.cordn_groups_manage +import com.vitorpamplona.amethyst.commons.resources.cordn_groups_none +import com.vitorpamplona.amethyst.commons.resources.cordn_groups_none_detail +import com.vitorpamplona.amethyst.commons.resources.cordn_groups_start import com.vitorpamplona.amethyst.commons.resources.cordn_groups_title import com.vitorpamplona.amethyst.commons.ui.components.EmptyState import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav @@ -106,14 +112,14 @@ fun CordnGroupListScreen( IconButton(onClick = { nav.nav(Route.CordnCoordinators) }) { Icon( symbol = MaterialSymbols.Dns, - contentDescription = stringRes(R.string.cordn_groups_manage), + contentDescription = stringRes(Res.string.cordn_groups_manage), modifier = Modifier.size(22.dp), ) } IconButton(onClick = { nav.nav(Route.CordnCreateGroup) }) { Icon( symbol = MaterialSymbols.Add, - contentDescription = stringRes(R.string.cordn_groups_start), + contentDescription = stringRes(Res.string.cordn_groups_start), modifier = Modifier.size(24.dp), ) } @@ -122,8 +128,8 @@ fun CordnGroupListScreen( ) { padding -> if (runtime == null) { EmptyState( - title = stringRes(R.string.cordn_group_unavailable), - description = stringRes(R.string.cordn_group_unavailable_detail), + title = stringRes(Res.string.cordn_group_unavailable), + description = stringRes(Res.string.cordn_group_unavailable_detail), modifier = Modifier.padding(padding), ) return@Scaffold @@ -148,8 +154,8 @@ fun CordnGroupListScreen( if (sections.isEmpty()) { EmptyState( - title = stringRes(R.string.cordn_groups_none), - description = stringRes(R.string.cordn_groups_none_detail), + title = stringRes(Res.string.cordn_groups_none), + description = stringRes(Res.string.cordn_groups_none_detail), modifier = Modifier.padding(padding), ) return@Scaffold @@ -230,7 +236,7 @@ private fun CoordinatorSection( }, ) Text( - text = pluralStringRes(LocalContext.current, R.plurals.cordn_groups_count, groupCount, groupCount), + text = pluralStringRes(Res.plurals.cordn_groups_count, groupCount, groupCount), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -248,7 +254,7 @@ private fun CoordinatorSection( verticalAlignment = Alignment.Top, ) { Text( - text = stringRes(R.string.cordn_groups_down_note), + text = stringRes(Res.string.cordn_groups_down_note), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnInvitationsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnInvitationsScreen.kt index a1292ad0e6..b145042c85 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnInvitationsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnInvitationsScreen.kt @@ -48,16 +48,30 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.navigation.Route import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.back +import com.vitorpamplona.amethyst.commons.resources.cordn_group_unavailable +import com.vitorpamplona.amethyst.commons.resources.cordn_group_unavailable_detail import com.vitorpamplona.amethyst.commons.resources.cordn_group_untitled +import com.vitorpamplona.amethyst.commons.resources.cordn_invitations_accept +import com.vitorpamplona.amethyst.commons.resources.cordn_invitations_decline +import com.vitorpamplona.amethyst.commons.resources.cordn_invitations_decline_warning +import com.vitorpamplona.amethyst.commons.resources.cordn_invitations_explainer +import com.vitorpamplona.amethyst.commons.resources.cordn_invitations_load_failed +import com.vitorpamplona.amethyst.commons.resources.cordn_invitations_members +import com.vitorpamplona.amethyst.commons.resources.cordn_invitations_members_more +import com.vitorpamplona.amethyst.commons.resources.cordn_invitations_no_coordinators +import com.vitorpamplona.amethyst.commons.resources.cordn_invitations_none +import com.vitorpamplona.amethyst.commons.resources.cordn_invitations_refresh +import com.vitorpamplona.amethyst.commons.resources.cordn_invitations_skipped +import com.vitorpamplona.amethyst.commons.resources.cordn_invitations_title +import com.vitorpamplona.amethyst.commons.resources.cordn_invitations_unreachable +import com.vitorpamplona.amethyst.commons.resources.cordn_invitations_via import com.vitorpamplona.amethyst.commons.ui.components.EmptyState import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.model.cordn.CordnInvitation @@ -69,6 +83,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.cordn.CoordinatorI import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.cordn.coordinatorDisplayName import com.vitorpamplona.amethyst.ui.stringRes import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.pluralStringResource /** * Invitations to cordn groups, waiting to be answered. @@ -106,7 +121,7 @@ fun CordnInvitationsScreen( var invitations by remember { mutableStateOf<CordnInvitations?>(null) } var busy by remember { mutableStateOf(false) } var error by remember { mutableStateOf<String?>(null) } - val loadFailed = stringRes(R.string.cordn_invitations_load_failed) + val loadFailed = stringRes(Res.string.cordn_invitations_load_failed) suspend fun reload() { busy = true @@ -130,10 +145,10 @@ fun CordnInvitationsScreen( Icon(MaterialSymbols.AutoMirrored.ArrowBack, contentDescription = stringRes(Res.string.back)) } }, - title = { Text(stringRes(R.string.cordn_invitations_title)) }, + title = { Text(stringRes(Res.string.cordn_invitations_title)) }, actions = { IconButton(onClick = { scope.launch { reload() } }, enabled = !busy) { - Icon(MaterialSymbols.Refresh, contentDescription = stringRes(R.string.cordn_invitations_refresh)) + Icon(MaterialSymbols.Refresh, contentDescription = stringRes(Res.string.cordn_invitations_refresh)) } }, ) @@ -150,8 +165,8 @@ fun CordnInvitationsScreen( ) { if (runtime == null) { EmptyState( - title = stringRes(R.string.cordn_group_unavailable), - description = stringRes(R.string.cordn_group_unavailable_detail), + title = stringRes(Res.string.cordn_group_unavailable), + description = stringRes(Res.string.cordn_group_unavailable_detail), ) return@Column } @@ -163,7 +178,7 @@ fun CordnInvitationsScreen( val coordinators by runtime.coordinators.collectAsStateWithLifecycle() Text( - text = stringRes(R.string.cordn_invitations_explainer), + text = stringRes(Res.string.cordn_invitations_explainer), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -212,7 +227,7 @@ fun CordnInvitationsScreen( NoticeCard( title = stringRes( - R.string.cordn_invitations_unreachable, + Res.string.cordn_invitations_unreachable, coordinatorDisplayName(it.coordinator.pubKey, it.coordinator.label, accountViewModel), ), detail = it.reason, @@ -225,7 +240,7 @@ fun CordnInvitationsScreen( // friend swears they sent simply does not exist here. loaded.skipped.forEach { NoticeCard( - title = stringRes(R.string.cordn_invitations_skipped), + title = stringRes(Res.string.cordn_invitations_skipped), detail = it.reason, isError = false, ) @@ -235,9 +250,9 @@ fun CordnInvitationsScreen( Text( text = if (coordinators.isEmpty()) { - stringRes(R.string.cordn_invitations_no_coordinators) + stringRes(Res.string.cordn_invitations_no_coordinators) } else { - stringRes(R.string.cordn_invitations_none) + stringRes(Res.string.cordn_invitations_none) }, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, @@ -276,7 +291,7 @@ private fun InvitationCard( // "Already in it", never "invited you" — see the screen KDoc. Text( - text = pluralStringResource(R.plurals.cordn_invitations_members, welcome.members.size, welcome.members.size), + text = pluralStringResource(Res.plurals.cordn_invitations_members, welcome.members.size, welcome.members.size), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -300,7 +315,7 @@ private fun InvitationCard( } if (welcome.members.size > MEMBER_FACES) { Text( - text = stringRes(R.string.cordn_invitations_members_more, welcome.members.size - MEMBER_FACES), + text = stringRes(Res.string.cordn_invitations_members_more, welcome.members.size - MEMBER_FACES), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -329,7 +344,7 @@ private fun InvitationCard( size = 20.dp, ) { name -> Text( - text = stringRes(R.string.cordn_invitations_via, name), + text = stringRes(Res.string.cordn_invitations_via, name), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -340,11 +355,11 @@ private fun InvitationCard( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, ) { - Button(onClick = onAccept) { Text(stringRes(R.string.cordn_invitations_accept)) } - TextButton(onClick = onDecline) { Text(stringRes(R.string.cordn_invitations_decline)) } + Button(onClick = onAccept) { Text(stringRes(Res.string.cordn_invitations_accept)) } + TextButton(onClick = onDecline) { Text(stringRes(Res.string.cordn_invitations_decline)) } } Text( - text = stringRes(R.string.cordn_invitations_decline_warning), + text = stringRes(Res.string.cordn_invitations_decline_warning), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnMessageRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnMessageRow.kt index 189fca42a9..031512faea 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnMessageRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnMessageRow.kt @@ -55,7 +55,6 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.chats.ui.ChatDivisor import com.vitorpamplona.amethyst.commons.chats.ui.UserDisplayNameLayout import com.vitorpamplona.amethyst.commons.cordn.CordnMentions @@ -67,6 +66,24 @@ import com.vitorpamplona.amethyst.commons.model.navigation.Route import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.copied_to_clipboard import com.vitorpamplona.amethyst.commons.resources.copy_text +import com.vitorpamplona.amethyst.commons.resources.cordn_action_delete +import com.vitorpamplona.amethyst.commons.resources.cordn_action_edit +import com.vitorpamplona.amethyst.commons.resources.cordn_action_go_to_message +import com.vitorpamplona.amethyst.commons.resources.cordn_action_pin +import com.vitorpamplona.amethyst.commons.resources.cordn_action_reply +import com.vitorpamplona.amethyst.commons.resources.cordn_action_unpin +import com.vitorpamplona.amethyst.commons.resources.cordn_chat_unread_divider +import com.vitorpamplona.amethyst.commons.resources.cordn_chat_yesterday +import com.vitorpamplona.amethyst.commons.resources.cordn_delete_confirm_body +import com.vitorpamplona.amethyst.commons.resources.cordn_delete_confirm_title +import com.vitorpamplona.amethyst.commons.resources.cordn_delivery_accepted +import com.vitorpamplona.amethyst.commons.resources.cordn_message_deleted +import com.vitorpamplona.amethyst.commons.resources.cordn_message_details_coordinator +import com.vitorpamplona.amethyst.commons.resources.cordn_message_details_cursor +import com.vitorpamplona.amethyst.commons.resources.cordn_message_details_sent_at +import com.vitorpamplona.amethyst.commons.resources.cordn_message_details_title +import com.vitorpamplona.amethyst.commons.resources.cordn_message_edited +import com.vitorpamplona.amethyst.commons.resources.cordn_reactions_title import com.vitorpamplona.amethyst.commons.resources.quick_action_share import com.vitorpamplona.amethyst.commons.resources.today import com.vitorpamplona.amethyst.commons.ui.components.ClickableBox @@ -292,7 +309,7 @@ private fun CordnBubbleContents( when { text == null -> Text( - text = stringRes(R.string.cordn_message_deleted), + text = stringRes(Res.string.cordn_message_deleted), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -448,7 +465,7 @@ private fun CordnReactionDetailSheet( ) { Column(Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(bottom = 24.dp)) { Text( - text = stringRes(R.string.cordn_reactions_title), + text = stringRes(Res.string.cordn_reactions_title), style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp), ) @@ -519,7 +536,7 @@ internal fun CordnQuotedMessage( val body = if (annotations.isDeleted(parent.envelope.id)) { - stringRes(R.string.cordn_message_deleted) + stringRes(Res.string.cordn_message_deleted) } else { annotations.contentOf(parent.envelope.id).orEmpty() } @@ -575,7 +592,7 @@ private fun CordnQuoteActionSheet( Column(Modifier.padding(bottom = 24.dp)) { TileRow { if (onGoToMessage != null) { - ActionTile(MaterialSymbols.ArrowUpward, stringRes(R.string.cordn_action_go_to_message)) { + ActionTile(MaterialSymbols.ArrowUpward, stringRes(Res.string.cordn_action_go_to_message)) { onGoToMessage() onDismiss() } @@ -594,7 +611,7 @@ private fun CordnQuoteActionSheet( */ @Composable internal fun UnreadDivider() { - ChatDivisor(stringRes(R.string.cordn_chat_unread_divider), MaterialTheme.colorScheme.primary) + ChatDivisor(stringRes(Res.string.cordn_chat_unread_divider), MaterialTheme.colorScheme.primary) } /** @@ -618,7 +635,7 @@ private fun CordnMessageFooter( if (isPinned) { Icon( symbol = MaterialSymbols.PushPin, - contentDescription = stringRes(R.string.cordn_action_pin), + contentDescription = stringRes(Res.string.cordn_action_pin), modifier = Modifier.size(12.dp), tint = MaterialTheme.colorScheme.primary, ) @@ -627,7 +644,7 @@ private fun CordnMessageFooter( if (isEdited) { Text( - text = stringRes(R.string.cordn_message_edited), + text = stringRes(Res.string.cordn_message_edited), fontSize = Font12SP, color = MaterialTheme.colorScheme.placeholderText, maxLines = 1, @@ -658,7 +675,7 @@ private fun CordnMessageFooter( Spacer(StdHorzSpacer) Icon( symbol = MaterialSymbols.Done, - contentDescription = stringRes(R.string.cordn_delivery_accepted), + contentDescription = stringRes(Res.string.cordn_delivery_accepted), modifier = Modifier.size(12.dp), tint = MaterialTheme.colorScheme.allGoodColor, ) @@ -711,18 +728,18 @@ private fun CordnMessageDetailsSheet( verticalArrangement = Arrangement.spacedBy(6.dp), ) { Text( - text = stringRes(R.string.cordn_message_details_title), + text = stringRes(Res.string.cordn_message_details_title), style = MaterialTheme.typography.titleSmall, ) - DetailLine(stringRes(R.string.cordn_message_details_sent_at), sentAt) - DetailLine(stringRes(R.string.cordn_message_details_cursor), message.cursor.toString()) + DetailLine(stringRes(Res.string.cordn_message_details_sent_at), sentAt) + DetailLine(stringRes(Res.string.cordn_message_details_cursor), message.cursor.toString()) DetailLine( - stringRes(R.string.cordn_message_details_coordinator), + stringRes(Res.string.cordn_message_details_coordinator), coordinatorDisplayName(coordinatorPubKey, label = null, accountViewModel = accountViewModel), ) if (isMine) { Text( - text = stringRes(R.string.cordn_delivery_accepted), + text = stringRes(Res.string.cordn_delivery_accepted), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.allGoodColor, ) @@ -797,10 +814,10 @@ private fun CordnMessageActionSheet( if (confirmingDelete) { QuickActionAlertDialog( - title = stringRes(R.string.cordn_delete_confirm_title), - textContent = stringRes(R.string.cordn_delete_confirm_body), + title = stringRes(Res.string.cordn_delete_confirm_title), + textContent = stringRes(Res.string.cordn_delete_confirm_body), buttonIcon = MaterialSymbols.Delete, - buttonText = stringRes(R.string.cordn_action_delete), + buttonText = stringRes(Res.string.cordn_action_delete), onClickDoOnce = performDelete, onClickDontShowAgain = { accountViewModel.account.settings.setHideDeleteRequestDialog() @@ -843,7 +860,7 @@ private fun CordnMessageActionSheet( TileRow { if (isLive) { - ActionTile(MaterialSymbols.AutoMirrored.Chat, stringRes(R.string.cordn_action_reply)) { + ActionTile(MaterialSymbols.AutoMirrored.Chat, stringRes(Res.string.cordn_action_reply)) { onReply() onDismiss() } @@ -853,7 +870,7 @@ private fun CordnMessageActionSheet( // message rather than only on your own. ActionTile( MaterialSymbols.PushPin, - stringRes(if (isPinned) R.string.cordn_action_unpin else R.string.cordn_action_pin), + stringRes(if (isPinned) Res.string.cordn_action_unpin else Res.string.cordn_action_pin), ) { onTogglePin() onDismiss() @@ -863,11 +880,11 @@ private fun CordnMessageActionSheet( // anyone else. Hiding them here is the same rule, stated where it stops // being a surprise. if (isMine && isLive) { - ActionTile(MaterialSymbols.Edit, stringRes(R.string.cordn_action_edit)) { + ActionTile(MaterialSymbols.Edit, stringRes(Res.string.cordn_action_edit)) { onEdit() onDismiss() } - ActionTile(MaterialSymbols.Delete, stringRes(R.string.cordn_action_delete), isDestructive = true) { + ActionTile(MaterialSymbols.Delete, stringRes(Res.string.cordn_action_delete), isDestructive = true) { // A withdrawal cannot be taken back and the coordinator keeps // the ciphertext either way, so it is worth one question — // unless the account has already opted out of being asked. @@ -989,7 +1006,7 @@ internal fun DaySeparator( val label = when (day) { today -> stringRes(Res.string.today) - today.minusDays(1) -> stringRes(R.string.cordn_chat_yesterday) + today.minusDays(1) -> stringRes(Res.string.cordn_chat_yesterday) // Year included only when it is not this one: printing 2026 on every // divider all year is noise. else -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/CordnBackupScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/CordnBackupScreen.kt index 3560b60be6..0876d4cf5d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/CordnBackupScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/CordnBackupScreen.kt @@ -52,13 +52,26 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp -import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.cancel +import com.vitorpamplona.amethyst.commons.resources.cordn_backup_contents_body +import com.vitorpamplona.amethyst.commons.resources.cordn_backup_contents_title +import com.vitorpamplona.amethyst.commons.resources.cordn_backup_explainer +import com.vitorpamplona.amethyst.commons.resources.cordn_backup_export +import com.vitorpamplona.amethyst.commons.resources.cordn_backup_exported +import com.vitorpamplona.amethyst.commons.resources.cordn_backup_failed +import com.vitorpamplona.amethyst.commons.resources.cordn_backup_passphrase +import com.vitorpamplona.amethyst.commons.resources.cordn_backup_restore +import com.vitorpamplona.amethyst.commons.resources.cordn_backup_restore_body +import com.vitorpamplona.amethyst.commons.resources.cordn_backup_restore_confirm_body +import com.vitorpamplona.amethyst.commons.resources.cordn_backup_restore_confirm_title +import com.vitorpamplona.amethyst.commons.resources.cordn_backup_restored import com.vitorpamplona.amethyst.commons.resources.cordn_backup_section_export import com.vitorpamplona.amethyst.commons.resources.cordn_backup_section_passphrase import com.vitorpamplona.amethyst.commons.resources.cordn_backup_section_restore import com.vitorpamplona.amethyst.commons.resources.cordn_backup_title +import com.vitorpamplona.amethyst.commons.resources.cordn_group_unavailable +import com.vitorpamplona.amethyst.commons.resources.cordn_group_unavailable_detail import com.vitorpamplona.amethyst.commons.ui.components.EmptyState import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton @@ -113,9 +126,9 @@ fun CordnBackupScreen( var error by remember { mutableStateOf<String?>(null) } var pendingRestore by remember { mutableStateOf<Uri?>(null) } - val exported = stringRes(R.string.cordn_backup_exported) - val restored = stringRes(R.string.cordn_backup_restored) - val failed = stringRes(R.string.cordn_backup_failed) + val exported = stringRes(Res.string.cordn_backup_exported) + val restored = stringRes(Res.string.cordn_backup_restored) + val failed = stringRes(Res.string.cordn_backup_failed) val saver = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/octet-stream")) { uri -> @@ -179,8 +192,8 @@ fun CordnBackupScreen( // The app's own empty state, centred and titled, rather than a // sentence stranded in the top-left corner. EmptyState( - title = stringRes(R.string.cordn_group_unavailable), - description = stringRes(R.string.cordn_group_unavailable_detail), + title = stringRes(Res.string.cordn_group_unavailable), + description = stringRes(Res.string.cordn_group_unavailable_detail), modifier = Modifier.padding(padding), ) return@Scaffold @@ -196,7 +209,7 @@ fun CordnBackupScreen( verticalArrangement = Arrangement.spacedBy(20.dp), ) { Text( - text = stringRes(R.string.cordn_backup_explainer), + text = stringRes(Res.string.cordn_backup_explainer), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -212,7 +225,7 @@ fun CordnBackupScreen( passphrase = it error = null }, - label = { Text(stringRes(R.string.cordn_backup_passphrase)) }, + label = { Text(stringRes(Res.string.cordn_backup_passphrase)) }, visualTransformation = PasswordVisualTransformation(), // The dots are only half of it. Without the password // keyboard type the IME treats this as ordinary prose: @@ -230,9 +243,9 @@ fun CordnBackupScreen( SettingsSection(Res.string.cordn_backup_section_export) { SettingsFormBlock { - Text(stringRes(R.string.cordn_backup_contents_title), style = MaterialTheme.typography.titleSmall) + Text(stringRes(Res.string.cordn_backup_contents_title), style = MaterialTheme.typography.titleSmall) Text( - text = stringRes(R.string.cordn_backup_contents_body), + text = stringRes(Res.string.cordn_backup_contents_body), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -248,7 +261,7 @@ fun CordnBackupScreen( // Key derivation here is scrypt, which is slow on // purpose, so a greyed-out button was the only sign // anything was happening for several seconds. - BusyLabel(busy, stringRes(R.string.cordn_backup_export)) + BusyLabel(busy, stringRes(Res.string.cordn_backup_export)) } } } @@ -256,7 +269,7 @@ fun CordnBackupScreen( SettingsSection(Res.string.cordn_backup_section_restore) { SettingsFormBlock { Text( - text = stringRes(R.string.cordn_backup_restore_body), + text = stringRes(Res.string.cordn_backup_restore_body), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -266,7 +279,7 @@ fun CordnBackupScreen( enabled = !busy && passphrase.isNotBlank(), modifier = Modifier.fillMaxWidth(), ) { - BusyLabel(busy, stringRes(R.string.cordn_backup_restore)) + BusyLabel(busy, stringRes(Res.string.cordn_backup_restore)) } } } @@ -276,8 +289,8 @@ fun CordnBackupScreen( pendingRestore?.let { uri -> AlertDialog( onDismissRequest = { pendingRestore = null }, - title = { Text(stringRes(R.string.cordn_backup_restore_confirm_title)) }, - text = { Text(stringRes(R.string.cordn_backup_restore_confirm_body)) }, + title = { Text(stringRes(Res.string.cordn_backup_restore_confirm_title)) }, + text = { Text(stringRes(Res.string.cordn_backup_restore_confirm_body)) }, confirmButton = { TextButton(onClick = { pendingRestore = null @@ -302,7 +315,7 @@ fun CordnBackupScreen( } } }) { - Text(stringRes(R.string.cordn_backup_restore), color = MaterialTheme.colorScheme.error) + Text(stringRes(Res.string.cordn_backup_restore), color = MaterialTheme.colorScheme.error) } }, dismissButton = { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/CordnCoordinatorsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/CordnCoordinatorsScreen.kt index 01b0a3c93f..3459eec409 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/CordnCoordinatorsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/CordnCoordinatorsScreen.kt @@ -58,7 +58,6 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.cordn.CoordinatorConfig import com.vitorpamplona.amethyst.commons.cordn.CoordinatorHealth import com.vitorpamplona.amethyst.commons.cordn.CordnCoordinatorDiscovery @@ -68,10 +67,45 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.cancel +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_add_action +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_add_failed +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_copy_key +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_discover_action +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_discover_add +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_discover_explainer +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_discover_failed +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_discover_none +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_discover_seen +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_discover_unheard +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_explainer +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_health_down +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_health_ok +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_health_retrying +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_health_unknown +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_identify +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_key +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_label +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_label_note +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_more +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_none +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_purge +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_purge_body +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_purge_title +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_relays_label +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_remove +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_rename import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_section_discover import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_section_manual import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_section_yours +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_server +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_server_claim +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_server_silent import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_title +import com.vitorpamplona.amethyst.commons.resources.cordn_create_coordinator_pubkey +import com.vitorpamplona.amethyst.commons.resources.cordn_create_coordinator_relays +import com.vitorpamplona.amethyst.commons.resources.cordn_group_unavailable +import com.vitorpamplona.amethyst.commons.resources.cordn_group_unavailable_detail +import com.vitorpamplona.amethyst.commons.resources.cordn_info_save import com.vitorpamplona.amethyst.commons.ui.components.CrossfadeIfEnabled import com.vitorpamplona.amethyst.commons.ui.components.EmptyState import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav @@ -127,8 +161,8 @@ fun CordnCoordinatorsScreen( // The app's own empty state, centred and titled, rather than a // sentence stranded in the top-left corner. EmptyState( - title = stringRes(R.string.cordn_group_unavailable), - description = stringRes(R.string.cordn_group_unavailable_detail), + title = stringRes(Res.string.cordn_group_unavailable), + description = stringRes(Res.string.cordn_group_unavailable_detail), modifier = Modifier.padding(padding), ) return@Scaffold @@ -146,7 +180,7 @@ fun CordnCoordinatorsScreen( verticalArrangement = Arrangement.spacedBy(20.dp), ) { Text( - text = stringRes(R.string.cordn_coordinators_explainer), + text = stringRes(Res.string.cordn_coordinators_explainer), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -165,7 +199,7 @@ fun CordnCoordinatorsScreen( if (coordinators.isEmpty()) { Text( - text = stringRes(R.string.cordn_coordinators_none), + text = stringRes(Res.string.cordn_coordinators_none), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -225,7 +259,7 @@ private fun CoordinatorCard( IconButton(onClick = { renaming = true }) { Icon( symbol = MaterialSymbols.Edit, - contentDescription = stringRes(R.string.cordn_coordinators_rename), + contentDescription = stringRes(Res.string.cordn_coordinators_rename), modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -264,10 +298,10 @@ private fun CoordinatorCard( // away on the clipboard, which is how a comparison is actually done. val coordinator = remember(config.pubKey) { LocalCache.getOrCreateUser(config.pubKey) } CopyableKeyRow( - label = stringRes(R.string.cordn_coordinators_key), + label = stringRes(Res.string.cordn_coordinators_key), shown = coordinator.pubkeyDisplayHex(), copied = coordinator.pubkeyNpub(), - copyDescription = stringRes(R.string.cordn_coordinators_copy_key), + copyDescription = stringRes(Res.string.cordn_coordinators_copy_key), ) // Every relay, not the first two and a count: this is the management @@ -275,7 +309,7 @@ private fun CoordinatorCard( // came here to check or to rule out. Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { Text( - text = stringRes(R.string.cordn_coordinators_relays_label), + text = stringRes(Res.string.cordn_coordinators_relays_label), style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.SemiBold, ) @@ -304,10 +338,10 @@ private fun CoordinatorCard( text = info?.let { stringRes( - R.string.cordn_coordinators_server, + Res.string.cordn_coordinators_server, listOfNotNull(it.name, it.version, it.protocolVersion).joinToString(" \u00b7 "), ) - } ?: stringRes(R.string.cordn_coordinators_server_silent), + } ?: stringRes(Res.string.cordn_coordinators_server_silent), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -315,7 +349,7 @@ private fun CoordinatorCard( // Said next to it, every time, because a name on a settings // screen reads as verified and this one is not: §8.5 makes // the pubkey the identity and nothing else. - text = stringRes(R.string.cordn_coordinators_server_claim), + text = stringRes(Res.string.cordn_coordinators_server_claim), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -333,7 +367,7 @@ private fun CoordinatorCard( infoChecked = true } }) { - Text(stringRes(R.string.cordn_coordinators_identify)) + Text(stringRes(Res.string.cordn_coordinators_identify)) } Spacer(Modifier.weight(1f)) @@ -345,14 +379,14 @@ private fun CoordinatorCard( IconButton(onClick = { menuOpen = true }) { Icon( symbol = MaterialSymbols.MoreVert, - contentDescription = stringRes(R.string.cordn_coordinators_more), + contentDescription = stringRes(Res.string.cordn_coordinators_more), modifier = Modifier.size(20.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { DropdownMenuItem( - text = { Text(stringRes(R.string.cordn_coordinators_remove)) }, + text = { Text(stringRes(Res.string.cordn_coordinators_remove)) }, onClick = { menuOpen = false scope.launch { runtime.forget(config.pubKey) } @@ -361,7 +395,7 @@ private fun CoordinatorCard( DropdownMenuItem( text = { Text( - text = stringRes(R.string.cordn_coordinators_purge), + text = stringRes(Res.string.cordn_coordinators_purge), color = MaterialTheme.colorScheme.error, ) }, @@ -378,14 +412,14 @@ private fun CoordinatorCard( if (confirmingPurge) { AlertDialog( onDismissRequest = { confirmingPurge = false }, - title = { Text(stringRes(R.string.cordn_coordinators_purge_title)) }, - text = { Text(stringRes(R.string.cordn_coordinators_purge_body)) }, + title = { Text(stringRes(Res.string.cordn_coordinators_purge_title)) }, + text = { Text(stringRes(Res.string.cordn_coordinators_purge_body)) }, confirmButton = { TextButton(onClick = { confirmingPurge = false scope.launch { runtime.purge(config.pubKey) } }) { - Text(stringRes(R.string.cordn_coordinators_purge), color = MaterialTheme.colorScheme.error) + Text(stringRes(Res.string.cordn_coordinators_purge), color = MaterialTheme.colorScheme.error) } }, dismissButton = { @@ -409,10 +443,10 @@ private fun CoordinatorCard( private fun HealthLine(state: CoordinatorHealth.State) { val text = when { - state.isUnknown -> stringRes(R.string.cordn_coordinators_health_unknown) - state.isDown -> stringRes(R.string.cordn_coordinators_health_down, state.consecutiveFailures) - state.consecutiveFailures > 0 -> stringRes(R.string.cordn_coordinators_health_retrying) - else -> stringRes(R.string.cordn_coordinators_health_ok) + state.isUnknown -> stringRes(Res.string.cordn_coordinators_health_unknown) + state.isDown -> stringRes(Res.string.cordn_coordinators_health_down, state.consecutiveFailures) + state.consecutiveFailures > 0 -> stringRes(Res.string.cordn_coordinators_health_retrying) + else -> stringRes(Res.string.cordn_coordinators_health_ok) } Text( @@ -447,10 +481,10 @@ private fun DiscoverCoordinators( var result by remember { mutableStateOf<CordnCoordinatorDiscovery.Result?>(null) } var busy by remember { mutableStateOf(false) } var error by remember { mutableStateOf<String?>(null) } - val failed = stringRes(R.string.cordn_coordinators_discover_failed) + val failed = stringRes(Res.string.cordn_coordinators_discover_failed) Text( - text = stringRes(R.string.cordn_coordinators_discover_explainer), + text = stringRes(Res.string.cordn_coordinators_discover_explainer), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -475,7 +509,7 @@ private fun DiscoverCoordinators( CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp) Spacer(Modifier.width(8.dp)) } - Text(stringRes(R.string.cordn_coordinators_discover_action)) + Text(stringRes(Res.string.cordn_coordinators_discover_action)) } error?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error) } @@ -497,11 +531,11 @@ private fun DiscoverCoordinators( Text( text = if (found.unreachable.isEmpty()) { - stringRes(R.string.cordn_coordinators_discover_none) + stringRes(Res.string.cordn_coordinators_discover_none) } else { // "Nobody is announcing" and "we were not told" are // different answers and only one of them is final. - stringRes(R.string.cordn_coordinators_discover_unheard, found.unreachable.size) + stringRes(Res.string.cordn_coordinators_discover_unheard, found.unreachable.size) }, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, @@ -529,19 +563,19 @@ private fun RenameCoordinatorDialog( AlertDialog( onDismissRequest = onDismiss, - title = { Text(stringRes(R.string.cordn_coordinators_rename)) }, + title = { Text(stringRes(Res.string.cordn_coordinators_rename)) }, text = { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { OutlinedTextField( value = input, onValueChange = { input = it }, - label = { Text(stringRes(R.string.cordn_coordinators_label)) }, + label = { Text(stringRes(Res.string.cordn_coordinators_label)) }, singleLine = true, modifier = Modifier.fillMaxWidth(), ) Text( // The same caution the add form gives, for the same reason. - text = stringRes(R.string.cordn_coordinators_label_note), + text = stringRes(Res.string.cordn_coordinators_label_note), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -549,7 +583,7 @@ private fun RenameCoordinatorDialog( }, confirmButton = { TextButton(onClick = { onSave(input.trim().ifEmpty { null }) }) { - Text(stringRes(R.string.cordn_info_save)) + Text(stringRes(Res.string.cordn_info_save)) } }, dismissButton = { @@ -601,7 +635,7 @@ private fun DiscoveredCard( Text( // Staleness matters more here than anywhere: the last live // survey found most announcements were months-dead demos. - text = stringRes(R.string.cordn_coordinators_discover_seen, timeAgoNoDot(offer.announcedAt).trim()), + text = stringRes(Res.string.cordn_coordinators_discover_seen, timeAgoNoDot(offer.announcedAt).trim()), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -619,7 +653,7 @@ private fun DiscoveredCard( enabled = !busy, modifier = Modifier.padding(top = 4.dp), ) { - Text(stringRes(R.string.cordn_coordinators_discover_add)) + Text(stringRes(Res.string.cordn_coordinators_discover_add)) } } } @@ -633,7 +667,7 @@ private fun AddCoordinator(runtime: CordnRuntime) { var labelInput by remember { mutableStateOf("") } var error by remember { mutableStateOf<String?>(null) } var busy by remember { mutableStateOf(false) } - val failed = stringRes(R.string.cordn_coordinators_add_failed) + val failed = stringRes(Res.string.cordn_coordinators_add_failed) val config = remember(pubKeyInput, relaysInput, labelInput) { @@ -652,7 +686,7 @@ private fun AddCoordinator(runtime: CordnRuntime) { pubKeyInput = it error = null }, - label = { Text(stringRes(R.string.cordn_create_coordinator_pubkey)) }, + label = { Text(stringRes(Res.string.cordn_create_coordinator_pubkey)) }, singleLine = true, modifier = Modifier.fillMaxWidth(), ) @@ -662,14 +696,14 @@ private fun AddCoordinator(runtime: CordnRuntime) { relaysInput = it error = null }, - label = { Text(stringRes(R.string.cordn_create_coordinator_relays)) }, + label = { Text(stringRes(Res.string.cordn_create_coordinator_relays)) }, singleLine = false, modifier = Modifier.fillMaxWidth(), ) OutlinedTextField( value = labelInput, onValueChange = { labelInput = it }, - label = { Text(stringRes(R.string.cordn_coordinators_label)) }, + label = { Text(stringRes(Res.string.cordn_coordinators_label)) }, singleLine = true, modifier = Modifier.fillMaxWidth(), ) @@ -677,7 +711,7 @@ private fun AddCoordinator(runtime: CordnRuntime) { Text( // A label is ours, never theirs. A coordinator cannot prove a name and // this one is not asked for one. - text = stringRes(R.string.cordn_coordinators_label_note), + text = stringRes(Res.string.cordn_coordinators_label_note), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -705,6 +739,6 @@ private fun AddCoordinator(runtime: CordnRuntime) { enabled = !busy && config != null, modifier = Modifier.fillMaxWidth(), ) { - Text(stringRes(R.string.cordn_coordinators_add_action)) + Text(stringRes(Res.string.cordn_coordinators_add_action)) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/CordnKeyPackagesScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/CordnKeyPackagesScreen.kt index 5f33e13b24..b3ebf82863 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/CordnKeyPackagesScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/CordnKeyPackagesScreen.kt @@ -47,14 +47,28 @@ import androidx.compose.runtime.rememberCoroutineScope 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.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.cordn.CoordinatorConfig import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_none +import com.vitorpamplona.amethyst.commons.resources.cordn_group_unavailable +import com.vitorpamplona.amethyst.commons.resources.cordn_group_unavailable_detail +import com.vitorpamplona.amethyst.commons.resources.cordn_keypackages_disclosure +import com.vitorpamplona.amethyst.commons.resources.cordn_keypackages_explainer +import com.vitorpamplona.amethyst.commons.resources.cordn_keypackages_failed +import com.vitorpamplona.amethyst.commons.resources.cordn_keypackages_last_resort_no +import com.vitorpamplona.amethyst.commons.resources.cordn_keypackages_last_resort_yes +import com.vitorpamplona.amethyst.commons.resources.cordn_keypackages_orphans +import com.vitorpamplona.amethyst.commons.resources.cordn_keypackages_orphans_body +import com.vitorpamplona.amethyst.commons.resources.cordn_keypackages_publish +import com.vitorpamplona.amethyst.commons.resources.cordn_keypackages_publish_last_resort import com.vitorpamplona.amethyst.commons.resources.cordn_keypackages_section +import com.vitorpamplona.amethyst.commons.resources.cordn_keypackages_summary import com.vitorpamplona.amethyst.commons.resources.cordn_keypackages_title +import com.vitorpamplona.amethyst.commons.resources.cordn_keypackages_topup_note +import com.vitorpamplona.amethyst.commons.resources.cordn_keypackages_withdraw_all +import com.vitorpamplona.amethyst.commons.resources.cordn_keypackages_withdraw_orphans import com.vitorpamplona.amethyst.commons.ui.components.EmptyState import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton @@ -106,8 +120,8 @@ fun CordnKeyPackagesScreen( ) { padding -> if (runtime == null) { EmptyState( - title = stringRes(R.string.cordn_group_unavailable), - description = stringRes(R.string.cordn_group_unavailable_detail), + title = stringRes(Res.string.cordn_group_unavailable), + description = stringRes(Res.string.cordn_group_unavailable_detail), modifier = Modifier.padding(padding), ) return@Scaffold @@ -125,7 +139,7 @@ fun CordnKeyPackagesScreen( verticalArrangement = Arrangement.spacedBy(20.dp), ) { Text( - text = stringRes(R.string.cordn_keypackages_explainer), + text = stringRes(Res.string.cordn_keypackages_explainer), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -140,7 +154,7 @@ fun CordnKeyPackagesScreen( SettingsFormBlock { if (coordinators.isEmpty()) { Text( - text = stringRes(R.string.cordn_coordinators_none), + text = stringRes(Res.string.cordn_coordinators_none), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -169,7 +183,7 @@ private fun CoordinatorKeyPackages( var rows by remember(config.pubKey) { mutableStateOf<List<CordnKeyPackageRow>?>(null) } var busy by remember(config.pubKey) { mutableStateOf(false) } var error by remember(config.pubKey) { mutableStateOf<String?>(null) } - val failed = stringRes(R.string.cordn_keypackages_failed) + val failed = stringRes(Res.string.cordn_keypackages_failed) suspend fun reload() { busy = true @@ -204,15 +218,15 @@ private fun CoordinatorKeyPackages( val hasLastResort = loaded.any { it.lastResort } Text( - text = pluralStringRes(LocalContext.current, R.plurals.cordn_keypackages_summary, single, single), + text = pluralStringRes(Res.plurals.cordn_keypackages_summary, single, single), style = MaterialTheme.typography.bodyMedium, ) Text( text = if (hasLastResort) { - stringRes(R.string.cordn_keypackages_last_resort_yes) + stringRes(Res.string.cordn_keypackages_last_resort_yes) } else { - stringRes(R.string.cordn_keypackages_last_resort_no) + stringRes(Res.string.cordn_keypackages_last_resort_no) }, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, @@ -225,11 +239,11 @@ private fun CoordinatorKeyPackages( ) { Column(Modifier.padding(12.dp)) { Text( - text = pluralStringRes(LocalContext.current, R.plurals.cordn_keypackages_orphans, orphans.size, orphans.size), + text = pluralStringRes(Res.plurals.cordn_keypackages_orphans, orphans.size, orphans.size), style = MaterialTheme.typography.titleSmall, ) Text( - text = stringRes(R.string.cordn_keypackages_orphans_body), + text = stringRes(Res.string.cordn_keypackages_orphans_body), style = MaterialTheme.typography.bodySmall, ) TextButton(onClick = { @@ -242,7 +256,7 @@ private fun CoordinatorKeyPackages( reload() } }) { - Text(stringRes(R.string.cordn_keypackages_withdraw_orphans)) + Text(stringRes(Res.string.cordn_keypackages_withdraw_orphans)) } } } @@ -251,7 +265,7 @@ private fun CoordinatorKeyPackages( Text( // Above the buttons, because it is the part that cannot be taken // back: §8.4 makes publishing an attributable, permanent record. - text = stringRes(R.string.cordn_keypackages_disclosure), + text = stringRes(Res.string.cordn_keypackages_disclosure), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -274,7 +288,7 @@ private fun CoordinatorKeyPackages( }, enabled = !busy, ) { - Text(stringRes(R.string.cordn_keypackages_publish)) + Text(stringRes(Res.string.cordn_keypackages_publish)) } OutlinedButton( onClick = { @@ -289,7 +303,7 @@ private fun CoordinatorKeyPackages( }, enabled = !busy && !hasLastResort, ) { - Text(stringRes(R.string.cordn_keypackages_publish_last_resort)) + Text(stringRes(Res.string.cordn_keypackages_publish_last_resort)) } if (loaded.isNotEmpty()) { TextButton( @@ -305,7 +319,7 @@ private fun CoordinatorKeyPackages( }, enabled = !busy, ) { - Text(stringRes(R.string.cordn_keypackages_withdraw_all), color = MaterialTheme.colorScheme.error) + Text(stringRes(Res.string.cordn_keypackages_withdraw_all), color = MaterialTheme.colorScheme.error) } } } @@ -313,7 +327,7 @@ private fun CoordinatorKeyPackages( Text( // The rule CordnRuntime.maintainKeyPackages implements, said where // someone can see that it applies to them. - text = stringRes(R.string.cordn_keypackages_topup_note), + text = stringRes(Res.string.cordn_keypackages_topup_note), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/CordnLinkScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/CordnLinkScreen.kt index 86aab6c17c..f4ac911967 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/CordnLinkScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/CordnLinkScreen.kt @@ -45,7 +45,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.unit.dp -import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.cordn.CordnLinkInspection import com.vitorpamplona.amethyst.commons.cordn.ui.CordnExposureCard import com.vitorpamplona.amethyst.commons.resources.Res @@ -60,6 +59,11 @@ import com.vitorpamplona.amethyst.commons.resources.cordn_link_no_coordinator import com.vitorpamplona.amethyst.commons.resources.cordn_link_not_joinable import com.vitorpamplona.amethyst.commons.resources.cordn_link_paste import com.vitorpamplona.amethyst.commons.resources.cordn_link_relays +import com.vitorpamplona.amethyst.commons.resources.cordn_link_request +import com.vitorpamplona.amethyst.commons.resources.cordn_link_request_disclosure +import com.vitorpamplona.amethyst.commons.resources.cordn_link_request_failed +import com.vitorpamplona.amethyst.commons.resources.cordn_link_request_sent +import com.vitorpamplona.amethyst.commons.resources.cordn_link_scan import com.vitorpamplona.amethyst.commons.resources.cordn_link_section_input import com.vitorpamplona.amethyst.commons.resources.cordn_link_title import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav @@ -102,8 +106,8 @@ fun CordnLinkScreen( var requesting by remember { mutableStateOf(false) } val runtime = accountViewModel.account.cordnRuntime val scope = rememberCoroutineScope() - val requestFailed = stringRes(R.string.cordn_link_request_failed) - val asked = stringRes(R.string.cordn_link_request_sent) + val requestFailed = stringRes(Res.string.cordn_link_request_failed) + val asked = stringRes(Res.string.cordn_link_request_sent) var scanning by remember { mutableStateOf(false) } val clipboard = LocalClipboardManager.current @@ -163,7 +167,7 @@ fun CordnLinkScreen( Text(stringResource(Res.string.cordn_link_paste)) } OutlinedButton(onClick = { scanning = true }) { - Text(stringRes(R.string.cordn_link_scan)) + Text(stringRes(Res.string.cordn_link_scan)) } if (input.isNotEmpty()) { OutlinedButton( @@ -265,7 +269,7 @@ fun CordnLinkScreen( enabled = !requesting, modifier = Modifier.fillMaxWidth(), ) { - Text(stringRes(R.string.cordn_link_request)) + Text(stringRes(Res.string.cordn_link_request)) } Text( @@ -274,7 +278,7 @@ fun CordnLinkScreen( // KeyPackage under this account's own key and tells // the coordinator this account wants into this // group, whether or not anyone ever answers (§8.4). - text = stringRes(R.string.cordn_link_request_disclosure), + text = stringRes(Res.string.cordn_link_request_disclosure), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/CordnMigrateScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/CordnMigrateScreen.kt index db9b3d4d4e..6c55c2bbb0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/CordnMigrateScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/CordnMigrateScreen.kt @@ -47,9 +47,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.cordn.CordnMigration import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.cordn_group_unavailable +import com.vitorpamplona.amethyst.commons.resources.cordn_group_unavailable_detail import com.vitorpamplona.amethyst.commons.resources.cordn_migrate_cancel import com.vitorpamplona.amethyst.commons.resources.cordn_migrate_cancel_desc import com.vitorpamplona.amethyst.commons.resources.cordn_migrate_code_note @@ -119,8 +120,8 @@ fun CordnMigrateScreen( ) { padding -> if (runtime == null) { EmptyState( - title = stringRes(R.string.cordn_group_unavailable), - description = stringRes(R.string.cordn_group_unavailable_detail), + title = stringRes(Res.string.cordn_group_unavailable), + description = stringRes(Res.string.cordn_group_unavailable_detail), modifier = Modifier.padding(padding), ) return@Scaffold diff --git a/amethyst/src/main/res/values-hi-rIN/strings.xml b/amethyst/src/main/res/values-hi-rIN/strings.xml index 60879f7b96..8343082ebf 100644 --- a/amethyst/src/main/res/values-hi-rIN/strings.xml +++ b/amethyst/src/main/res/values-hi-rIN/strings.xml @@ -353,322 +353,4 @@ <string name="mute_button">ध्वनि सक्रिय। मौन करने के लिए टाँकें</string> <string name="play">चलाएँ</string> <string name="pause">विराम</string> - <string name="backup_conflict_title">आपने अपना %1$s अन्य क्रमक में परिवर्तित किया</string> - <string name="backup_conflict_removed">हटाए गए (%1$s)</string> - <string name="backup_conflict_added">जोडे गए (%1$s)</string> - <string name="backup_conflict_changed">परिवर्तित (%1$s)</string> - <string name="backup_conflict_private_cleared">आपने सभी निजी (रहस्यीकृत) वस्तुओं को हटा दिया।</string> - <string name="backup_conflict_private_changed">आपने निजी (रहस्यीकृत) वस्तुओं का परिवर्तन भी किया। उनकी तुलना यहाँ नहीं की जा सकती।</string> - <string name="backup_conflict_private_added">आपने निजी (रहस्यीकृत) वस्तुओं को जोडा।</string> - <string name="backup_conflict_restore_mine">सुरक्षित संस्करण पुनःस्थापन</string> - <string name="backup_review_saved">इस यन्त्र पर</string> - <string name="backup_review_new">नया संस्करण</string> - <string name="backup_review_not_set">स्थापित नहीं</string> - <string name="backup_conflict_count_removed">%1$s हटाए गए</string> - <string name="backup_conflict_count_added">%1$s जोडे गए</string> - <string name="backup_conflict_count_changed">%1$s परिवर्तित</string> - <plurals name="backup_card_follows_dropped"> - <item quantity="one">आपने %1$d व्यक्ति का अनुगमन हटाया</item> - <item quantity="other">आपने %1$d व्यक्तियों का अनुगमन हटाए</item> - </plurals> - <string name="backup_card_accept">स्वीकार</string> - <string name="backup_review_title_updated">%1$s अद्यतनकृत</string> - <string name="backup_review_title_cleared">%1$s पारित</string> - <string name="backup_review_changed_by_other_app">अन्य क्रमक में परिवर्तित। %1$s</string> - <string name="backup_action_keep_count">रखें %1$s</string> - <string name="backup_action_restore_count">पुनःस्थापन %1$s</string> - <string name="backup_action_remute">पुनःमौन %1$s</string> - <string name="backup_action_rejoin">पुनःजुडें %1$s</string> - <string name="backup_action_restore_profile">परिचय पुनःस्थापध</string> - <string name="backup_action_restore_relays">पुनःप्रसारक पुनःस्थापन</string> - <string name="backup_review_kept_count">%1$s रखे गए</string> - <string name="backup_review_dropped_count">%1$s परित्यक्त</string> - <string name="backup_review_new_count">%1$s नए</string> - <string name="backup_review_tab_dropped">परित्यक्त। %1$s</string> - <string name="backup_review_tab_new">नए। %1$s</string> - <string name="backup_review_tab_edited">सम्पादित। %1$s</string> - <string name="backup_review_search_people">%1$s लोग खोजें</string> - <string name="backup_review_nothing_here">यहाँ कुछ नहीं</string> - <string name="backup_review_unmuted">%1$s मौनावस्था हटाए गए</string> - <string name="backup_review_unmuted_explainer">ये पुनः आपके सूचनावलियों तक अभिगम्य</string> - <string name="backup_review_newly_muted">%1$s नए मौनकृत</string> - <string name="backup_review_words_and_hashtags">शब्द तथा विषयसूचक</string> - <string name="backup_review_names_and_more">%1$s तथा %2$s अधिक</string> - <string name="backup_review_private_wiped_title">आपने सभी निजी वस्तुओं को हटा दिया</string> - <string name="backup_review_private_wiped_body">वे रहस्यीकृत हैं। इसलिए केवल आप देख सकते हैं कि वे क्या थे। पुनःस्थापन से सब लौटके आ जाएँगे।</string> - <string name="backup_review_private_added_title">आपने वस्तुओं को निजी किया</string> - <string name="backup_review_private_added_body">वे अब रहस्यीकृत हैं। जो कि सम्भाव्यतः ऊपर के सार्वजनिक वस्तुओं का गन्तव्य था।</string> - <string name="backup_review_private_changed_title">आपने निजी वस्तुओं का परिवर्तन किया</string> - <string name="backup_review_private_changed_body">वे रहस्यीकृत हैं। इसलिए उनकी तुलना यहाँ नहीं की जा सकती।</string> - <string name="backup_review_gone">चला गया</string> - <string name="backup_review_new_tag">नव्य</string> - <string name="backup_review_dropped_tag">परित्यक्त</string> - <string name="backup_review_demoted_tag">स्थानान्तरित</string> - <string name="backup_review_units_tag">इकाई</string> - <string name="backup_review_left_tag">शेष</string> - <string name="backup_review_renamed_tag">पुनःनामकृत</string> - <string name="backup_review_outbox">निर्गतपेटिका</string> - <string name="backup_review_outbox_explainer">आप जहाँ प्रकाशित करते हैं</string> - <string name="backup_review_inbox">आगतपेटिका</string> - <string name="backup_review_inbox_explainer">जहाँ अन्य आप तक अभिगमन करते हैं</string> - <string name="backup_review_key_replaced">ग्राहक कुंचिका प्रतिस्थापित</string> - <string name="backup_review_key_removed">ग्राहक कुंचिका हटाया गया</string> - <string name="backup_review_key_warning">नटज्साप आगे से इस कुंचिका से बद्ध होंगे। यदि इस यन्त्र में वह नहीं है तो आप सम्भाव्यतः उनको यहाँ नहीं चुका सकते।</string> - <string name="backup_review_left">शेष</string> - <string name="backup_review_joined">जुड गए</string> - <string name="backup_review_renamed">पुनःनामकृत</string> - <string name="backup_review_was_named">पूर्वतः %1$s था</string> - <string name="backup_action_block_again">पुनःबाधित करें %1$s को</string> - <string name="backup_action_restore_feeds">सूचनावलियाँ पुनःस्थापन</string> - <string name="backup_action_restore_topics">विषय पुनःस्थापन</string> - <string name="backup_action_restore_places">स्थल पुनःस्थापन</string> - <string name="backup_action_restore_providers">प्रदाता पुनःस्थापन</string> - <string name="backup_action_restore_wallet">धनकोष पुनःस्थापन</string> - <string name="backup_review_kept">रखे गए</string> - <string name="backup_review_receiving_tag">ग्रहण मध्य</string> - <string name="backup_review_listed_tag">सूचीबद्ध</string> - <string name="backup_review_searching_tag">खोज मध्य</string> - <string name="backup_review_storing_tag">अभिलेखन मध्य</string> - <string name="backup_review_trusted_tag">विश्वसनीय</string> - <string name="backup_review_untrusted_tag">अविश्वसनीय</string> - <string name="backup_review_unblocked">%1$s अबाधित</string> - <string name="backup_review_unblocked_explainer">अमेथिस्ट पुनः उनसे जुडेगा</string> - <string name="backup_review_no_longer_blocked">अब से बाधित नहीं</string> - <string name="backup_review_newly_blocked">नव्यतः बाधित</string> - <string name="backup_review_still_blocked">अभी भी बाधित</string> - <string name="backup_review_allowed_tag">अनुमत</string> - <string name="backup_review_moved_tag">स्थानान्तरित</string> - <string name="backup_review_feed">सूचनावली</string> - <string name="backup_review_topics_gone">विषय हटाए गए</string> - <string name="backup_review_places_gone">स्थल हटाए गए</string> - <string name="backup_review_new_count_caption">नव्य</string> - <string name="backup_review_hashtags_you_follow">विषयसूचक जिनका आप अनुगमन करते हैं</string> - <string name="backup_review_places_you_follow">स्थल जिनका आप अनुगमन करते हैं</string> - <string name="backup_review_services_changed">%2$s में से %1$s सेवाएँ परिवर्तित</string> - <string name="backup_review_services_changed_explainer">वे अब अन्य व्यक्ति से आते है। अथवा किसी से नहीं</string> - <string name="backup_review_service">सेवा</string> - <string name="backup_review_service_rank">श्रेणी</string> - <string name="backup_review_service_followers">अनुचर</string> - <string name="backup_review_service_topics">विषयसूची</string> - <string name="backup_review_trust_warning">श्रेणीयाँ तथा अनुचर गिनती तथा कचरालेख छालन जो आप देखते हैं इन प्रदाताओं से आते हैं।</string> - <plurals name="backup_review_payment_targets_gone"> - <item quantity="one">आपको भुगतान करने की %1$d विधि हटाया गया</item> - <item quantity="other">आपको भुगतान करने की %1$d विधियाँ हटाए गए</item> - </plurals> - <plurals name="backup_review_offers_gone"> - <item quantity="one">%1$d प्रस्ताव हटाया गया</item> - <item quantity="other">%1$d प्रस्ताव हटाए गए</item> - </plurals> - <string name="backup_review_payments_explainer">लोग जो आपको भुगतान करते हैं उसी को देखते हैं जो इस सूची में है।</string> - <string name="backup_review_offer">बोल्ट॰बारह प्रस्ताव</string> - <string name="backup_review_encrypted_cleared">आपने उसे रिक्त किया</string> - <string name="backup_review_encrypted_added">आपने उसे बनाया</string> - <string name="backup_review_encrypted_changed">आपने उसे परिवर्तित किया</string> - <string name="backup_review_wallet_body">आपके धनकोष के टकसाल तथा कुंचिकाएँ आपके लिए रहस्यीकृत हैं। इसलिए उनकी तुलना वस्तु प्रति वस्तु यहाँ नहीं की जा सकती। पुनःस्थापन उन्हीं को लौट डालेगा जिनका अभिलेखन इस यन्त्र ने किया।</string> - <string name="backup_review_concord_body">आपके कोंकोर्ड समुदाय आपके लिए रहस्यीकृत हैं। इसलिए उनकी तुलना यहाँ नहीं की जा सकती। पुनःस्थापन उन्हीं को लौट डालेगा जिनका अभिलेखन इस यन्त्र ने किया।</string> - <plurals name="backup_card_mutes_visible"> - <item quantity="one">आपने %1$d की मौनावस्था हटाया</item> - <item quantity="other">आपने %1$d की मौनावस्था हटाए</item> - </plurals> - <plurals name="backup_card_profile_lost_fields"> - <item quantity="one">आपने %1$d परिचय प्रपत्रस्थान हटाया</item> - <item quantity="other">आपने %1$d परिचय प्रपत्रस्थान हटाए</item> - </plurals> - <plurals name="backup_card_nip65_lost"> - <item quantity="one">आपने %1$d सार्वजनिक पुनःप्रसारक हटाया</item> - <item quantity="other">आपने %1$d सार्वजनिक पुनःप्रसारकों को हटाए</item> - </plurals> - <plurals name="backup_card_dm_relays_lost"> - <item quantity="one">आपने %1$d सीधासन्देश पुनःप्रसारक हटाया</item> - <item quantity="other">आपने %1$d सीधासन्देश पुनःप्रसारकों को हटाए</item> - </plurals> - <plurals name="backup_card_key_package_relays_lost"> - <item quantity="one">आपने %1$d समूहचर्चा कुंचिका पुनःप्रसारक हटाया</item> - <item quantity="other">आपने %1$d समूहचर्चा कुंचिका पुनःप्रसारकों को हटाए</item> - </plurals> - <plurals name="backup_card_search_relays_lost"> - <item quantity="one">आपने %1$d खोज पुनःप्रसारक हटाया</item> - <item quantity="other">आपने %1$d खोज पुनःप्रसारकों को हटाए</item> - </plurals> - <plurals name="backup_card_indexer_relays_lost"> - <item quantity="one">आपने %1$d अनुक्रमणीकार पुनःप्रसारक हटाया</item> - <item quantity="other">आपने %1$d अनुक्रमणीकार पुनःप्रसारकों को हटाए</item> - </plurals> - <plurals name="backup_card_relay_feeds_lost"> - <item quantity="one">आपने %1$d पुनःप्रसारक सूचनावली हटाया</item> - <item quantity="other">आपने %1$d पुनःप्रसारक सूचनावलियाँ हटाए</item> - </plurals> - <plurals name="backup_card_private_outbox_lost"> - <item quantity="one">आपने %1$d निजी पुनःप्रसारक हटाया</item> - <item quantity="other">आपने %1$d निजी पुनःप्रसारकों को हटाए</item> - </plurals> - <plurals name="backup_card_trusted_lost"> - <item quantity="one">आपने %1$d पुनःप्रसारक पर विश्वास करना रोक दिया</item> - <item quantity="other">आपने %1$d पुनःप्रसारकों पर विश्वास करना रोक दिए</item> - </plurals> - <plurals name="backup_card_unblocked"> - <item quantity="one">आपने %1$d पुनःप्रसारक अबाधित किया</item> - <item quantity="other">आपने %1$d पुनःप्रसारकों को अबाधित किए</item> - </plurals> - <plurals name="backup_card_leave_chats"> - <item quantity="one">आपने %1$d सार्वजनिक चर्चा छोड दिया</item> - <item quantity="other">आपने %1$d सार्वजनिक चर्चाओं को छोड दिए</item> - </plurals> - <plurals name="backup_card_leave_communities"> - <item quantity="one">आपने %1$d समुदाय छोड दिया</item> - <item quantity="other">आपने %1$d समुदायों को छोड दिए</item> - </plurals> - <plurals name="backup_card_leave_rooms"> - <item quantity="one">आपने %1$d चर्चाशाला छोड दिया</item> - <item quantity="other">आपने %1$d चर्चाशालाओं को छोड दिए</item> - </plurals> - <plurals name="backup_card_leave_groups"> - <item quantity="one">आपने %1$d समूह छोड दिया</item> - <item quantity="other">आपने %1$d समूहों को छोड दिए</item> - </plurals> - <plurals name="backup_card_feeds_removed"> - <item quantity="one">आपने %1$d इष्ट सूचनावली हटाया</item> - <item quantity="other">आपने %1$d इष्ट सूचनावलियों को हटाए</item> - </plurals> - <plurals name="backup_card_hashtags_unfollowed"> - <item quantity="one">आपने %1$d विषयसूचक का अनुगमन हटाया</item> - <item quantity="other">आपने %1$d विषयसूचकों का अनुगमन हटाए</item> - </plurals> - <plurals name="backup_card_places_unfollowed"> - <item quantity="one">आपने %1$d स्थल का अनुगमन हटाया</item> - <item quantity="other">आपने %1$d स्थलों का अनुगमन हटाए</item> - </plurals> - <plurals name="backup_card_trust_services_changed"> - <item quantity="one">आपने %1$d विश्वाससेवा प्रदाता परिवर्तित किया</item> - <item quantity="other">आपने %1$d विश्वाससेवाओं के प्रदाता को परिवर्तित किया</item> - </plurals> - <plurals name="backup_card_nutzap_mints_removed"> - <item quantity="one">आपने %1$d नटज्साप टकसाल हटाया</item> - <item quantity="other">आपने %1$d नटज्साप टकसालों को हटाए</item> - </plurals> - <plurals name="backup_card_payment_targets_removed"> - <item quantity="one">आपने %1$d भुगतान प्राप्त करने की विधि हटाया</item> - <item quantity="other">आपने %1$d भुगतान प्राप्त करने के विधियों को हटाए</item> - </plurals> - <plurals name="backup_card_offers_removed"> - <item quantity="one">आपने %1$d बोल्ट॰बारह प्रस्ताव हटाया</item> - <item quantity="other">आपने %1$d बोल्ट॰बारह प्रस्तावों को हटाए</item> - </plurals> - <plurals name="backup_card_and_added"> - <item quantity="one">%1$s तथा %2$d जोडा</item> - <item quantity="other">%1$s तथा %2$d जोडे</item> - </plurals> - <plurals name="backup_card_and_followed"> - <item quantity="one">%1$s तथा %2$d का अनुगमन किया</item> - <item quantity="other">%1$s तथा %2$d का अनुगमन किए</item> - </plurals> - <plurals name="backup_card_and_muted"> - <item quantity="one">%1$s तथा %2$d को मौनकृत किया</item> - <item quantity="other">%1$s तथा %2$d को मौनकृत किए</item> - </plurals> - <plurals name="backup_card_and_joined"> - <item quantity="one">%1$s तथा %2$d से जुडे</item> - <item quantity="other">%1$s तथा %2$d से जुडे</item> - </plurals> - <plurals name="backup_card_and_blocked"> - <item quantity="one">%1$s तथा %2$d बाधित किया</item> - <item quantity="other">%1$s तथा %2$d को बाधित किए</item> - </plurals> - <string name="backup_card_app_settings">आपके अमेथिस्ट समचरणीकृत क्रमक स्थापना विकल्पों को परिवर्तित किया गया एक अन्य क्रमक में और वह अमेथिस्ट नहीं था। इससे समस्याएँ उत्पन्न हो सकते हैं।</string> - <string name="backup_card_nutzap_key_replaced">आपने अपनी नटज्साप कुंचिका का प्रतिस्थापन किया</string> - <string name="backup_card_nutzap_key_removed">आपने अपनी नटज्साप कुंचिका को हटाया</string> - <string name="backup_card_wallet_emptied">आपने अपना काशयू धनकोष रिक्त किया</string> - <string name="backup_card_wallet_rewritten">आपने अपने काशयू धनकोष को परिवर्तित किया</string> - <string name="backup_card_concord_emptied">आपने अपनी कोंकोर्ड सूची रिक्त की</string> - <string name="backup_card_concord_rewritten">आपने अपनी कोंकोर्ड सूची का परिवर्तन किया</string> - <string name="backup_conflict_keep_new">नया संस्करण का उपयोग करें</string> - <string name="backup_conflict_relay_read_only">पठनेवशक्य</string> - <string name="backup_conflict_relay_write_only">लेखनेवशक्य</string> - <string name="backup_conflict_relay_read_write">पठन तथा लेखन</string> - <string name="backup_type_profile">परिचय</string> - <string name="backup_type_profile_explainer">आपका नाम चित्र चरित तथा भुगतान पते। जैसे सब को दिखते हैं वैसे।</string> - <string name="backup_type_follow_list">अनुचरण सूची</string> - <string name="backup_type_follow_list_explainer">निर्णय करता है किसके पत्र आपकी मुख्य सूचनावली में प्रकट होंगे।</string> - <string name="backup_type_mute_list">मौन सूची</string> - <string name="backup_type_mute_list_explainer">छिपाता है लोग शब्द विषयसूचक तथा सूत्र उन सब में से जो आप देखते हैं।</string> - <string name="backup_type_outbox_inbox_relays">सार्वजनिक पुनःप्रसारक सूची</string> - <string name="backup_type_outbox_inbox_relays_explainer">सबको बताता है आपके पत्र कहाँ प्राप्त करें तथा आपको उत्तर तथा उल्लेखों को कहाँ भेजें।</string> - <string name="backup_type_dm_relays">सीधासन्देश पुनःप्रसारक सूची</string> - <string name="backup_type_dm_relays_explainer">जहाँ अन्य आपके निजी सन्देश भेजते हैं।</string> - <string name="backup_type_key_package_relays">समूहचर्चा कुंचिका पुनःप्रसारक</string> - <string name="backup_type_key_package_relays_explainer">जहाँ अन्य उन कुंचिकाओं को प्राप्त करते हैं आपको रहस्यीकृत समूहचर्चाओं में जोडने के लिए।</string> - <string name="backup_type_search_relays">खोज पुनःप्रसारक सूची</string> - <string name="backup_type_search_relays_explainer">उपयुक्त पुनःप्रसारक जब आप खोजते हैं।</string> - <string name="backup_type_indexer_relays">अनुक्रमणीकार पुनःप्रसारक सूची</string> - <string name="backup_type_indexer_relays_explainer">पुनःप्रसारक उपयुक्त अन्य लोगों के परिचय तथा पुनःप्रसारक सूचियों को प्राप्त करने के लिए।</string> - <string name="backup_type_relay_feeds">इष्ट पुनःप्रसारक सूचनावलियाँ</string> - <string name="backup_type_relay_feeds_explainer">पुनःप्रसारक जिनका आप वीक्षण करते हैं सूचनावलियों के रूप में।</string> - <string name="backup_type_blocked_relays">बाधित पुनःप्रसारक सूची</string> - <string name="backup_type_blocked_relays_explainer">पुनःप्रसारक जिनसे यह क्रमक कभी भी संयोजन नहीं करता।</string> - <string name="backup_type_trusted_relays">विश्वसनीय पुनःप्रसारक सूची</string> - <string name="backup_type_trusted_relays_explainer">पुनःप्रसारक जिनपर आप पर्याप्त विश्वास करते हैं बिना पूछे उनसे संयोजन करने के लिए।</string> - <string name="backup_type_private_outbox_relays">निजी पुनःप्रसारक सूची</string> - <string name="backup_type_private_outbox_relays_explainer">जहाँ आपके पाण्डुलिपियाँ तथा निजी जानकारी रखे जाते हैं।</string> - <string name="backup_type_app_settings">समचरणीकृत क्रमक स्थापना विकल्प</string> - <string name="backup_type_app_settings_explainer">अमेथिस्ट स्थापना विकल्प समचरणीकृत आपके यन्त्रों में। वे रहस्यीकृत हैं। इसलिए केवल उनकी उपस्थिति की तुलना की जा सकती है।</string> - <string name="backup_type_public_chats">सार्वजनिक चर्चा सूची</string> - <string name="backup_type_public_chats_explainer">सार्वजनिक चर्चाएँ जिनसे आप जुडे।</string> - <string name="backup_type_communities">समुदाय सूची</string> - <string name="backup_type_communities_explainer">समुदाय जिनसे आप जुडे।</string> - <string name="backup_type_hashtags">अनुचरित विषयसूचक</string> - <string name="backup_type_hashtags_explainer">विषयसूचक जिससे युक्त पत्र आपकी मुख्य सूचनावली में प्रकट होंगे।</string> - <string name="backup_type_geohashes">अनुचरित स्थान</string> - <string name="backup_type_geohashes_explainer">स्थान जहाँ से पत्र आपकी मुख्य सूचनावली में प्रकट होंगे।</string> - <string name="backup_type_favorite_algo_feeds">इष्ट सूचनावली सूची</string> - <string name="backup_type_favorite_algo_feeds_explainer">कलनविध्यात्मक सूचनावलियाँ जिनको आपने अभिलेखित किया।</string> - <string name="backup_type_ephemeral_chats">अस्थायी चर्चा सूची</string> - <string name="backup_type_ephemeral_chats_explainer">अस्थायी चर्चा शालाएँ जिनसे आप जुडे।</string> - <string name="backup_type_relay_groups">समूह सूची</string> - <string name="backup_type_relay_groups_explainer">पुनःप्रसारक आधारित समूह जिनसे आप जुडे।</string> - <string name="backup_type_concord_communities">कोंकोर्ड समुदाय सूची</string> - <string name="backup_type_concord_communities_explainer">कोंकोर्ड समुदाय जिनसे आप जुडे। वे रहस्यीकृत हैं। इसलिए केवल उनकी उपस्थिति की तुलना की जा सकती है।</string> - <string name="backup_type_trust_providers">विश्वाससेवा प्रदाता सूची</string> - <string name="backup_type_trust_providers_explainer">सेवाएँ जिनपर आप विश्वास करते है आपके लिए लोगों का सत्यापन तथा श्रेणीकरण करने के लिए।</string> - <string name="backup_type_cashu_wallet">काशयू धनकोष</string> - <string name="backup_type_cashu_wallet_explainer">आपके ईकाश धनकोष स्थापना विकल्प तथा टकसाल। वे रहस्यीकृत हैं। इसलिए केवल उनकी उपस्थिति की तुलना की जा सकती है।</string> - <string name="backup_type_nutzap_info">नटज्साप प्राप्ती स्थापना विकल्प</string> - <string name="backup_type_nutzap_info_explainer">टकसाल तथा कुंचिका जिनका उपयोग अन्य करते हैं आपको नटज्साप भेजने के लिए।</string> - <string name="backup_type_payment_targets">भुगतान पते</string> - <string name="backup_type_payment_targets_explainer">विधियाँ जिनसे अन्य लोग आपको भुगतान कर सके।</string> - <string name="backup_type_bolt12_offers">बोल्ट॰बारह प्रस्ताव</string> - <string name="backup_type_bolt12_offers_explainer">लैटनिंग प्रस्ताव जिनका उपयोग करके अन्य लोग आपको भुगतान करते हैं।</string> - <string name="backup_type_other">सूची</string> - <string name="backup_type_other_explainer">इस यन्त्र पर अभिलेखित सूची।</string> - <string name="backup_entry_profile_field">परिचय प्रपत्रस्थान</string> - <string name="backup_entry_person">लोग</string> - <string name="backup_entry_hashtag">विषयसूचक</string> - <string name="backup_entry_word">शब्द</string> - <string name="backup_entry_thread">सूत्र</string> - <string name="backup_entry_public_chat">सार्वजनिक चर्चाएँ</string> - <string name="backup_entry_community">समुदाय</string> - <string name="backup_entry_algo_feed">सूचनावलियाँ</string> - <string name="backup_entry_location">स्थान</string> - <string name="backup_entry_relay">पुनःप्रसारक</string> - <string name="backup_entry_relay_group">समूह</string> - <string name="backup_entry_chat_room">चर्चा शालाएँ</string> - <string name="backup_entry_trust_provider">विश्वाससेवा प्रदाता</string> - <string name="backup_entry_mint">टकसाल</string> - <string name="backup_entry_nutzap_key">नटज्साप कुंचिकाएँ</string> - <string name="backup_entry_payment_target">भुगतान पते</string> - <string name="backup_entry_offer">प्रस्ताव</string> - <string name="backup_entry_identity_claim">सम्बन्धित व्यक्तिगण</string> - <string name="backup_entry_other">अन्य प्रविष्टियाँ</string> - <string name="backup_profile_field_name">प्रयोक्तानाम</string> - <string name="backup_profile_field_display_name">प्रदर्शन नाम</string> - <string name="backup_profile_field_about">जानकारी</string> - <string name="backup_profile_field_picture">चित्र</string> - <string name="backup_profile_field_banner">ध्वज</string> - <string name="backup_profile_field_website">जालस्थान</string> - <string name="backup_profile_field_nip05">नोस्टर पता</string> - <string name="backup_profile_field_lud16">लैटनिंग पता</string> - <string name="backup_profile_field_lud06">लैटनिंग जालपता</string> - <string name="backup_profile_field_pronouns">सर्वनाम</string> - <string name="backup_profile_field_birthday">जन्मदिन</string> - <string name="backup_profile_field_clink_offer">क्लिंक॰ प्रस्ताव</string> - <string name="backup_profile_field_bot">क्रमचर चिह्न</string> </resources> diff --git a/amethyst/src/main/res/values-hu-rHU/strings.xml b/amethyst/src/main/res/values-hu-rHU/strings.xml index e7a9da119d..37bd743202 100644 --- a/amethyst/src/main/res/values-hu-rHU/strings.xml +++ b/amethyst/src/main/res/values-hu-rHU/strings.xml @@ -353,322 +353,4 @@ <string name="mute_button">Hang bekapcsolva. Koppintson a némításhoz</string> <string name="play">Lejátszás</string> <string name="pause">Szüneteltetés</string> - <string name="backup_conflict_title">Ön módosította a következőt: %1$s, egy másik alkalmazásban</string> - <string name="backup_conflict_removed">Eltávolítva (%1$s)</string> - <string name="backup_conflict_added">Hozzáadva (%1$s)</string> - <string name="backup_conflict_changed">Módosítva (%1$s)</string> - <string name="backup_conflict_private_cleared">Ön eltávolította az összes privát (titkosított) elemet.</string> - <string name="backup_conflict_private_changed">Ön privát (titkosított) elemeket is módosított. Ezek itt nem hasonlíthatók össze.</string> - <string name="backup_conflict_private_added">Ön privát (titkosított) elemeket adott hozzá.</string> - <string name="backup_conflict_restore_mine">Mentett verzió visszaállítása</string> - <string name="backup_review_saved">Ezen az eszközön</string> - <string name="backup_review_new">Új verzió</string> - <string name="backup_review_not_set">Nincs beállítva</string> - <string name="backup_conflict_count_removed">%1$s eltávolítva</string> - <string name="backup_conflict_count_added">%1$s hozzáadva</string> - <string name="backup_conflict_count_changed">%1$s módosítva</string> - <plurals name="backup_card_follows_dropped"> - <item quantity="one">%1$d személyt nem követ többé</item> - <item quantity="other">%1$d személyt nem követ többé</item> - </plurals> - <string name="backup_card_accept">Elfogadás</string> - <string name="backup_review_title_updated">%1$s frissítve</string> - <string name="backup_review_title_cleared">%1$s törölve</string> - <string name="backup_review_changed_by_other_app">Módosítva egy másik alkalmazásban · %1$s</string> - <string name="backup_action_keep_count">%1$s megtartása</string> - <string name="backup_action_restore_count">%1$s visszaállítása</string> - <string name="backup_action_remute">%1$s újbóli némítása</string> - <string name="backup_action_rejoin">Újbóli csatlakozás ehhez: %1$s</string> - <string name="backup_action_restore_profile">Profil visszaállítása</string> - <string name="backup_action_restore_relays">Átjátszók visszaállítása</string> - <string name="backup_review_kept_count">%1$s megtartva</string> - <string name="backup_review_dropped_count">%1$s elvetve</string> - <string name="backup_review_new_count">%1$s új</string> - <string name="backup_review_tab_dropped">Elvetve · %1$s</string> - <string name="backup_review_tab_new">Új · %1$s</string> - <string name="backup_review_tab_edited">Szerkesztve · %1$s</string> - <string name="backup_review_search_people">%1$s személy keresése</string> - <string name="backup_review_nothing_here">Itt nincs semmi</string> - <string name="backup_review_unmuted">%1$s némítása feloldva</string> - <string name="backup_review_unmuted_explainer">Ezek újra megjelenhetnek a hírfolyamaiban</string> - <string name="backup_review_newly_muted">%1$s újonnan némítva</string> - <string name="backup_review_words_and_hashtags">Szavak és kulcsszavak</string> - <string name="backup_review_names_and_more">%1$s és %2$s további</string> - <string name="backup_review_private_wiped_title">Ön eltávolította az összes privát elemet</string> - <string name="backup_review_private_wiped_body">Titkosítva vannak, így csak Ön láthatja, mik voltak. A visszaállítás mindet visszahozza.</string> - <string name="backup_review_private_added_title">Ön priváttá tette az elemeket</string> - <string name="backup_review_private_added_body">Most már titkosítva vannak, valószínűleg ide kerültek a fenti nyilvános elemek.</string> - <string name="backup_review_private_changed_title">Ön módosította a privát elemeket</string> - <string name="backup_review_private_changed_body">Titkosítva vannak, így itt nem hasonlíthatók össze.</string> - <string name="backup_review_gone">Eltűnt</string> - <string name="backup_review_new_tag">Új</string> - <string name="backup_review_dropped_tag">Elvetve</string> - <string name="backup_review_demoted_tag">Áthelyezve</string> - <string name="backup_review_units_tag">Egységek</string> - <string name="backup_review_left_tag">Elhagyva</string> - <string name="backup_review_renamed_tag">Átnevezve</string> - <string name="backup_review_outbox">Kimenő</string> - <string name="backup_review_outbox_explainer">ahol közzétesz</string> - <string name="backup_review_inbox">Bejövő</string> - <string name="backup_review_inbox_explainer">ahol mások elérik Önt</string> - <string name="backup_review_key_replaced">Fogadási kulcs lecserélve</string> - <string name="backup_review_key_removed">Fogadási kulcs eltávolítva</string> - <string name="backup_review_key_warning">A nutzapok ehhez az új kulcshoz lesznek kötve. Ha ez az eszköz nem rendelkezik vele, lehet, hogy itt nem tudja beváltani őket.</string> - <string name="backup_review_left">elhagyta a csoportot</string> - <string name="backup_review_joined">csatlakozott</string> - <string name="backup_review_renamed">átnevezte</string> - <string name="backup_review_was_named">„%1$s” volt</string> - <string name="backup_action_block_again">%1$s újbóli letiltása</string> - <string name="backup_action_restore_feeds">Hírfolyamok visszaállítása</string> - <string name="backup_action_restore_topics">Témák visszaállítása</string> - <string name="backup_action_restore_places">Helyek visszaállítása</string> - <string name="backup_action_restore_providers">Szolgáltatók visszaállítása</string> - <string name="backup_action_restore_wallet">Pénztárca visszaállítása</string> - <string name="backup_review_kept">megtartva</string> - <string name="backup_review_receiving_tag">Fogadás</string> - <string name="backup_review_listed_tag">Listázva</string> - <string name="backup_review_searching_tag">Keresés</string> - <string name="backup_review_storing_tag">Tárolás</string> - <string name="backup_review_trusted_tag">Megbízható</string> - <string name="backup_review_untrusted_tag">Nem megbízható</string> - <string name="backup_review_unblocked">%1$s letiltása feloldva</string> - <string name="backup_review_unblocked_explainer">Az Amethyst újra kapcsolódni fog hozzájuk</string> - <string name="backup_review_no_longer_blocked">Már nincs letiltva</string> - <string name="backup_review_newly_blocked">Újonnan letiltva</string> - <string name="backup_review_still_blocked">Továbbra is le van tiltva</string> - <string name="backup_review_allowed_tag">Engedélyezve</string> - <string name="backup_review_moved_tag">Áthelyezve</string> - <string name="backup_review_feed">Hírfolyam</string> - <string name="backup_review_topics_gone">eltűnt témák</string> - <string name="backup_review_places_gone">eltűnt helyek</string> - <string name="backup_review_new_count_caption">új</string> - <string name="backup_review_hashtags_you_follow">Ön által követett kulcsszavak</string> - <string name="backup_review_places_you_follow">Ön által követett helyek</string> - <string name="backup_review_services_changed">%2$s szolgáltatásból %1$s módosult</string> - <string name="backup_review_services_changed_explainer">Most már valaki mástól vagy senkitől sem származnak</string> - <string name="backup_review_service">Szolgáltatás</string> - <string name="backup_review_service_rank">Rang</string> - <string name="backup_review_service_followers">Követők</string> - <string name="backup_review_service_topics">Témák</string> - <string name="backup_review_trust_warning">A látott rangok, követőszámok és a kéretlen tartalomszűrés ezektől a szolgáltatóktól származnak.</string> - <plurals name="backup_review_payment_targets_gone"> - <item quantity="one">%1$d fizetési mód eltávolítva</item> - <item quantity="other">%1$d fizetési mód eltávolítva</item> - </plurals> - <plurals name="backup_review_offers_gone"> - <item quantity="one">%1$d ajánlat eltávolítva</item> - <item quantity="other">%1$d ajánlat eltávolítva</item> - </plurals> - <string name="backup_review_payments_explainer">A fizető emberek csak azt látják, ami ebben a listában van.</string> - <string name="backup_review_offer">BOLT12 ajánlat</string> - <string name="backup_review_encrypted_cleared">Ön kiürítette</string> - <string name="backup_review_encrypted_added">Ön hozta létre</string> - <string name="backup_review_encrypted_changed">Ön módosította</string> - <string name="backup_review_wallet_body">A saját pénztárca pénzverdéi és kulcsai Önhöz vannak titkosítva, így itt nem hasonlíthatók össze tételesen. A visszaállítás pontosan azt hozza vissza, amit ez az eszköz mentett.</string> - <string name="backup_review_concord_body">A Concord közösségei Önhöz vannak titkosítva, így itt nem hasonlíthatók össze. A visszaállítás pontosan azt hozza vissza, amit ez az eszköz mentett.</string> - <plurals name="backup_card_mutes_visible"> - <item quantity="one">Ön %1$d dolog némítását oldotta fel</item> - <item quantity="other">%1$d dolog némítását oldotta fel</item> - </plurals> - <plurals name="backup_card_profile_lost_fields"> - <item quantity="one">Ön %1$d profilmezőt távolított el</item> - <item quantity="other">%1$d profilmezőt távolított el</item> - </plurals> - <plurals name="backup_card_nip65_lost"> - <item quantity="one">Ön %1$d nyilvános átjátszót távolított el</item> - <item quantity="other">%1$d nyilvános átjátszót távolított el</item> - </plurals> - <plurals name="backup_card_dm_relays_lost"> - <item quantity="one">Ön %1$d privát üzenetátjátszót távolított el</item> - <item quantity="other">%1$d privát üzenet átjátszót távolított el</item> - </plurals> - <plurals name="backup_card_key_package_relays_lost"> - <item quantity="one">Ön %1$d csoportos csevegési kulcsátjátszót távolított el</item> - <item quantity="other">%1$d csoportcsevegés kulcs átjátszót távolított el</item> - </plurals> - <plurals name="backup_card_search_relays_lost"> - <item quantity="one">Ön %1$d kereső átjátszót távolított el</item> - <item quantity="other">%1$d kereső átjátszót távolított el</item> - </plurals> - <plurals name="backup_card_indexer_relays_lost"> - <item quantity="one">Ön %1$d indexelő átjátszót távolított el</item> - <item quantity="other">%1$d indexelő átjátszót távolított el</item> - </plurals> - <plurals name="backup_card_relay_feeds_lost"> - <item quantity="one">Ön %1$d átjátszó-hírfolyamot távolított el</item> - <item quantity="other">%1$d átjátszó hírfolyamot távolított el</item> - </plurals> - <plurals name="backup_card_private_outbox_lost"> - <item quantity="one">Ön %1$d privát átjátszót távolított el</item> - <item quantity="other">%1$d privát átjátszót távolított el</item> - </plurals> - <plurals name="backup_card_trusted_lost"> - <item quantity="one">Ön %1$d átjátszóban nem bízik többé</item> - <item quantity="other">%1$d átjátszóban nem bízik többé</item> - </plurals> - <plurals name="backup_card_unblocked"> - <item quantity="one">Ön %1$d átjátszó letiltását oldotta fel</item> - <item quantity="other">%1$d átjátszó blokkolását oldotta fel</item> - </plurals> - <plurals name="backup_card_leave_chats"> - <item quantity="one">Ön %1$d nyilvános csevegést hagyott el</item> - <item quantity="other">%1$d nyilvános csevegést hagyott el</item> - </plurals> - <plurals name="backup_card_leave_communities"> - <item quantity="one">Ön %1$d közösséget hagyott el</item> - <item quantity="other">%1$d közösséget hagyott el</item> - </plurals> - <plurals name="backup_card_leave_rooms"> - <item quantity="one">Ön %1$d csevegőszobát hagyott el</item> - <item quantity="other">%1$d csevegőszobát hagyott el</item> - </plurals> - <plurals name="backup_card_leave_groups"> - <item quantity="one">Ön %1$d csoportot hagyott el</item> - <item quantity="other">%1$d csoportot hagyott el</item> - </plurals> - <plurals name="backup_card_feeds_removed"> - <item quantity="one">Ön %1$d kedvenc hírfolyamot távolított el</item> - <item quantity="other">%1$d kedvenc hírfolyamot távolított el</item> - </plurals> - <plurals name="backup_card_hashtags_unfollowed"> - <item quantity="one">Ön %1$d kulcsszót nem követ többé</item> - <item quantity="other">%1$d hashtaget nem követ többé</item> - </plurals> - <plurals name="backup_card_places_unfollowed"> - <item quantity="one">Ön %1$d helyet nem követ többé</item> - <item quantity="other">%1$d helyet nem követ többé</item> - </plurals> - <plurals name="backup_card_trust_services_changed"> - <item quantity="one">Ön %1$d bizalmi szolgáltatás szolgáltatóját módosította</item> - <item quantity="other">%1$d bizalmi szolgáltatás szolgáltatóját változtatta meg</item> - </plurals> - <plurals name="backup_card_nutzap_mints_removed"> - <item quantity="one">Ön %1$d nutzap pénzverdét távolított el</item> - <item quantity="other">%1$d nutzap pénzverdét távolított el</item> - </plurals> - <plurals name="backup_card_payment_targets_removed"> - <item quantity="one">Ön %1$d fizetési módot távolított el</item> - <item quantity="other">%1$d fizetési módot távolított el</item> - </plurals> - <plurals name="backup_card_offers_removed"> - <item quantity="one">Ön %1$d BOLT12 ajánlatot távolított el</item> - <item quantity="other">%1$d BOLT12 ajánlatot távolított el</item> - </plurals> - <plurals name="backup_card_and_added"> - <item quantity="one">%1$s és %2$d hozzáadva</item> - <item quantity="other">%1$s és %2$d hozzáadva</item> - </plurals> - <plurals name="backup_card_and_followed"> - <item quantity="one">%1$s és %2$d követve</item> - <item quantity="other">%1$s és %2$d követve</item> - </plurals> - <plurals name="backup_card_and_muted"> - <item quantity="one">%1$s és %2$d némítva</item> - <item quantity="other">%1$s és %2$d némítva</item> - </plurals> - <plurals name="backup_card_and_joined"> - <item quantity="one">%1$s és %2$d csatlakozva</item> - <item quantity="other">%1$s és %2$d csatlakozva</item> - </plurals> - <plurals name="backup_card_and_blocked"> - <item quantity="one">%1$s és %2$d letiltva</item> - <item quantity="other">%1$s és %2$d letiltva</item> - </plurals> - <string name="backup_card_app_settings">A szinkronizált Amethyst alkalmazásbeállításai egy másik, nem Amethyst alkalmazásban módosultak. Ez problémákat okozhat.</string> - <string name="backup_card_nutzap_key_replaced">Ön lecserélte a nutzap kulcsát</string> - <string name="backup_card_nutzap_key_removed">Ön eltávolította a nutzap kulcsát</string> - <string name="backup_card_wallet_emptied">Ön kiürítette a Cashu pénztárcáját</string> - <string name="backup_card_wallet_rewritten">Ön módosította a Cashu pénztárcáját</string> - <string name="backup_card_concord_emptied">Ön kiürítette a Concord listáját</string> - <string name="backup_card_concord_rewritten">Ön módosította a Concord listáját</string> - <string name="backup_conflict_keep_new">Új verzió használata</string> - <string name="backup_conflict_relay_read_only">csak olvasható</string> - <string name="backup_conflict_relay_write_only">csak írható</string> - <string name="backup_conflict_relay_read_write">olvasás és írás</string> - <string name="backup_type_profile">profil</string> - <string name="backup_type_profile_explainer">Az Ön neve, képe, bemutatkozása és fizetési címei, ahogy mindenki látja őket.</string> - <string name="backup_type_follow_list">követési lista</string> - <string name="backup_type_follow_list_explainer">Meghatározza, kinek a bejegyzései jelenjenek meg a kezdőlapon.</string> - <string name="backup_type_mute_list">némítási lista</string> - <string name="backup_type_mute_list_explainer">Elrejti a személyeket, szavakat, kulcsszavakat és beszélgetéseket mindenből, amit lát.</string> - <string name="backup_type_outbox_inbox_relays">nyilvános átjátszólista</string> - <string name="backup_type_outbox_inbox_relays_explainer">Megmondja mindenkinek, hol találhatók a bejegyzései, és hová küldjék a válaszokat és említéseket.</string> - <string name="backup_type_dm_relays">privát üzenet átjátszólista</string> - <string name="backup_type_dm_relays_explainer">Ahová mások kézbesítik a privát üzeneteit.</string> - <string name="backup_type_key_package_relays">csoportos csevegési kulcsátjátszók</string> - <string name="backup_type_key_package_relays_explainer">Ahol mások megtalálják a kulcsokat, hogy titkosított csoportos csevegésekhez adhassák Önt.</string> - <string name="backup_type_search_relays">kereső átjátszólista</string> - <string name="backup_type_search_relays_explainer">Kereséskor használt átjátszók.</string> - <string name="backup_type_indexer_relays">indexelő átjátszólista</string> - <string name="backup_type_indexer_relays_explainer">Azok az átjátszók, amelyeket mások profiljainak és átjátszólistáinak megtalálásához használnak.</string> - <string name="backup_type_relay_feeds">kedvenc átjátszóhírfolyamok</string> - <string name="backup_type_relay_feeds_explainer">Olyan átjátszók, amelyeket hírfolyamként böngész.</string> - <string name="backup_type_blocked_relays">letiltott átjátszólista</string> - <string name="backup_type_blocked_relays_explainer">Átjátszók, amelyekhez ez az alkalmazás soha nem kapcsolódik.</string> - <string name="backup_type_trusted_relays">megbízható átjátszólista</string> - <string name="backup_type_trusted_relays_explainer">Átjátszók, amelyekhez kérdezés nélkül kapcsolódhat.</string> - <string name="backup_type_private_outbox_relays">privát átjátszólista</string> - <string name="backup_type_private_outbox_relays_explainer">Ahol a piszkozatai és privát adatai tárolódnak.</string> - <string name="backup_type_app_settings">szinkronizált alkalmazásbeállítások</string> - <string name="backup_type_app_settings_explainer">Az eszközök között szinkronizált Amethyst-beállítások. Titkosítva vannak, így csak a jelenlétük hasonlítható össze.</string> - <string name="backup_type_public_chats">nyilvános csevegési lista</string> - <string name="backup_type_public_chats_explainer">Nyilvános csevegések, amelyekhez csatlakozott.</string> - <string name="backup_type_communities">közösséglista</string> - <string name="backup_type_communities_explainer">Közösségek, amelyekhez csatlakozott.</string> - <string name="backup_type_hashtags">követett kulcsszavak</string> - <string name="backup_type_hashtags_explainer">Kulcsszavak, amelyek bejegyzései megjelennek a hírfolyamban.</string> - <string name="backup_type_geohashes">követett helyek</string> - <string name="backup_type_geohashes_explainer">Helyek, amelyek bejegyzései megjelennek a hírfolyamaiban.</string> - <string name="backup_type_favorite_algo_feeds">kedvenc hírfolyamlista</string> - <string name="backup_type_favorite_algo_feeds_explainer">Mentett algoritmus-hírfolyamok.</string> - <string name="backup_type_ephemeral_chats">rövid életű csevegési lista</string> - <string name="backup_type_ephemeral_chats_explainer">Rövid életű csevegőszobák, amelyekhez csatlakozott.</string> - <string name="backup_type_relay_groups">csoportlista</string> - <string name="backup_type_relay_groups_explainer">Átjátszó-alapú csoportok, amelyekhez csatlakozott.</string> - <string name="backup_type_concord_communities">Concord közösséglista</string> - <string name="backup_type_concord_communities_explainer">A Concord közösségek, amelyekhez csatlakozott. Titkosítva vannak, így csak a jelenlétük hasonlítható össze.</string> - <string name="backup_type_trust_providers">bizalmi szolgáltatólista</string> - <string name="backup_type_trust_providers_explainer">A szolgáltatások, amelyekben megbízik, hogy rangsorolják és ellenőrizzék Önnek az embereket.</string> - <string name="backup_type_cashu_wallet">Cashu pénztárca</string> - <string name="backup_type_cashu_wallet_explainer">Az ecash pénztárca beállításai és pénzverdéi. Titkosítva vannak, így csak a jelenlétük hasonlítható össze.</string> - <string name="backup_type_nutzap_info">nutzap fogadási beállítások</string> - <string name="backup_type_nutzap_info_explainer">A pénzverdék és a kulcs, amelyeket mások a nutzapok küldéséhez használnak az Ön számára.</string> - <string name="backup_type_payment_targets">fizetési címek</string> - <string name="backup_type_payment_targets_explainer">A módok, ahogy mások fizethetnek Önnek.</string> - <string name="backup_type_bolt12_offers">BOLT12 ajánlatok</string> - <string name="backup_type_bolt12_offers_explainer">A lightning ajánlatok, amelyeket mások a fizetéshez használnak az Ön számára.</string> - <string name="backup_type_other">lista</string> - <string name="backup_type_other_explainer">Egy listaelem ezen az eszközön mentve.</string> - <string name="backup_entry_profile_field">Profilmezők</string> - <string name="backup_entry_person">Személyek</string> - <string name="backup_entry_hashtag">Kulcsszavak</string> - <string name="backup_entry_word">Szavak</string> - <string name="backup_entry_thread">Beszélgetések</string> - <string name="backup_entry_public_chat">Nyilvános csevegések</string> - <string name="backup_entry_community">Közösségek</string> - <string name="backup_entry_algo_feed">Hírfolyamok</string> - <string name="backup_entry_location">Helyek</string> - <string name="backup_entry_relay">Átjátszók</string> - <string name="backup_entry_relay_group">Csoportok</string> - <string name="backup_entry_chat_room">Csevegőszobák</string> - <string name="backup_entry_trust_provider">Bizalmi szolgáltatók</string> - <string name="backup_entry_mint">Pénzverdék</string> - <string name="backup_entry_nutzap_key">Nutzap kulcsok</string> - <string name="backup_entry_payment_target">Fizetési címek</string> - <string name="backup_entry_offer">Ajánlatok</string> - <string name="backup_entry_identity_claim">Kapcsolt identitások</string> - <string name="backup_entry_other">Egyéb bejegyzések</string> - <string name="backup_profile_field_name">Felhasználónév</string> - <string name="backup_profile_field_display_name">Megjelenített név</string> - <string name="backup_profile_field_about">Névjegy</string> - <string name="backup_profile_field_picture">Kép</string> - <string name="backup_profile_field_banner">Borító</string> - <string name="backup_profile_field_website">Weboldal</string> - <string name="backup_profile_field_nip05">Nostr-cím</string> - <string name="backup_profile_field_lud16">Lightning-cím</string> - <string name="backup_profile_field_lud06">LNURL</string> - <string name="backup_profile_field_pronouns">Névmások</string> - <string name="backup_profile_field_birthday">Születésnap</string> - <string name="backup_profile_field_clink_offer">CLINK-ajánlat</string> - <string name="backup_profile_field_bot">Bot jelölő</string> </resources> diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index 601165a320..eda8c8fe1a 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -381,382 +381,4 @@ <string name="mute_button">Dźwięk włączony. Kliknij, aby wyciszyć</string> <string name="play">Odtwórz</string> <string name="pause">Pauza</string> - <string name="backup_conflict_title">Zmieniłeś %1$s w innej aplikacji</string> - <string name="backup_conflict_removed">Usunięto (%1$s)</string> - <string name="backup_conflict_added">Dodano (%1$s)</string> - <string name="backup_conflict_changed">Zmieniono (%1$s)</string> - <string name="backup_conflict_private_cleared">Usunąłeś wszystkie prywatne (zaszyfrowane) elementy.</string> - <string name="backup_conflict_private_changed">Zmieniłeś również elementy prywatne (zaszyfrowane). Nie można ich tutaj porównać.</string> - <string name="backup_conflict_private_added">Dodano prywatne (zaszyfrowane) pozycje.</string> - <string name="backup_conflict_restore_mine">Przywróć zapisaną wersję</string> - <string name="backup_review_saved">Na tym urządzeniu</string> - <string name="backup_review_new">Nowa wersja</string> - <string name="backup_review_not_set">Nie ustawiono</string> - <string name="backup_conflict_count_removed">Usunięto %1$s</string> - <string name="backup_conflict_count_added">Dodano %1$s</string> - <string name="backup_conflict_count_changed">%1$s zmieniono</string> - <plurals name="backup_card_follows_dropped"> - <item quantity="one">Przestałeś obserwować %1$d osobę</item> - <item quantity="few">Przestałeś obserwować %1$d osób</item> - <item quantity="many">Przestałeś obserwować %1$d osób</item> - <item quantity="other">Przestałeś obserwować %1$d osoby</item> - </plurals> - <string name="backup_card_accept">Zaakceptuj</string> - <string name="backup_review_title_updated">%1$s zaktualizowano</string> - <string name="backup_review_title_cleared">%1$s wyczyszczono</string> - <string name="backup_review_changed_by_other_app">Zmieniono w innej aplikacji · %1$s</string> - <string name="backup_action_keep_count">Zachowaj %1$s</string> - <string name="backup_action_restore_count">Przywróć %1$s</string> - <string name="backup_action_remute">Ponownie wycisz %1$s</string> - <string name="backup_action_rejoin">Ponów %1$s</string> - <string name="backup_action_restore_profile">Przywróć profil</string> - <string name="backup_action_restore_relays">Przywróć transmitery</string> - <string name="backup_review_kept_count">Zachowano %1$s</string> - <string name="backup_review_dropped_count">%1$s odrzuconych</string> - <string name="backup_review_new_count">%1$s nowych</string> - <string name="backup_review_tab_dropped">Usunięte · %1$s</string> - <string name="backup_review_tab_new">Nowe · %1$s</string> - <string name="backup_review_tab_edited">Edytowane · %1$s</string> - <string name="backup_review_search_people">Szukaj %1$s osób</string> - <string name="backup_review_nothing_here">Nic tu nie ma!</string> - <string name="backup_review_unmuted">%1$s odblokowanych</string> - <string name="backup_review_unmuted_explainer">Mogą one ponownie pojawić się w Twoich informacjach</string> - <string name="backup_review_newly_muted">%1$s nowo wyciszonych</string> - <string name="backup_review_words_and_hashtags">Wyrazy & hasztagi</string> - <string name="backup_review_names_and_more">%1$s i %2$s więcej</string> - <string name="backup_review_private_wiped_title">Usunąłeś wszystkie prywatne elementy</string> - <string name="backup_review_private_wiped_body">Są zaszyfrowane, więc tylko Ty możesz zobaczyć, co to było. Odtworzenie przywróci je wszystkie.</string> - <string name="backup_review_private_added_title">Ustawiłeś te pozycje jako prywatne</string> - <string name="backup_review_private_added_body">Są teraz zaszyfrowane, co prawdopodobnie wyjaśnia, gdzie podziały się powyższe elementy publiczne.</string> - <string name="backup_review_private_changed_title">Zmieniłeś prywatne elementy</string> - <string name="backup_review_private_changed_body">Są zaszyfrowane, więc nie można ich tu porównać.</string> - <string name="backup_review_gone">Zniknęła</string> - <string name="backup_review_new_tag">Nowa</string> - <string name="backup_review_dropped_tag">Usunięta</string> - <string name="backup_review_demoted_tag">Przeniesiona</string> - <string name="backup_review_units_tag">Jednostek</string> - <string name="backup_review_left_tag">Zostało</string> - <string name="backup_review_renamed_tag">Zmieniono nazwę</string> - <string name="backup_review_outbox">Skrzynka nadawcza</string> - <string name="backup_review_outbox_explainer">gdzie publikujesz</string> - <string name="backup_review_inbox">Skrzynka odbiorcza</string> - <string name="backup_review_inbox_explainer">gdzie inni docierają do Ciebie</string> - <string name="backup_review_key_replaced">Wymieniono klucz odbierający</string> - <string name="backup_review_key_removed">Usunięto klucz odbierający</string> - <string name="backup_review_key_warning">Nutzapy zostaną przypisane do tego nowego klucza. Jeśli urządzenie nie posiada tego klucza, może się okazać, że nie będzie można ich tutaj zrealizować.</string> - <string name="backup_review_left">zostało</string> - <string name="backup_review_joined">dołączył(a)</string> - <string name="backup_review_renamed">zmieniono nazwę</string> - <string name="backup_review_was_named">było „%1$s”</string> - <string name="backup_action_block_again">Zablokuj %1$s ponownie</string> - <string name="backup_action_restore_feeds">Przywróć kanały</string> - <string name="backup_action_restore_topics">Przywróć tematy</string> - <string name="backup_action_restore_places">Przywróć miejsca</string> - <string name="backup_action_restore_providers">Przywróć dostawców</string> - <string name="backup_action_restore_wallet">Przywróć portfel</string> - <string name="backup_review_kept">zachowane</string> - <string name="backup_review_receiving_tag">Pobieranie</string> - <string name="backup_review_listed_tag">Na Liście</string> - <string name="backup_review_searching_tag">Szukam</string> - <string name="backup_review_storing_tag">Przechowywanie</string> - <string name="backup_review_trusted_tag">Zaufane</string> - <string name="backup_review_untrusted_tag">Niezaufane</string> - <string name="backup_review_unblocked">%1$s odblokowane</string> - <string name="backup_review_unblocked_explainer">Ametyst ponownie połączy się z nimi</string> - <string name="backup_review_no_longer_blocked">Nie jest już zablokowana</string> - <string name="backup_review_newly_blocked">Nowo zablokowana</string> - <string name="backup_review_still_blocked">Nadal zablokowana</string> - <string name="backup_review_allowed_tag">Dozwolona</string> - <string name="backup_review_moved_tag">Przeniesiona</string> - <string name="backup_review_feed">Kanał</string> - <string name="backup_review_topics_gone">tematy zniknęły</string> - <string name="backup_review_places_gone"> miejsca zniknęły</string> - <string name="backup_review_new_count_caption">nowe</string> - <string name="backup_review_hashtags_you_follow">Hashtagi, które obserwujesz</string> - <string name="backup_review_places_you_follow">Miejsca które obserwujesz</string> - <string name="backup_review_services_changed">Zmieniono %1$s z %2$s serwisów</string> - <string name="backup_review_services_changed_explainer">Teraz pochodzą od kogoś innego albo od nikogo</string> - <string name="backup_review_service">Serwis</string> - <string name="backup_review_service_rank">Ranking</string> - <string name="backup_review_service_followers">Obserwujący</string> - <string name="backup_review_service_topics">Tematy</string> - <string name="backup_review_trust_warning">Rankingi, liczba obserwujących i filtrowanie spamu, które widzisz, pochodzą od tych dostawców.</string> - <plurals name="backup_review_payment_targets_gone"> - <item quantity="one">%1$d sposób płatności został usunięty</item> - <item quantity="few">%1$d sposobów płatności zostało usuniętych</item> - <item quantity="many">%1$d sposobów płatności zostało usuniętych</item> - <item quantity="other">%1$d sposoby płatności zostały usunięte</item> - </plurals> - <plurals name="backup_review_offers_gone"> - <item quantity="one">%1$d oferta usunięta</item> - <item quantity="few">%1$d ofert usunięto</item> - <item quantity="many">%1$d ofert usunięto</item> - <item quantity="other">%1$d oferty usunięte</item> - </plurals> - <string name="backup_review_payments_explainer">Osoby, które Ci płacą, widzą tylko to, co znajduje się na tej liście.</string> - <string name="backup_review_offer">Oferta BOLT12</string> - <string name="backup_review_encrypted_cleared">Opróżniłeś to</string> - <string name="backup_review_encrypted_added">Utworzyłeś to</string> - <string name="backup_review_encrypted_changed">Zmieniłeś to</string> - <string name="backup_review_wallet_body">Minty i klucze w Twoim portfelu są zaszyfrowane dla Ciebie, więc nie można ich tutaj porównać pozycja po pozycji. Przywrócenie danych powoduje przywrócenie dokładnie tego, co zostało zapisane na tym urządzeniu.</string> - <string name="backup_review_concord_body">Twoje społeczności w Concord są dla Ciebie zaszyfrowane, więc nie można ich tutaj porównać. Przywrócenie danych powoduje przywrócenie dokładnie tego, co zostało zapisane na tym urządzeniu.</string> - <plurals name="backup_card_mutes_visible"> - <item quantity="one">Włączyłeś dźwięk dla %1$d elementu</item> - <item quantity="few">Włączyłeś dźwięk dla %1$d elementów</item> - <item quantity="many">Włączyłeś dźwięk dla %1$d elementów</item> - <item quantity="other">%1$d elementy mają włączony dźwięk</item> - </plurals> - <plurals name="backup_card_profile_lost_fields"> - <item quantity="one">Usunąłeś %1$d pole profilu</item> - <item quantity="few">Usunąłeś %1$d pól profilowych</item> - <item quantity="many">Usunąłeś %1$d pól profilowych</item> - <item quantity="other">Usunąłeś %1$d pola profilowe</item> - </plurals> - <plurals name="backup_card_nip65_lost"> - <item quantity="one">Usunąłeś %1$d publiczny transmiter</item> - <item quantity="few">Usunąłeś %1$d publicznych transmiterów</item> - <item quantity="many">Usunąłeś %1$d publicznych transmiterów</item> - <item quantity="other">Usunąłeś %1$d publiczne transmitery</item> - </plurals> - <plurals name="backup_card_dm_relays_lost"> - <item quantity="one">Usunąłeś %1$d transmiter DM</item> - <item quantity="few">Usunąłeś %1$d transmiterów DM</item> - <item quantity="many">Usunąłeś %1$d transmiterów DM</item> - <item quantity="other">Usunąłeś %1$d transmitery DM</item> - </plurals> - <plurals name="backup_card_key_package_relays_lost"> - <item quantity="one">Usunięto %1$d transmiter klucza grupowego czatu</item> - <item quantity="few">Usunięto %1$d transmiterów klucza grupowego czatu</item> - <item quantity="many">Usunięto %1$d transmiterów klucza grupowego czatu</item> - <item quantity="other">Usunięto %1$d transmitery klucza grupowego czatu</item> - </plurals> - <plurals name="backup_card_search_relays_lost"> - <item quantity="one">Usunięto %1$d transmiter wyszukiwania</item> - <item quantity="few">Usunięto %1$d transmiterów wyszukiwania</item> - <item quantity="many">Usunięto %1$d transmiterów wyszukiwania</item> - <item quantity="other">Usunięto %1$d transmitery wyszukiwania</item> - </plurals> - <plurals name="backup_card_indexer_relays_lost"> - <item quantity="one">Usunięto %1$d transmiter indeksujący</item> - <item quantity="few">Usunięto %1$d transmiterów indeksujących</item> - <item quantity="many">Usunięto %1$d transmiterów indeksujących</item> - <item quantity="other">Usunięto %1$d transmitery indeksujące</item> - </plurals> - <plurals name="backup_card_relay_feeds_lost"> - <item quantity="one">Usunięto %1$d kanał transmitera</item> - <item quantity="few">Usunięto %1$d kanałów transmitera</item> - <item quantity="many">Usunięto %1$d kanałów transmitera</item> - <item quantity="other">Usunięto %1$d kanały transmitera</item> - </plurals> - <plurals name="backup_card_private_outbox_lost"> - <item quantity="one">Usunięto %1$d prywatny transmiter</item> - <item quantity="few">Usunięto %1$d prywatnych transmiterów</item> - <item quantity="many">Usunięto %1$d prywatnych transmiterów</item> - <item quantity="other">Usunięto %1$d prywatne transmitery</item> - </plurals> - <plurals name="backup_card_trusted_lost"> - <item quantity="one">Przestałeś ufać %1$d transmiterowi</item> - <item quantity="few">Przestałeś ufać %1$d transmiterom</item> - <item quantity="many">Przestałeś ufać %1$d transmiterom</item> - <item quantity="other">Przestałeś ufać %1$d transmiterom</item> - </plurals> - <plurals name="backup_card_unblocked"> - <item quantity="one">Odblokowałeś %1$d transmiter</item> - <item quantity="few">Odblokowałeś %1$d transmiterów</item> - <item quantity="many">Odblokowałeś %1$d transmiterów</item> - <item quantity="other">Odblokowałeś %1$d transmitery</item> - </plurals> - <plurals name="backup_card_leave_chats"> - <item quantity="one">Opuściłeś %1$d czat publiczny</item> - <item quantity="few">Opuściłeś %1$d czatów publicznych</item> - <item quantity="many">Opuściłeś %1$d czatów publicznych</item> - <item quantity="other">Opuściłeś %1$d czaty publiczne</item> - </plurals> - <plurals name="backup_card_leave_communities"> - <item quantity="one">Opuściłeś %1$d społeczność</item> - <item quantity="few">Opuściłeś %1$d społeczności</item> - <item quantity="many">Opuściłeś %1$d społeczności</item> - <item quantity="other">Opuściłeś %1$d społeczności</item> - </plurals> - <plurals name="backup_card_leave_rooms"> - <item quantity="one">Opuściłeś %1$d pokój czatu</item> - <item quantity="few">Opuściłeś %1$d pokoi czatu</item> - <item quantity="many">Opuściłeś %1$d pokoi czatu</item> - <item quantity="other">Opuściłeś %1$d pokoje czatu</item> - </plurals> - <plurals name="backup_card_leave_groups"> - <item quantity="one">Opuściłeś %1$d grupę</item> - <item quantity="few">Opuściłeś %1$d grup</item> - <item quantity="many">Opuściłeś %1$d grup</item> - <item quantity="other">Opuszczono %1$d grupy</item> - </plurals> - <plurals name="backup_card_feeds_removed"> - <item quantity="one">Usunąłeś %1$d ulubiony kanał</item> - <item quantity="few">Usunąłeś %1$d ulubionych kanałów</item> - <item quantity="many">Usunąłeś %1$d ulubionych kanałów</item> - <item quantity="other">Usunięto %1$d ulubione kanały</item> - </plurals> - <plurals name="backup_card_hashtags_unfollowed"> - <item quantity="one">Przestałeś obserwować %1$d hashtag</item> - <item quantity="few">Przestałeś obserwować %1$d hashtagów</item> - <item quantity="many">Przestałeś obserwować %1$d hashtagów</item> - <item quantity="other">Przestałeś obserwować %1$d hashtagi</item> - </plurals> - <plurals name="backup_card_places_unfollowed"> - <item quantity="one">Przestałeś obserwować %1$d miejsce</item> - <item quantity="few">Przestałeś obserwować %1$d miejsc</item> - <item quantity="many">Przestałeś obserwować %1$d miejsc</item> - <item quantity="other">Przestałeś obserwować %1$d miejsca</item> - </plurals> - <plurals name="backup_card_trust_services_changed"> - <item quantity="one">Zmieniłeś dostawcę %1$d zaufanego serwisu</item> - <item quantity="few">Zmieniłeś dostawcę %1$d zaufanych serwisów</item> - <item quantity="many">Zmieniłeś dostawcę %1$d zaufanych serwisów</item> - <item quantity="other">Zmieniłeś dostawcę %1$d zaufanych serwisów</item> - </plurals> - <plurals name="backup_card_nutzap_mints_removed"> - <item quantity="one">Usunięto %1$d nutzap mint</item> - <item quantity="few">Usunięto %1$d nutzap mintów</item> - <item quantity="many">Usunięto %1$d nutzap mintów</item> - <item quantity="other">Usunięto %1$d nutzap minty</item> - </plurals> - <plurals name="backup_card_payment_targets_removed"> - <item quantity="one">Usunięto %1$d sposób na otrzymanie zapłaty</item> - <item quantity="few">Usunięto %1$d sposobów otrzymywania zapłaty</item> - <item quantity="many">Usunięto %1$d sposobów otrzymywania zapłaty</item> - <item quantity="other">Usunięto %1$d sposoby otrzymywania zapłaty</item> - </plurals> - <plurals name="backup_card_offers_removed"> - <item quantity="one">Usunięto %1$d ofertę BOLT12</item> - <item quantity="few">Usunięto %1$d ofert BOLT12</item> - <item quantity="many">Usunięto %1$d ofert BOLT12</item> - <item quantity="other">Usunięto %1$d oferty BOLT12</item> - </plurals> - <plurals name="backup_card_and_added"> - <item quantity="one">%1$s i dodano %2$d</item> - <item quantity="few">%1$s i dodano %2$d</item> - <item quantity="many">%1$s i dodano %2$d</item> - <item quantity="other">%1$s i dodano %2$d</item> - </plurals> - <plurals name="backup_card_and_followed"> - <item quantity="one">%1$s i obserwowany %2$d</item> - <item quantity="few">%1$s i obserwowanych %2$d</item> - <item quantity="many">%1$s i obserwowanych %2$d</item> - <item quantity="other">%1$s i obserwowane %2$d</item> - </plurals> - <plurals name="backup_card_and_muted"> - <item quantity="one">%1$s i wyciszony %2$d</item> - <item quantity="few">%1$s i wyciszono %2$d</item> - <item quantity="many">%1$s i wyciszono %2$d</item> - <item quantity="other">%1$s i wyciszone %2$d</item> - </plurals> - <plurals name="backup_card_and_joined"> - <item quantity="one">%1$s i dołączono do %2$d</item> - <item quantity="few">%1$s i dołączono do %2$d</item> - <item quantity="many">%1$s i dołączono do %2$d</item> - <item quantity="other">%1$s i dołączono do %2$d</item> - </plurals> - <plurals name="backup_card_and_blocked"> - <item quantity="one">%1$s i zablokowano %2$d</item> - <item quantity="few">%1$s i zablokowano %2$d</item> - <item quantity="many">%1$s i zablokowano %2$d</item> - <item quantity="other">%1$s i zablokowano %2$d</item> - </plurals> - <string name="backup_card_app_settings">Twoje zsynchronizowane ustawienia aplikacji Amethyst zostały zmienione w innej aplikacji, która nie jest Amethystem. To może powodować problemy.</string> - <string name="backup_card_nutzap_key_replaced">Wymieniłeś swój klucz Nutzap</string> - <string name="backup_card_nutzap_key_removed">Usunąłeś swój klucz nutzap</string> - <string name="backup_card_wallet_emptied">Opróżniłeś swój portfel Cashu</string> - <string name="backup_card_wallet_rewritten">Zmieniłeś swój portfel Cashu</string> - <string name="backup_card_concord_emptied">Opróżniłeś swoją listę Concorda</string> - <string name="backup_card_concord_rewritten">Zmieniłeś swoją listę Concorda</string> - <string name="backup_conflict_keep_new">Użyj nowej wersji</string> - <string name="backup_conflict_relay_read_only">tylko do odczytu</string> - <string name="backup_conflict_relay_write_only">tylko do zapisu</string> - <string name="backup_conflict_relay_read_write">odczyt i zapis</string> - <string name="backup_type_profile">profil</string> - <string name="backup_type_profile_explainer">Twoje imię i nazwisko, zdjęcie, biografia oraz adresy do płatności – tak, jak widzą je wszyscy.</string> - <string name="backup_type_follow_list">lista obserwowanych</string> - <string name="backup_type_follow_list_explainer">Określa, czyje posty pojawiają się w Twoim kanale głównym.</string> - <string name="backup_type_mute_list">lista zablokowanych</string> - <string name="backup_type_mute_list_explainer">Ukrywa osoby, słowa, hashtagi i wątki we wszystkich wyświetlanych treściach.</string> - <string name="backup_type_outbox_inbox_relays">lista publicznych transmiterów</string> - <string name="backup_type_outbox_inbox_relays_explainer">Informuje wszystkich, gdzie mogą znaleźć Twoje posty oraz gdzie mają wysyłać Ci odpowiedzi i wzmianki.</string> - <string name="backup_type_dm_relays">Lista transmiterów DM</string> - <string name="backup_type_dm_relays_explainer">Gdzie inni dostarczają Twoje prywatne wiadomości.</string> - <string name="backup_type_key_package_relays">transmitery kluczy czatu grupowego</string> - <string name="backup_type_key_package_relays_explainer">Gdzie inni znajdą klucze do dodawania cię do zaszyfrowanych czatów grupowych.</string> - <string name="backup_type_search_relays">lista transmiterów wyszukiwania</string> - <string name="backup_type_search_relays_explainer">Transmitery używane podczas wyszukiwania.</string> - <string name="backup_type_indexer_relays">lista transmiterów indeksujących</string> - <string name="backup_type_indexer_relays_explainer">Transmitery służące do wyszukiwania profili innych osób oraz list transmiterów.</string> - <string name="backup_type_relay_feeds">ulubione kanały transmitera</string> - <string name="backup_type_relay_feeds_explainer">Transmitery, które przeglądasz jako kanały informacyjne.</string> - <string name="backup_type_blocked_relays">lista zablokowanych przekaźników</string> - <string name="backup_type_blocked_relays_explainer">Transmitery, z którymi ta aplikacja nigdy się nie łączy.</string> - <string name="backup_type_trusted_relays">lista zaufanych transmiterów</string> - <string name="backup_type_trusted_relays_explainer">Zaufane transmitery, z którymi bez wahania nawiązujesz połączenie.</string> - <string name="backup_type_private_outbox_relays">lista prywatnych transmiterów</string> - <string name="backup_type_private_outbox_relays_explainer">Gdzie przechowywane są Twoje projekty i prywatne dane.</string> - <string name="backup_type_app_settings">zsynchronizowane ustawienia aplikacji</string> - <string name="backup_type_app_settings_explainer">Ustawienia Amethyst są synchronizowane na wszystkich Twoich urządzeniach. Są one zaszyfrowane, więc można porównać jedynie sam fakt ich obecności.</string> - <string name="backup_type_public_chats">lista czatów publicznych</string> - <string name="backup_type_public_chats_explainer">Publiczne czaty, do których dołączyłeś.</string> - <string name="backup_type_communities">lista społeczności</string> - <string name="backup_type_communities_explainer">Społeczności, do których dołączyłeś.</string> - <string name="backup_type_hashtags">obserwowane hashtagi</string> - <string name="backup_type_hashtags_explainer">Hashtagi, z których pochodzą posty wyświetlane w Twoich kanałach.</string> - <string name="backup_type_geohashes">obserwowane lokalizacje</string> - <string name="backup_type_geohashes_explainer">Miejsca, z których posty pojawiają się na Twoich kanałach.</string> - <string name="backup_type_favorite_algo_feeds">lista ulubionych kanałów</string> - <string name="backup_type_favorite_algo_feeds_explainer">Zapisane przez Ciebie kanały algorytmiczne.</string> - <string name="backup_type_ephemeral_chats">lista czatów efemerycznych</string> - <string name="backup_type_ephemeral_chats_explainer">Tymczasowe czaty, do których dołączyłeś.</string> - <string name="backup_type_relay_groups">lista grup</string> - <string name="backup_type_relay_groups_explainer">Grupy oparte na transmiterach, do których dołączyłeś.</string> - <string name="backup_type_concord_communities">Lista społeczności Concord</string> - <string name="backup_type_concord_communities_explainer">Społeczności Concord, do których dołączyłeś. Są one zaszyfrowane, więc można porównać jedynie fakt ich istnienia.</string> - <string name="backup_type_trust_providers">lista zaufanych dostawców</string> - <string name="backup_type_trust_providers_explainer">Serwisy, którym ufasz, a które zajmują się klasyfikowaniem i weryfikacją osób w Twoim imieniu.</string> - <string name="backup_type_cashu_wallet">Portfel Cashu</string> - <string name="backup_type_cashu_wallet_explainer">Ustawienia portfela ecash oraz minty. Są one zaszyfrowane, więc można porównać jedynie sam fakt ich istnienia.</string> - <string name="backup_type_nutzap_info">ustawienia odbioru nutzap</string> - <string name="backup_type_nutzap_info_explainer">Minty i klucz, które inni używają do wysyłania ci „nutzapów”.</string> - <string name="backup_type_payment_targets">adresy płatności</string> - <string name="backup_type_payment_targets_explainer">Sposoby, w jakie inni mogą Ci zapłacić.</string> - <string name="backup_type_bolt12_offers">Oferta BOLT12</string> - <string name="backup_type_bolt12_offers_explainer">Ta możliwość pozwala innym płacić Ci za pomocą Lightning.</string> - <string name="backup_type_other">lista</string> - <string name="backup_type_other_explainer">Lista zapisana na tym urządzeniu.</string> - <string name="backup_entry_profile_field">Pola profilu</string> - <string name="backup_entry_person">Osoby</string> - <string name="backup_entry_hashtag">Hashtagi</string> - <string name="backup_entry_word">Słowa</string> - <string name="backup_entry_thread">Wątki</string> - <string name="backup_entry_public_chat">Czaty publiczne</string> - <string name="backup_entry_community">Społeczności</string> - <string name="backup_entry_algo_feed">Kanały</string> - <string name="backup_entry_location">Lokalizacje</string> - <string name="backup_entry_relay">Transmitery</string> - <string name="backup_entry_relay_group">Grupy</string> - <string name="backup_entry_chat_room">Czat pokoje</string> - <string name="backup_entry_trust_provider">Zaufani dostawcy</string> - <string name="backup_entry_mint">Minty</string> - <string name="backup_entry_nutzap_key">Klucze Nutzap</string> - <string name="backup_entry_payment_target">Adresy płatności</string> - <string name="backup_entry_offer">Oferty</string> - <string name="backup_entry_identity_claim">Powiązane tożsamości</string> - <string name="backup_entry_other">Inne wpisy</string> - <string name="backup_profile_field_name">Nazwa użytkownika</string> - <string name="backup_profile_field_display_name">Nazwa wyświetlana</string> - <string name="backup_profile_field_about">O profilu</string> - <string name="backup_profile_field_picture">Zdjęcie</string> - <string name="backup_profile_field_banner">Baner</string> - <string name="backup_profile_field_website">Strona www</string> - <string name="backup_profile_field_nip05">Adres Nostr</string> - <string name="backup_profile_field_lud16">Lightning Adres</string> - <string name="backup_profile_field_lud06">LNURL</string> - <string name="backup_profile_field_pronouns">Zaimki</string> - <string name="backup_profile_field_birthday">Data urodzin</string> - <string name="backup_profile_field_clink_offer">Oferta CLINK</string> - <string name="backup_profile_field_bot">Flaga bota</string> </resources> diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 2da4657d8d..3803e00f8a 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -305,250 +305,6 @@ - <string name="cordn_groups_manage">Coordinators</string> - <string name="cordn_groups_down_note">Nothing is arriving from these groups. Amethyst keeps retrying.</string> - <string name="cordn_groups_none">No cordn groups yet</string> - <string name="cordn_groups_none_detail">A cordn group lives on one coordinator that holds its membership and passes messages along, without ever seeing what anyone writes.</string> - <string name="cordn_groups_start">Start one</string> - <string name="cordn_group_unavailable">This group is not available. Its coordinator may have been removed.</string> - <string name="cordn_group_info">Group info</string> - <string name="cordn_message_deleted">Message deleted</string> - <string name="cordn_message_edited">edited</string> - <string name="cordn_action_go_to_message">Go to message</string> - <string name="cordn_delete_confirm_title">Withdraw message</string> - <string name="cordn_delete_confirm_body">Everyone in the group will see this message as withdrawn, and Amethyst will stop showing its text. The coordinator still holds the encrypted copy it was sent as, and anyone who already read it has already read it.</string> - <string name="cordn_delivery_accepted">Accepted by the coordinator</string> - <string name="cordn_message_details_title">Message details</string> - <string name="cordn_message_details_sent_at">Sent</string> - <string name="cordn_message_details_cursor">Cursor</string> - <string name="cordn_message_details_coordinator">Coordinator</string> - <string name="cordn_composer_hint">Message</string> - <string name="cordn_action_reply">Reply</string> - <string name="cordn_action_edit">Edit</string> - <string name="cordn_action_delete">Delete</string> - <string name="cordn_action_pin">Pin</string> - <string name="cordn_pinned_previous">Previous pinned message</string> - <string name="cordn_pinned_next">Next pinned message</string> - <string name="cordn_pinned_show_all">Show all pinned messages</string> - <string name="cordn_pinned_title">Pinned messages</string> - <string name="cordn_pinned_by">Pinned by %1$s</string> - <plurals name="cordn_pinned_count"> - <item quantity="one">%1$d pinned message in this group</item> - <item quantity="other">%1$d pinned messages in this group</item> - </plurals> - <string name="cordn_action_unpin">Unpin</string> - <string name="cordn_action_editing">Editing your message</string> - <string name="cordn_media_upload_failed">Could not send that file.</string> - <string name="cordn_send_failed">That did not send.</string> - <string name="cordn_send_no_session">This group\'s coordinator is not open, so nothing can be sent yet.</string> - <string name="cordn_media_download_failed">Could not open that file.</string> - <string name="cordn_media_no_server">No media server is set for this account, so there is nowhere to put the file. Pick one in Settings.</string> - <string name="cordn_media_unreadable">That file could not be read.</string> - <string name="cordn_voice_record">Record a voice note</string> - <string name="cordn_voice_stop">Stop and send</string> - <string name="cordn_voice_play">Play voice note</string> - <string name="cordn_backup_explainer">Your nsec cannot bring cordn groups back. A cordn group lives as encryption state on this device and an ordered stream on a coordinator that cannot read it — lose the device without a backup and the group is gone, including everything already said in it.</string> - <string name="cordn_backup_passphrase">Passphrase</string> - <string name="cordn_backup_contents_title">What is in the file</string> - <string name="cordn_backup_contents_body">Your group encryption keys, your key packages, and every message in those groups. Anyone who opens it can read the conversations. The passphrase is the only thing protecting it once it leaves Amethyst.</string> - <string name="cordn_backup_export">Export</string> - <string name="cordn_backup_exported">Backup saved.</string> - <string name="cordn_backup_restore_title">Restore</string> - <string name="cordn_backup_restore_body">Restoring replaces the cordn groups on this device with the ones in the file. It is for a phone that has taken over from another — not a way to use the same groups on two devices at once.</string> - <string name="cordn_backup_restore">Restore</string> - <string name="cordn_backup_restore_confirm_title">Replace this device\'s cordn groups?</string> - <string name="cordn_backup_restore_confirm_body">Every cordn group now on this device will be removed and replaced by the ones in the file.\n\nDo not restore onto a second phone while the first is still in use. Both would hold the same group keys, and once both have sent a message the group breaks for everyone — silently, and with no way back.</string> - <string name="cordn_backup_restored">Restored.</string> - <string name="cordn_backup_failed">That did not work. Check the passphrase and the file.</string> - <string name="cordn_send">Send</string> - <string name="cordn_info_coordinator">Coordinator</string> - <string name="cordn_info_coordinator_key">Coordinator key</string> - <string name="cordn_info_coordinator_nprofile">Coordinator link</string> - <string name="cordn_info_copy_nprofile">Copy the coordinator\'s nprofile</string> - <string name="cordn_info_gid">Group id</string> - <string name="cordn_info_epoch">Epoch</string> - <string name="cordn_info_members">Members</string> - <string name="cordn_info_egalitarian">This group has no admins: everyone can add, remove and rename, permanently.</string> - <string name="cordn_info_technical">Technical details</string> - <string name="cordn_create_title">New Cordn group</string> - <string name="cordn_create_explainer">A cordn group is ordered by one coordinator you choose. Creating the group happens on this device — the coordinator only learns the group exists once you invite someone or send a message.</string> - <string name="cordn_create_people_none">Nobody yet — tap to add people</string> - <string name="cordn_create_people_empty">Nobody added yet. A group can be created empty and invitations sent later, but naming people now is what shows which coordinator can reach them.</string> - <string name="cordn_create_people_done">Done</string> - <string name="cordn_create_you">You, always an admin</string> - <string name="cordn_create_step_name">Name it</string> - <string name="cordn_create_step_people">Who is in it</string> - <string name="cordn_create_step_where">Where it lives</string> - <string name="cordn_create_step_admins">Who can add and remove</string> - <string name="cordn_create_add_member">Add someone</string> - <string name="cordn_create_people_note">Naming people now is what tells us which coordinator can reach them. Each one gets a Welcome left on that coordinator, which they pick up next time they open their app.</string> - <string name="cordn_create_reach_unknown">Not checked yet</string> - <string name="cordn_create_reach_none">No key on any coordinator you use</string> - <string name="cordn_create_egalitarian">Everyone can add and remove</string> - <string name="cordn_create_admin">Admin</string> - <string name="cordn_create_no_coordinator">No coordinator chosen</string> - <string name="cordn_create_coordinator_change">Change</string> - <string name="cordn_create_coordinator_hide">Done</string> - <string name="cordn_create_coverage_unknown">Not checked — picking it will ask</string> - <string name="cordn_create_coverage_all">Reaches all %1$d of your people</string> - <string name="cordn_create_coverage_partial">Reaches %1$d of %2$d — the rest would be left out</string> - <string name="cordn_create_outcome_title">The group exists</string> - <string name="cordn_create_outcome_explainer">Each invitation is a Welcome left on the coordinator, so they can fail one at a time. Nobody has been notified: they see it next time they open their app.</string> - <string name="cordn_create_outcome_no_welcome">Added to the group, but the invitation did not reach them — try again from the group screen</string> - <string name="cordn_create_outcome_waiting">Waiting for them</string> - <string name="cordn_create_outcome_failed">The coordinator refused the invitation</string> - <string name="cordn_create_outcome_skipped">No key here, so no Welcome could be left</string> - <string name="cordn_create_outcome_link">Send a link</string> - <string name="cordn_create_outcome_open">Open the chat</string> - <string name="cordn_create_coordinator">Coordinator</string> - <string name="cordn_create_coordinator_discover">Find coordinators</string> - <string name="cordn_create_coordinator_new">Another coordinator</string> - <string name="cordn_create_coordinator_pubkey">Coordinator public key (hex or npub)</string> - <string name="cordn_create_coordinator_relays">Relays it answers on, one per line</string> - <string name="cordn_create_name">Group name</string> - <string name="cordn_create_description">Description (optional)</string> - <string name="cordn_create_egalitarian_note">Everyone in the group can invite, remove members and change its details. Any member can name admins later, which ends that.</string> - <string name="cordn_create_admin_only_me">Only I manage this group</string> - <string name="cordn_admin_action_failed">That change could not be made.</string> - <string name="cordn_info_remove_member">Remove</string> - <string name="cordn_info_remove_confirm_title">Remove from group?</string> - <string name="cordn_info_remove_confirm_body">%1$s stops receiving messages from here. They keep what they have already read, and this cannot be undone — they would have to be invited again.</string> - <string name="cordn_info_edit_details">Edit details</string> - <string name="cordn_info_save">Save</string> - <string name="cordn_create_admin_only_me_on">Only you can invite, remove members or change the group\'s details. You can hand that to someone else later.</string> - <string name="cordn_create_action">Create group</string> - <string name="cordn_create_failed">The group could not be created.</string> - <string name="cordn_invitations_title">Cordn invitations</string> - <string name="cordn_invitations_explainer">Checked when you open this screen, and at no other time — asking a coordinator whether anyone invited you tells it you are here, so Amethyst does not do it in the background.</string> - <string name="cordn_invitations_refresh">Check again</string> - <string name="cordn_invitations_accept">Join</string> - <string name="cordn_invitations_decline">Decline</string> - <string name="cordn_invitations_decline_warning">Declining is permanent. Getting back in means being invited again.</string> - <string name="cordn_chat_yesterday">Yesterday</string> - <string name="cordn_chat_unread_divider">New messages</string> - <string name="cordn_chat_empty_title">No messages yet</string> - <string name="cordn_chat_empty_description">Anything sent here is encrypted for this group. The coordinator relays it without being able to read it.</string> - <string name="cordn_reactions_title">Reactions</string> - <string name="cordn_group_unavailable_detail">Add a coordinator in Settings to start using cordn groups.</string> - <string name="cordn_coordinators_discover_explainer">Reads announcements your relays already carry. No coordinator is contacted, so none of them learns you looked.</string> - <string name="cordn_coordinators_discover_action">Look for coordinators</string> - <string name="cordn_coordinators_discover_add">Add</string> - <string name="cordn_coordinators_discover_failed">Could not read announcements</string> - <string name="cordn_coordinators_discover_none">Nobody is announcing on your relays.</string> - <string name="cordn_coordinators_discover_unheard">Nothing found, and %1$d of your relays did not answer.</string> - <string name="cordn_coordinators_discover_seen">Announced %1$s ago</string> - <string name="cordn_coordinators_discover_seen_on">Last announced %1$s</string> - <string name="cordn_coordinators_relays">%1$s</string> - <string name="cordn_coordinators_relays_more">%1$s +%2$d more</string> - <string name="cordn_coordinators_show_all">Show all %1$d</string> - <string name="cordn_coordinators_show_fewer">Show fewer</string> - <string name="cordn_coordinators_show_older">Show %1$d that stopped announcing</string> - <string name="cordn_coordinators_hide_older">Hide the ones that stopped announcing</string> - <string name="cordn_coordinators_stale_note">These have not announced in over a month. Creating a group on one that has gone away will fail.</string> - <string name="cordn_info_add_member">Add someone</string> - <string name="cordn_info_add_member_placeholder">Name, npub or name@domain</string> - <string name="cordn_info_has_key_package">Can be added on this coordinator</string> - <string name="cordn_info_add_member_note">They can only be added if they published a key package to this coordinator.</string> - <string name="cordn_exposure_open">What this coordinator can see</string> - <string name="cordn_exposure_close">Got it</string> - <string name="cordn_admin_someone">That person</string> - <string name="cordn_admin_no_key_package">%1$s has not published a key package to this coordinator, so they cannot be added yet. Ask them to open cordn on this coordinator first.</string> - <string name="cordn_admin_wrong_key_package">This coordinator served a key package that belongs to someone else, so %1$s was not added. Nothing was changed.</string> - <string name="cordn_admin_no_welcome">%1$s could not be given a way into the group, so the add was abandoned.</string> - <string name="cordn_admin_not_a_member">%1$s is not in this group.</string> - <string name="cordn_admin_no_self_remove">You cannot remove yourself from a cordn group.</string> - <string name="cordn_admin_coordinator_silent">The coordinator did not answer, so nothing was changed. Try again in a moment.</string> - <string name="cordn_info_add_member_none">Nobody found by that name.</string> - <string name="cordn_info_admin_badge">Admin</string> - <string name="cordn_invitations_members_more">+%1$d more</string> - <string name="cordn_invitations_via">Through %1$s</string> - <string name="cordn_invitations_none">No invitations waiting.</string> - <string name="cordn_invitations_no_coordinators">You have no coordinator open. cordn invitations arrive through a coordinator, so there is nowhere to look yet.</string> - <string name="cordn_invitations_unreachable">%1$s did not answer</string> - <string name="cordn_invitations_skipped">An invitation was left for another device</string> - <string name="cordn_invitations_load_failed">Could not read invitations.</string> - <string name="cordn_link_request">Ask to join this group</string> - <string name="cordn_link_request_disclosure">This publishes a key package under your own account key and tells the coordinator you want into this group — whether or not anyone answers.</string> - <string name="cordn_link_request_sent">Asked. A member of the group has to add you; the invitation will show up under cordn invitations.</string> - <string name="cordn_link_request_failed">Could not ask to join.</string> - <string name="cordn_requests_title">Requests to join</string> - <string name="cordn_requests_check">Check for requests</string> - <string name="cordn_requests_none">Nobody is waiting to join.</string> - <string name="cordn_requests_accept">Add</string> - <string name="cordn_requests_decline">Dismiss</string> - <string name="cordn_requests_admin_only">Only an admin can accept. In a group with no admins, that is everyone.</string> - <string name="cordn_requests_failed">Could not read the requests.</string> - <string name="cordn_link_scan">Scan</string> - <string name="cordn_share_title">Share this group</string> - <string name="cordn_share_explainer">Anyone with this link can ask to join. An admin still has to let them in.</string> - <string name="cordn_share_copy">Copy link</string> - <string name="cordn_share_copied">Copied</string> - <string name="cordn_coordinators_explainer">A coordinator orders one group\'s messages and is the only thing that can. Losing it loses the ordering, and a second one does not mirror the first — so these are named things you choose, not interchangeable relays.</string> - <string name="cordn_coordinators_none">No coordinators yet.</string> - <string name="cordn_coordinators_add_action">Add</string> - <string name="cordn_coordinators_add_failed">Could not open that coordinator.</string> - <string name="cordn_coordinators_label">A name for it (yours, optional)</string> - <string name="cordn_coordinators_relays_label">Answers on</string> - <string name="cordn_coordinators_key">Coordinator key</string> - <string name="cordn_coordinators_copy_key">Copy the coordinator\'s npub</string> - <string name="cordn_coordinators_more">More actions</string> - <string name="cordn_coordinators_rename">Rename</string> - <string name="cordn_coordinators_label_note">This name is yours and stays on this device. A coordinator cannot prove a name, so it is never asked for one.</string> - <string name="cordn_coordinators_identify">Ask who it is</string> - <string name="cordn_coordinators_server">Says it is: %1$s</string> - <string name="cordn_coordinators_server_silent">Answered, but named nothing.</string> - <string name="cordn_coordinators_server_claim">A coordinator\'s name and version are strings it chose. Its public key is the only thing that identifies it.</string> - <string name="cordn_coordinators_health_ok">Answering.</string> - <string name="cordn_coordinators_health_retrying">A call failed; retrying.</string> - <string name="cordn_coordinators_health_down">Not answering — %1$d calls in a row have failed. Groups on it are not syncing.</string> - <string name="cordn_coordinators_health_unknown">Nothing asked of it yet.</string> - <string name="cordn_coordinators_remove">Remove</string> - <string name="cordn_coordinators_purge">Purge</string> - <string name="cordn_coordinators_purge_title">Purge this coordinator?</string> - <string name="cordn_coordinators_purge_body">Removing keeps the groups on this device, so adding the coordinator back brings them with it. Purging deletes them: the group keys, the read positions and your key packages all go, and those groups can only be re-entered by a fresh invitation.\n\nThe coordinator is not told and keeps whatever it already had.</string> - <string name="cordn_keypackages_explainer">Others can only add you to a cordn group by taking a key package you published to that coordinator. cordn has no other place to keep one, so with none published nobody can invite you — and nothing tells them why.</string> - <plurals name="cordn_create_unreachable_count"> - <item quantity="one">%1$d person cannot be reached by any coordinator you use</item> - <item quantity="other">%1$d people cannot be reached by any coordinator you use</item> - </plurals> - <plurals name="cordn_groups_count"> - <item quantity="one">%1$d group</item> - <item quantity="other">%1$d groups</item> - </plurals> - <plurals name="cordn_create_reach_count"> - <item quantity="one">Reachable on %1$d coordinator</item> - <item quantity="other">Reachable on %1$d coordinators</item> - </plurals> - <plurals name="cordn_create_admin_count"> - <item quantity="one">%1$d person can add and remove</item> - <item quantity="other">%1$d people can add and remove</item> - </plurals> - <plurals name="cordn_create_action_invite"> - <item quantity="one">Create and invite %1$d person</item> - <item quantity="other">Create and invite %1$d people</item> - </plurals> - <plurals name="cordn_member_count"> - <item quantity="one">%1$d member</item> - <item quantity="other">%1$d members</item> - </plurals> - <plurals name="cordn_keypackages_summary"> - <item quantity="one">%1$d single-use package available.</item> - <item quantity="other">%1$d single-use packages available.</item> - </plurals> - <string name="cordn_keypackages_last_resort_yes">A last-resort package is published, so an invitation can still be made once the single-use ones run out.</string> - <string name="cordn_keypackages_last_resort_no">No last-resort package. Once the single-use ones run out, invitations fail.</string> - <plurals name="cordn_keypackages_orphans"> - <item quantity="one">%1$d package this device cannot open</item> - <item quantity="other">%1$d packages this device cannot open</item> - </plurals> - <string name="cordn_keypackages_orphans_body">The coordinator will hand these to anyone inviting you, but the private half is not on this device — it belongs to another install. If that install is gone, an invitation using one of these produces a welcome nobody can ever open.</string> - <string name="cordn_keypackages_withdraw_orphans">Withdraw those</string> - <string name="cordn_keypackages_disclosure">Publishing signs a record under your own account key. That coordinator then knows this account exists and is invitable — permanently, and whether or not anyone invites you.</string> - <string name="cordn_keypackages_publish">Publish one</string> - <string name="cordn_keypackages_publish_last_resort">Publish last-resort</string> - <string name="cordn_keypackages_withdraw_all">Withdraw all</string> - <string name="cordn_keypackages_topup_note">Once you have published here, Amethyst quietly replaces packages as they are used up. It never publishes the first one for you.</string> - <string name="cordn_keypackages_failed">Could not read the key packages.</string> <!-- Buzz calls its groups channels, and a Buzz relay is one workspace rather than a directory of groups. --> <string name="buzz_channel_flag_forum">Forum channel</string> <string name="buzz_channel_flag_forum_desc">Threaded posts instead of a chat timeline. This cannot be changed later.</string> @@ -899,10 +655,6 @@ <!-- My Fitness: the signed-in user's own training summary. Android resource because NavBarItemDef labels are R.string ids. --> - <plurals name="cordn_invitations_members"> - <item quantity="one">%1$d person is already in this group</item> - <item quantity="other">%1$d people are already in this group</item> - </plurals> <string name="private_message">Private Message</string> <string name="pow_kind_chat_message">Chat message</string> <string name="nest_notification_broadcasting">Nest — Live</string> @@ -931,326 +683,4 @@ <string name="mute_button">Sound on. Click to mute</string> <string name="play">Play</string> <string name="pause">Pause</string> - <string name="backup_conflict_title">You changed your %1$s in another app</string> - <string name="backup_conflict_removed">Removed (%1$s)</string> - <string name="backup_conflict_added">Added (%1$s)</string> - <string name="backup_conflict_changed">Changed (%1$s)</string> - <string name="backup_conflict_private_cleared">You removed all private (encrypted) items.</string> - <string name="backup_conflict_private_changed">You also changed private (encrypted) items. They can\'t be compared here.</string> - <string name="backup_conflict_private_added">You added private (encrypted) items.</string> - <string name="backup_conflict_restore_mine">Restore saved version</string> - <string name="backup_review_saved">On this device</string> - <string name="backup_review_new">New version</string> - <string name="backup_review_not_set">Not set</string> - <string name="backup_conflict_count_removed">%1$s removed</string> - <string name="backup_conflict_count_added">%1$s added</string> - <string name="backup_conflict_count_changed">%1$s changed</string> - <plurals name="backup_card_follows_dropped"> - <item quantity="one">You unfollowed %1$d person</item> - <item quantity="other">You unfollowed %1$d people</item> - </plurals> - <string name="backup_card_accept">Accept</string> - <string name="backup_review_title_updated">%1$s updated</string> - <string name="backup_review_title_cleared">%1$s cleared</string> - <string name="backup_review_changed_by_other_app">Changed in another app · %1$s</string> - <string name="backup_action_keep_count">Keep %1$s</string> - <string name="backup_action_restore_count">Restore %1$s</string> - <string name="backup_action_remute">Re-mute %1$s</string> - <string name="backup_action_rejoin">Rejoin %1$s</string> - <string name="backup_action_restore_profile">Restore profile</string> - <string name="backup_action_restore_relays">Restore relays</string> - <string name="backup_review_kept_count">%1$s kept</string> - <string name="backup_review_dropped_count">%1$s dropped</string> - <string name="backup_review_new_count">%1$s new</string> - <string name="backup_review_tab_dropped">Dropped · %1$s</string> - <string name="backup_review_tab_new">New · %1$s</string> - <string name="backup_review_tab_edited">Edited · %1$s</string> - <string name="backup_review_search_people">Search %1$s people</string> - <string name="backup_review_nothing_here">Nothing here</string> - <string name="backup_review_unmuted">%1$s unmuted</string> - <string name="backup_review_unmuted_explainer">These can reach your feeds again</string> - <string name="backup_review_newly_muted">%1$s newly muted</string> - <string name="backup_review_words_and_hashtags">Words & hashtags</string> - <string name="backup_review_names_and_more">%1$s and %2$s more</string> - <string name="backup_review_private_wiped_title">You removed all private items</string> - <string name="backup_review_private_wiped_body">They\'re encrypted, so only you can see what they were. Restoring brings them all back.</string> - <string name="backup_review_private_added_title">You made items private</string> - <string name="backup_review_private_added_body">They\'re encrypted now, which is likely where the public items above went.</string> - <string name="backup_review_private_changed_title">You changed private items</string> - <string name="backup_review_private_changed_body">They\'re encrypted, so they can\'t be compared here.</string> - <string name="backup_review_gone">Gone</string> - <string name="backup_review_new_tag">New</string> - <string name="backup_review_dropped_tag">Dropped</string> - <string name="backup_review_demoted_tag">Moved</string> - <string name="backup_review_units_tag">Units</string> - <string name="backup_review_left_tag">Left</string> - <string name="backup_review_renamed_tag">Renamed</string> - <string name="backup_review_outbox">Outbox</string> - <string name="backup_review_outbox_explainer">where you publish</string> - <string name="backup_review_inbox">Inbox</string> - <string name="backup_review_inbox_explainer">where others reach you</string> - <string name="backup_review_key_replaced">Receiving key replaced</string> - <string name="backup_review_key_removed">Receiving key removed</string> - <string name="backup_review_key_warning">Nutzaps will be locked to this new key. If this device doesn\'t have it, you may not be able to redeem them here.</string> - <string name="backup_review_left">left</string> - <string name="backup_review_joined">joined</string> - <string name="backup_review_renamed">renamed</string> - <string name="backup_review_was_named">was “%1$s”</string> - <string name="backup_action_block_again">Block %1$s again</string> - <string name="backup_action_restore_feeds">Restore feeds</string> - <string name="backup_action_restore_topics">Restore topics</string> - <string name="backup_action_restore_places">Restore places</string> - <string name="backup_action_restore_providers">Restore providers</string> - <string name="backup_action_restore_wallet">Restore wallet</string> - <string name="backup_review_kept">kept</string> - <string name="backup_review_receiving_tag">Receiving</string> - <string name="backup_review_listed_tag">Listed</string> - <string name="backup_review_searching_tag">Searching</string> - <string name="backup_review_storing_tag">Storing</string> - <string name="backup_review_trusted_tag">Trusted</string> - <string name="backup_review_untrusted_tag">Untrusted</string> - <string name="backup_review_unblocked">%1$s unblocked</string> - <string name="backup_review_unblocked_explainer">Amethyst will connect to them again</string> - <string name="backup_review_no_longer_blocked">No longer blocked</string> - <string name="backup_review_newly_blocked">Newly blocked</string> - <string name="backup_review_still_blocked">Still blocked</string> - <string name="backup_review_allowed_tag">Allowed</string> - <string name="backup_review_moved_tag">Moved</string> - <string name="backup_review_feed">Feed</string> - <string name="backup_review_topics_gone">topics gone</string> - <string name="backup_review_places_gone">places gone</string> - <string name="backup_review_new_count_caption">new</string> - <string name="backup_review_hashtags_you_follow">Hashtags you follow</string> - <string name="backup_review_places_you_follow">Places you follow</string> - <string name="backup_review_services_changed">%1$s of %2$s services changed</string> - <string name="backup_review_services_changed_explainer">They now come from someone else, or no one</string> - <string name="backup_review_service">Service</string> - <string name="backup_review_service_rank">Rank</string> - <string name="backup_review_service_followers">Followers</string> - <string name="backup_review_service_topics">Topics</string> - <string name="backup_review_trust_warning">Ranks, follower counts and spam filtering you see come from these providers.</string> - <plurals name="backup_review_payment_targets_gone"> - <item quantity="one">%1$d way to pay you removed</item> - <item quantity="other">%1$d ways to pay you removed</item> - </plurals> - <plurals name="backup_review_offers_gone"> - <item quantity="one">%1$d offer removed</item> - <item quantity="other">%1$d offers removed</item> - </plurals> - <string name="backup_review_payments_explainer">People paying you only see what\'s in this list.</string> - <string name="backup_review_offer">BOLT12 offer</string> - <string name="backup_review_encrypted_cleared">You emptied it</string> - <string name="backup_review_encrypted_added">You created it</string> - <string name="backup_review_encrypted_changed">You changed it</string> - <string name="backup_review_wallet_body">Your wallet\'s mints and keys are encrypted to you, so they can\'t be compared item by item here. Restoring puts back exactly what this device saved.</string> - <string name="backup_review_concord_body">Your Concord communities are encrypted to you, so they can\'t be compared here. Restoring puts back exactly what this device saved.</string> - <plurals name="backup_card_mutes_visible"> - <item quantity="one">You unmuted %1$d thing</item> - <item quantity="other">You unmuted %1$d things</item> - </plurals> - <plurals name="backup_card_profile_lost_fields"> - <item quantity="one">You removed %1$d profile field</item> - <item quantity="other">You removed %1$d profile fields</item> - </plurals> - <plurals name="backup_card_nip65_lost"> - <item quantity="one">You removed %1$d public relay</item> - <item quantity="other">You removed %1$d public relays</item> - </plurals> - <plurals name="backup_card_dm_relays_lost"> - <item quantity="one">You removed %1$d DM relay</item> - <item quantity="other">You removed %1$d DM relays</item> - </plurals> - <plurals name="backup_card_key_package_relays_lost"> - <item quantity="one">You removed %1$d group-chat key relay</item> - <item quantity="other">You removed %1$d group-chat key relays</item> - </plurals> - <plurals name="backup_card_search_relays_lost"> - <item quantity="one">You removed %1$d search relay</item> - <item quantity="other">You removed %1$d search relays</item> - </plurals> - <plurals name="backup_card_indexer_relays_lost"> - <item quantity="one">You removed %1$d indexer relay</item> - <item quantity="other">You removed %1$d indexer relays</item> - </plurals> - <plurals name="backup_card_relay_feeds_lost"> - <item quantity="one">You removed %1$d relay feed</item> - <item quantity="other">You removed %1$d relay feeds</item> - </plurals> - <plurals name="backup_card_private_outbox_lost"> - <item quantity="one">You removed %1$d private relay</item> - <item quantity="other">You removed %1$d private relays</item> - </plurals> - <plurals name="backup_card_trusted_lost"> - <item quantity="one">You stopped trusting %1$d relay</item> - <item quantity="other">You stopped trusting %1$d relays</item> - </plurals> - <plurals name="backup_card_unblocked"> - <item quantity="one">You unblocked %1$d relay</item> - <item quantity="other">You unblocked %1$d relays</item> - </plurals> - <plurals name="backup_card_leave_chats"> - <item quantity="one">You left %1$d public chat</item> - <item quantity="other">You left %1$d public chats</item> - </plurals> - <plurals name="backup_card_leave_communities"> - <item quantity="one">You left %1$d community</item> - <item quantity="other">You left %1$d communities</item> - </plurals> - <plurals name="backup_card_leave_rooms"> - <item quantity="one">You left %1$d chat room</item> - <item quantity="other">You left %1$d chat rooms</item> - </plurals> - <plurals name="backup_card_leave_groups"> - <item quantity="one">You left %1$d group</item> - <item quantity="other">You left %1$d groups</item> - </plurals> - <plurals name="backup_card_feeds_removed"> - <item quantity="one">You removed %1$d favorite feed</item> - <item quantity="other">You removed %1$d favorite feeds</item> - </plurals> - <plurals name="backup_card_hashtags_unfollowed"> - <item quantity="one">You unfollowed %1$d hashtag</item> - <item quantity="other">You unfollowed %1$d hashtags</item> - </plurals> - <plurals name="backup_card_places_unfollowed"> - <item quantity="one">You unfollowed %1$d place</item> - <item quantity="other">You unfollowed %1$d places</item> - </plurals> - <plurals name="backup_card_trust_services_changed"> - <item quantity="one">You changed the provider of %1$d trust service</item> - <item quantity="other">You changed the provider of %1$d trust services</item> - </plurals> - <plurals name="backup_card_nutzap_mints_removed"> - <item quantity="one">You removed %1$d nutzap mint</item> - <item quantity="other">You removed %1$d nutzap mints</item> - </plurals> - <plurals name="backup_card_payment_targets_removed"> - <item quantity="one">You removed %1$d way to get paid</item> - <item quantity="other">You removed %1$d ways to get paid</item> - </plurals> - <plurals name="backup_card_offers_removed"> - <item quantity="one">You removed %1$d BOLT12 offer</item> - <item quantity="other">You removed %1$d BOLT12 offers</item> - </plurals> - <plurals name="backup_card_and_added"> - <item quantity="one">%1$s and added %2$d</item> - <item quantity="other">%1$s and added %2$d</item> - </plurals> - <plurals name="backup_card_and_followed"> - <item quantity="one">%1$s and followed %2$d</item> - <item quantity="other">%1$s and followed %2$d</item> - </plurals> - <plurals name="backup_card_and_muted"> - <item quantity="one">%1$s and muted %2$d</item> - <item quantity="other">%1$s and muted %2$d</item> - </plurals> - <plurals name="backup_card_and_joined"> - <item quantity="one">%1$s and joined %2$d</item> - <item quantity="other">%1$s and joined %2$d</item> - </plurals> - <plurals name="backup_card_and_blocked"> - <item quantity="one">%1$s and blocked %2$d</item> - <item quantity="other">%1$s and blocked %2$d</item> - </plurals> - <string name="backup_card_app_settings">Your Amethyst synced app settings changed in another app that is not Amethyst. This can create problems.</string> - <string name="backup_card_nutzap_key_replaced">You replaced your nutzap key</string> - <string name="backup_card_nutzap_key_removed">You removed your nutzap key</string> - <string name="backup_card_wallet_emptied">You emptied your Cashu wallet</string> - <string name="backup_card_wallet_rewritten">You changed your Cashu wallet</string> - <string name="backup_card_concord_emptied">You emptied your Concord list</string> - <string name="backup_card_concord_rewritten">You changed your Concord list</string> - <string name="backup_conflict_keep_new">Use new version</string> - <string name="backup_conflict_relay_read_only">read only</string> - <string name="backup_conflict_relay_write_only">write only</string> - <string name="backup_conflict_relay_read_write">read and write</string> - <string name="backup_type_profile">profile</string> - <string name="backup_type_profile_explainer">Your name, picture, bio and payment addresses, as everyone sees them.</string> - <string name="backup_type_follow_list">follow list</string> - <string name="backup_type_follow_list_explainer">Decides whose posts appear in your home feed.</string> - <string name="backup_type_mute_list">mute list</string> - <string name="backup_type_mute_list_explainer">Hides people, words, hashtags and threads from everything you see.</string> - <string name="backup_type_outbox_inbox_relays">public relay list</string> - <string name="backup_type_outbox_inbox_relays_explainer">Tells everyone where to find your posts and where to send you replies and mentions.</string> - <string name="backup_type_dm_relays">DM relay list</string> - <string name="backup_type_dm_relays_explainer">Where others deliver your private messages.</string> - <string name="backup_type_key_package_relays">group-chat key relays</string> - <string name="backup_type_key_package_relays_explainer">Where others find the keys to add you to encrypted group chats.</string> - <string name="backup_type_search_relays">search relay list</string> - <string name="backup_type_search_relays_explainer">The relays used when you search.</string> - <string name="backup_type_indexer_relays">indexer relay list</string> - <string name="backup_type_indexer_relays_explainer">The relays used to find other people\'s profiles and relay lists.</string> - <string name="backup_type_relay_feeds">favorite relay feeds</string> - <string name="backup_type_relay_feeds_explainer">The relays you browse as feeds.</string> - <string name="backup_type_blocked_relays">blocked relay list</string> - <string name="backup_type_blocked_relays_explainer">Relays this app never connects to.</string> - <string name="backup_type_trusted_relays">trusted relay list</string> - <string name="backup_type_trusted_relays_explainer">Relays you trust to connect to without asking.</string> - <string name="backup_type_private_outbox_relays">private relay list</string> - <string name="backup_type_private_outbox_relays_explainer">Where your drafts and private data are stored.</string> - <string name="backup_type_app_settings">synced app settings</string> - <string name="backup_type_app_settings_explainer">Amethyst settings synced across your devices. They are encrypted, so only their presence can be compared.</string> - <string name="backup_type_public_chats">public chat list</string> - <string name="backup_type_public_chats_explainer">The public chats you joined.</string> - <string name="backup_type_communities">community list</string> - <string name="backup_type_communities_explainer">The communities you joined.</string> - <string name="backup_type_hashtags">followed hashtags</string> - <string name="backup_type_hashtags_explainer">Hashtags whose posts appear in your feeds.</string> - <string name="backup_type_geohashes">followed locations</string> - <string name="backup_type_geohashes_explainer">Locations whose posts appear in your feeds.</string> - <string name="backup_type_favorite_algo_feeds">favorite feeds list</string> - <string name="backup_type_favorite_algo_feeds_explainer">The algorithmic feeds you saved.</string> - <string name="backup_type_ephemeral_chats">ephemeral chat list</string> - <string name="backup_type_ephemeral_chats_explainer">The ephemeral chat rooms you joined.</string> - <string name="backup_type_relay_groups">group list</string> - <string name="backup_type_relay_groups_explainer">The relay-based groups you joined.</string> - <string name="backup_type_concord_communities">Concord community list</string> - <string name="backup_type_concord_communities_explainer">The Concord communities you joined. They are encrypted, so only their presence can be compared.</string> - <string name="backup_type_trust_providers">trust provider list</string> - <string name="backup_type_trust_providers_explainer">The services you trust to rank and verify people for you.</string> - <string name="backup_type_cashu_wallet">Cashu wallet</string> - <string name="backup_type_cashu_wallet_explainer">Your ecash wallet settings and mints. They are encrypted, so only their presence can be compared.</string> - <string name="backup_type_nutzap_info">nutzap receiving settings</string> - <string name="backup_type_nutzap_info_explainer">The mints and key others use to send you nutzaps.</string> - <string name="backup_type_payment_targets">payment addresses</string> - <string name="backup_type_payment_targets_explainer">The ways other people can pay you.</string> - <string name="backup_type_bolt12_offers">BOLT12 offers</string> - <string name="backup_type_bolt12_offers_explainer">The lightning offers others use to pay you.</string> - <string name="backup_type_other">list</string> - <string name="backup_type_other_explainer">A list saved on this device.</string> - <string name="backup_entry_profile_field">Profile fields</string> - <string name="backup_entry_person">People</string> - <string name="backup_entry_hashtag">Hashtags</string> - <string name="backup_entry_word">Words</string> - <string name="backup_entry_thread">Threads</string> - <string name="backup_entry_public_chat">Public chats</string> - <string name="backup_entry_community">Communities</string> - <string name="backup_entry_algo_feed">Feeds</string> - <string name="backup_entry_location">Locations</string> - <string name="backup_entry_relay">Relays</string> - <string name="backup_entry_relay_group">Groups</string> - <string name="backup_entry_chat_room">Chat rooms</string> - <string name="backup_entry_trust_provider">Trust providers</string> - <string name="backup_entry_mint">Mints</string> - <string name="backup_entry_nutzap_key">Nutzap keys</string> - <string name="backup_entry_payment_target">Payment addresses</string> - <string name="backup_entry_offer">Offers</string> - <string name="backup_entry_identity_claim">Linked identities</string> - <string name="backup_entry_other">Other entries</string> - <string name="backup_profile_field_name">Username</string> - <string name="backup_profile_field_display_name">Display name</string> - <string name="backup_profile_field_about">About</string> - <string name="backup_profile_field_picture">Picture</string> - <string name="backup_profile_field_banner">Banner</string> - <string name="backup_profile_field_website">Website</string> - <string name="backup_profile_field_nip05">Nostr address</string> - <string name="backup_profile_field_lud16">Lightning address</string> - <string name="backup_profile_field_lud06">LNURL</string> - <string name="backup_profile_field_pronouns">Pronouns</string> - <string name="backup_profile_field_birthday">Birthday</string> - <string name="backup_profile_field_clink_offer">CLINK offer</string> - <string name="backup_profile_field_bot">Bot flag</string> - <plurals name="cordn_info_admins_kept"> - <item quantity="one">The group\'s %1$d admin stays as it is.</item> - <item quantity="other">The group\'s %1$d admins stay as they are.</item> - </plurals> </resources> diff --git a/commonsUI/ARCHITECTURE.md b/commonsUI/ARCHITECTURE.md index b693e06a2b..8a091c05f7 100644 --- a/commonsUI/ARCHITECTURE.md +++ b/commonsUI/ARCHITECTURE.md @@ -58,7 +58,10 @@ is specific to the UI module. `tools/icon-font/build_icon_font.py` read `icons/` and write `composeResources/font/` in this module (see root `.claude/CLAUDE.md`, "Icons"). - Translations: `crowdin.yml` and `tools/strings-migrate/` target - `commonsUI/src/commonMain/composeResources/`. + `commonsUI/src/commonMain/composeResources/`. This is where **every new + user-visible string** goes, Android-only screens included; the app's own + `res/values/strings.xml` holds only the synchronous-platform tier (see root + `.claude/CLAUDE.md`, "Strings"). - CI: `.github/workflows/build.yml` runs `:commonsUI:jvmTest`, `:commonsUI:verifyKmpPurity` and the iOS compile/test tasks next to the `:commons` ones. diff --git a/commonsUI/src/commonMain/composeResources/values-hi-rIN/strings.xml b/commonsUI/src/commonMain/composeResources/values-hi-rIN/strings.xml index 3a1f286d3d..2d16c9c513 100644 --- a/commonsUI/src/commonMain/composeResources/values-hi-rIN/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-hi-rIN/strings.xml @@ -5286,4 +5286,322 @@ <string name="cyberspace_bag_unsigned">हस्ताक्षररहित। इसलिए यह कर्ता केवल एक कथन है</string> <string name="cyberspace_bag_unknown_kind">प्रकार %1$d का वस्तु। जिसे यह ग्राहक चित्रित नहीं करता</string> <string name="cyberspace_bag_opaque">%1$d अष्टकों का अमुक जिसे यह ग्राहक पढता नहीं</string> + <string name="backup_action_block_again">पुनःबाधित करें %1$s को</string> + <string name="backup_action_keep_count">रखें %1$s</string> + <string name="backup_action_rejoin">पुनःजुडें %1$s</string> + <string name="backup_action_remute">पुनःमौन %1$s</string> + <string name="backup_action_restore_count">पुनःस्थापन %1$s</string> + <string name="backup_action_restore_feeds">सूचनावलियाँ पुनःस्थापन</string> + <string name="backup_action_restore_places">स्थल पुनःस्थापन</string> + <string name="backup_action_restore_profile">परिचय पुनःस्थापध</string> + <string name="backup_action_restore_providers">प्रदाता पुनःस्थापन</string> + <string name="backup_action_restore_relays">पुनःप्रसारक पुनःस्थापन</string> + <string name="backup_action_restore_topics">विषय पुनःस्थापन</string> + <string name="backup_action_restore_wallet">धनकोष पुनःस्थापन</string> + <string name="backup_card_accept">स्वीकार</string> + <plurals name="backup_card_and_added"> + <item quantity="one">%1$s तथा %2$d जोडा</item> + <item quantity="other">%1$s तथा %2$d जोडे</item> + </plurals> + <plurals name="backup_card_and_blocked"> + <item quantity="one">%1$s तथा %2$d बाधित किया</item> + <item quantity="other">%1$s तथा %2$d को बाधित किए</item> + </plurals> + <plurals name="backup_card_and_followed"> + <item quantity="one">%1$s तथा %2$d का अनुगमन किया</item> + <item quantity="other">%1$s तथा %2$d का अनुगमन किए</item> + </plurals> + <plurals name="backup_card_and_joined"> + <item quantity="one">%1$s तथा %2$d से जुडे</item> + <item quantity="other">%1$s तथा %2$d से जुडे</item> + </plurals> + <plurals name="backup_card_and_muted"> + <item quantity="one">%1$s तथा %2$d को मौनकृत किया</item> + <item quantity="other">%1$s तथा %2$d को मौनकृत किए</item> + </plurals> + <string name="backup_card_app_settings">आपके अमेथिस्ट समचरणीकृत क्रमक स्थापना विकल्पों को परिवर्तित किया गया एक अन्य क्रमक में और वह अमेथिस्ट नहीं था। इससे समस्याएँ उत्पन्न हो सकते हैं।</string> + <string name="backup_card_concord_emptied">आपने अपनी कोंकोर्ड सूची रिक्त की</string> + <string name="backup_card_concord_rewritten">आपने अपनी कोंकोर्ड सूची का परिवर्तन किया</string> + <plurals name="backup_card_dm_relays_lost"> + <item quantity="one">आपने %1$d सीधासन्देश पुनःप्रसारक हटाया</item> + <item quantity="other">आपने %1$d सीधासन्देश पुनःप्रसारकों को हटाए</item> + </plurals> + <plurals name="backup_card_feeds_removed"> + <item quantity="one">आपने %1$d इष्ट सूचनावली हटाया</item> + <item quantity="other">आपने %1$d इष्ट सूचनावलियों को हटाए</item> + </plurals> + <plurals name="backup_card_follows_dropped"> + <item quantity="one">आपने %1$d व्यक्ति का अनुगमन हटाया</item> + <item quantity="other">आपने %1$d व्यक्तियों का अनुगमन हटाए</item> + </plurals> + <plurals name="backup_card_hashtags_unfollowed"> + <item quantity="one">आपने %1$d विषयसूचक का अनुगमन हटाया</item> + <item quantity="other">आपने %1$d विषयसूचकों का अनुगमन हटाए</item> + </plurals> + <plurals name="backup_card_indexer_relays_lost"> + <item quantity="one">आपने %1$d अनुक्रमणीकार पुनःप्रसारक हटाया</item> + <item quantity="other">आपने %1$d अनुक्रमणीकार पुनःप्रसारकों को हटाए</item> + </plurals> + <plurals name="backup_card_key_package_relays_lost"> + <item quantity="one">आपने %1$d समूहचर्चा कुंचिका पुनःप्रसारक हटाया</item> + <item quantity="other">आपने %1$d समूहचर्चा कुंचिका पुनःप्रसारकों को हटाए</item> + </plurals> + <plurals name="backup_card_leave_chats"> + <item quantity="one">आपने %1$d सार्वजनिक चर्चा छोड दिया</item> + <item quantity="other">आपने %1$d सार्वजनिक चर्चाओं को छोड दिए</item> + </plurals> + <plurals name="backup_card_leave_communities"> + <item quantity="one">आपने %1$d समुदाय छोड दिया</item> + <item quantity="other">आपने %1$d समुदायों को छोड दिए</item> + </plurals> + <plurals name="backup_card_leave_groups"> + <item quantity="one">आपने %1$d समूह छोड दिया</item> + <item quantity="other">आपने %1$d समूहों को छोड दिए</item> + </plurals> + <plurals name="backup_card_leave_rooms"> + <item quantity="one">आपने %1$d चर्चाशाला छोड दिया</item> + <item quantity="other">आपने %1$d चर्चाशालाओं को छोड दिए</item> + </plurals> + <plurals name="backup_card_mutes_visible"> + <item quantity="one">आपने %1$d की मौनावस्था हटाया</item> + <item quantity="other">आपने %1$d की मौनावस्था हटाए</item> + </plurals> + <plurals name="backup_card_nip65_lost"> + <item quantity="one">आपने %1$d सार्वजनिक पुनःप्रसारक हटाया</item> + <item quantity="other">आपने %1$d सार्वजनिक पुनःप्रसारकों को हटाए</item> + </plurals> + <string name="backup_card_nutzap_key_removed">आपने अपनी नटज्साप कुंचिका को हटाया</string> + <string name="backup_card_nutzap_key_replaced">आपने अपनी नटज्साप कुंचिका का प्रतिस्थापन किया</string> + <plurals name="backup_card_nutzap_mints_removed"> + <item quantity="one">आपने %1$d नटज्साप टकसाल हटाया</item> + <item quantity="other">आपने %1$d नटज्साप टकसालों को हटाए</item> + </plurals> + <plurals name="backup_card_offers_removed"> + <item quantity="one">आपने %1$d बोल्ट॰बारह प्रस्ताव हटाया</item> + <item quantity="other">आपने %1$d बोल्ट॰बारह प्रस्तावों को हटाए</item> + </plurals> + <plurals name="backup_card_payment_targets_removed"> + <item quantity="one">आपने %1$d भुगतान प्राप्त करने की विधि हटाया</item> + <item quantity="other">आपने %1$d भुगतान प्राप्त करने के विधियों को हटाए</item> + </plurals> + <plurals name="backup_card_places_unfollowed"> + <item quantity="one">आपने %1$d स्थल का अनुगमन हटाया</item> + <item quantity="other">आपने %1$d स्थलों का अनुगमन हटाए</item> + </plurals> + <plurals name="backup_card_private_outbox_lost"> + <item quantity="one">आपने %1$d निजी पुनःप्रसारक हटाया</item> + <item quantity="other">आपने %1$d निजी पुनःप्रसारकों को हटाए</item> + </plurals> + <plurals name="backup_card_profile_lost_fields"> + <item quantity="one">आपने %1$d परिचय प्रपत्रस्थान हटाया</item> + <item quantity="other">आपने %1$d परिचय प्रपत्रस्थान हटाए</item> + </plurals> + <plurals name="backup_card_relay_feeds_lost"> + <item quantity="one">आपने %1$d पुनःप्रसारक सूचनावली हटाया</item> + <item quantity="other">आपने %1$d पुनःप्रसारक सूचनावलियाँ हटाए</item> + </plurals> + <plurals name="backup_card_search_relays_lost"> + <item quantity="one">आपने %1$d खोज पुनःप्रसारक हटाया</item> + <item quantity="other">आपने %1$d खोज पुनःप्रसारकों को हटाए</item> + </plurals> + <plurals name="backup_card_trust_services_changed"> + <item quantity="one">आपने %1$d विश्वाससेवा प्रदाता परिवर्तित किया</item> + <item quantity="other">आपने %1$d विश्वाससेवाओं के प्रदाता को परिवर्तित किया</item> + </plurals> + <plurals name="backup_card_trusted_lost"> + <item quantity="one">आपने %1$d पुनःप्रसारक पर विश्वास करना रोक दिया</item> + <item quantity="other">आपने %1$d पुनःप्रसारकों पर विश्वास करना रोक दिए</item> + </plurals> + <plurals name="backup_card_unblocked"> + <item quantity="one">आपने %1$d पुनःप्रसारक अबाधित किया</item> + <item quantity="other">आपने %1$d पुनःप्रसारकों को अबाधित किए</item> + </plurals> + <string name="backup_card_wallet_emptied">आपने अपना काशयू धनकोष रिक्त किया</string> + <string name="backup_card_wallet_rewritten">आपने अपने काशयू धनकोष को परिवर्तित किया</string> + <string name="backup_conflict_added">जोडे गए (%1$s)</string> + <string name="backup_conflict_changed">परिवर्तित (%1$s)</string> + <string name="backup_conflict_count_added">%1$s जोडे गए</string> + <string name="backup_conflict_count_changed">%1$s परिवर्तित</string> + <string name="backup_conflict_count_removed">%1$s हटाए गए</string> + <string name="backup_conflict_keep_new">नया संस्करण का उपयोग करें</string> + <string name="backup_conflict_private_added">आपने निजी (रहस्यीकृत) वस्तुओं को जोडा।</string> + <string name="backup_conflict_private_changed">आपने निजी (रहस्यीकृत) वस्तुओं का परिवर्तन भी किया। उनकी तुलना यहाँ नहीं की जा सकती।</string> + <string name="backup_conflict_private_cleared">आपने सभी निजी (रहस्यीकृत) वस्तुओं को हटा दिया।</string> + <string name="backup_conflict_relay_read_only">पठनेवशक्य</string> + <string name="backup_conflict_relay_read_write">पठन तथा लेखन</string> + <string name="backup_conflict_relay_write_only">लेखनेवशक्य</string> + <string name="backup_conflict_removed">हटाए गए (%1$s)</string> + <string name="backup_conflict_restore_mine">सुरक्षित संस्करण पुनःस्थापन</string> + <string name="backup_conflict_title">आपने अपना %1$s अन्य क्रमक में परिवर्तित किया</string> + <string name="backup_entry_algo_feed">सूचनावलियाँ</string> + <string name="backup_entry_chat_room">चर्चा शालाएँ</string> + <string name="backup_entry_community">समुदाय</string> + <string name="backup_entry_hashtag">विषयसूचक</string> + <string name="backup_entry_identity_claim">सम्बन्धित व्यक्तिगण</string> + <string name="backup_entry_location">स्थान</string> + <string name="backup_entry_mint">टकसाल</string> + <string name="backup_entry_nutzap_key">नटज्साप कुंचिकाएँ</string> + <string name="backup_entry_offer">प्रस्ताव</string> + <string name="backup_entry_other">अन्य प्रविष्टियाँ</string> + <string name="backup_entry_payment_target">भुगतान पते</string> + <string name="backup_entry_person">लोग</string> + <string name="backup_entry_profile_field">परिचय प्रपत्रस्थान</string> + <string name="backup_entry_public_chat">सार्वजनिक चर्चाएँ</string> + <string name="backup_entry_relay">पुनःप्रसारक</string> + <string name="backup_entry_relay_group">समूह</string> + <string name="backup_entry_thread">सूत्र</string> + <string name="backup_entry_trust_provider">विश्वाससेवा प्रदाता</string> + <string name="backup_entry_word">शब्द</string> + <string name="backup_profile_field_about">जानकारी</string> + <string name="backup_profile_field_banner">ध्वज</string> + <string name="backup_profile_field_birthday">जन्मदिन</string> + <string name="backup_profile_field_bot">क्रमचर चिह्न</string> + <string name="backup_profile_field_clink_offer">क्लिंक॰ प्रस्ताव</string> + <string name="backup_profile_field_display_name">प्रदर्शन नाम</string> + <string name="backup_profile_field_lud06">लैटनिंग जालपता</string> + <string name="backup_profile_field_lud16">लैटनिंग पता</string> + <string name="backup_profile_field_name">प्रयोक्तानाम</string> + <string name="backup_profile_field_nip05">नोस्टर पता</string> + <string name="backup_profile_field_picture">चित्र</string> + <string name="backup_profile_field_pronouns">सर्वनाम</string> + <string name="backup_profile_field_website">जालस्थान</string> + <string name="backup_review_allowed_tag">अनुमत</string> + <string name="backup_review_changed_by_other_app">अन्य क्रमक में परिवर्तित। %1$s</string> + <string name="backup_review_concord_body">आपके कोंकोर्ड समुदाय आपके लिए रहस्यीकृत हैं। इसलिए उनकी तुलना यहाँ नहीं की जा सकती। पुनःस्थापन उन्हीं को लौट डालेगा जिनका अभिलेखन इस यन्त्र ने किया।</string> + <string name="backup_review_demoted_tag">स्थानान्तरित</string> + <string name="backup_review_dropped_count">%1$s परित्यक्त</string> + <string name="backup_review_dropped_tag">परित्यक्त</string> + <string name="backup_review_encrypted_added">आपने उसे बनाया</string> + <string name="backup_review_encrypted_changed">आपने उसे परिवर्तित किया</string> + <string name="backup_review_encrypted_cleared">आपने उसे रिक्त किया</string> + <string name="backup_review_feed">सूचनावली</string> + <string name="backup_review_gone">चला गया</string> + <string name="backup_review_hashtags_you_follow">विषयसूचक जिनका आप अनुगमन करते हैं</string> + <string name="backup_review_inbox">आगतपेटिका</string> + <string name="backup_review_inbox_explainer">जहाँ अन्य आप तक अभिगमन करते हैं</string> + <string name="backup_review_joined">जुड गए</string> + <string name="backup_review_kept">रखे गए</string> + <string name="backup_review_kept_count">%1$s रखे गए</string> + <string name="backup_review_key_removed">ग्राहक कुंचिका हटाया गया</string> + <string name="backup_review_key_replaced">ग्राहक कुंचिका प्रतिस्थापित</string> + <string name="backup_review_key_warning">नटज्साप आगे से इस कुंचिका से बद्ध होंगे। यदि इस यन्त्र में वह नहीं है तो आप सम्भाव्यतः उनको यहाँ नहीं चुका सकते।</string> + <string name="backup_review_left">शेष</string> + <string name="backup_review_left_tag">शेष</string> + <string name="backup_review_listed_tag">सूचीबद्ध</string> + <string name="backup_review_moved_tag">स्थानान्तरित</string> + <string name="backup_review_names_and_more">%1$s तथा %2$s अधिक</string> + <string name="backup_review_new">नया संस्करण</string> + <string name="backup_review_new_count">%1$s नए</string> + <string name="backup_review_new_count_caption">नव्य</string> + <string name="backup_review_new_tag">नव्य</string> + <string name="backup_review_newly_blocked">नव्यतः बाधित</string> + <string name="backup_review_newly_muted">%1$s नए मौनकृत</string> + <string name="backup_review_no_longer_blocked">अब से बाधित नहीं</string> + <string name="backup_review_not_set">स्थापित नहीं</string> + <string name="backup_review_nothing_here">यहाँ कुछ नहीं</string> + <string name="backup_review_offer">बोल्ट॰बारह प्रस्ताव</string> + <plurals name="backup_review_offers_gone"> + <item quantity="one">%1$d प्रस्ताव हटाया गया</item> + <item quantity="other">%1$d प्रस्ताव हटाए गए</item> + </plurals> + <string name="backup_review_outbox">निर्गतपेटिका</string> + <string name="backup_review_outbox_explainer">आप जहाँ प्रकाशित करते हैं</string> + <plurals name="backup_review_payment_targets_gone"> + <item quantity="one">आपको भुगतान करने की %1$d विधि हटाया गया</item> + <item quantity="other">आपको भुगतान करने की %1$d विधियाँ हटाए गए</item> + </plurals> + <string name="backup_review_payments_explainer">लोग जो आपको भुगतान करते हैं उसी को देखते हैं जो इस सूची में है।</string> + <string name="backup_review_places_gone">स्थल हटाए गए</string> + <string name="backup_review_places_you_follow">स्थल जिनका आप अनुगमन करते हैं</string> + <string name="backup_review_private_added_body">वे अब रहस्यीकृत हैं। जो कि सम्भाव्यतः ऊपर के सार्वजनिक वस्तुओं का गन्तव्य था।</string> + <string name="backup_review_private_added_title">आपने वस्तुओं को निजी किया</string> + <string name="backup_review_private_changed_body">वे रहस्यीकृत हैं। इसलिए उनकी तुलना यहाँ नहीं की जा सकती।</string> + <string name="backup_review_private_changed_title">आपने निजी वस्तुओं का परिवर्तन किया</string> + <string name="backup_review_private_wiped_body">वे रहस्यीकृत हैं। इसलिए केवल आप देख सकते हैं कि वे क्या थे। पुनःस्थापन से सब लौटके आ जाएँगे।</string> + <string name="backup_review_private_wiped_title">आपने सभी निजी वस्तुओं को हटा दिया</string> + <string name="backup_review_receiving_tag">ग्रहण मध्य</string> + <string name="backup_review_renamed">पुनःनामकृत</string> + <string name="backup_review_renamed_tag">पुनःनामकृत</string> + <string name="backup_review_saved">इस यन्त्र पर</string> + <string name="backup_review_search_people">%1$s लोग खोजें</string> + <string name="backup_review_searching_tag">खोज मध्य</string> + <string name="backup_review_service">सेवा</string> + <string name="backup_review_service_followers">अनुचर</string> + <string name="backup_review_service_rank">श्रेणी</string> + <string name="backup_review_service_topics">विषयसूची</string> + <string name="backup_review_services_changed">%2$s में से %1$s सेवाएँ परिवर्तित</string> + <string name="backup_review_services_changed_explainer">वे अब अन्य व्यक्ति से आते है। अथवा किसी से नहीं</string> + <string name="backup_review_still_blocked">अभी भी बाधित</string> + <string name="backup_review_storing_tag">अभिलेखन मध्य</string> + <string name="backup_review_tab_dropped">परित्यक्त। %1$s</string> + <string name="backup_review_tab_edited">सम्पादित। %1$s</string> + <string name="backup_review_tab_new">नए। %1$s</string> + <string name="backup_review_title_cleared">%1$s पारित</string> + <string name="backup_review_title_updated">%1$s अद्यतनकृत</string> + <string name="backup_review_topics_gone">विषय हटाए गए</string> + <string name="backup_review_trust_warning">श्रेणीयाँ तथा अनुचर गिनती तथा कचरालेख छालन जो आप देखते हैं इन प्रदाताओं से आते हैं।</string> + <string name="backup_review_trusted_tag">विश्वसनीय</string> + <string name="backup_review_unblocked">%1$s अबाधित</string> + <string name="backup_review_unblocked_explainer">अमेथिस्ट पुनः उनसे जुडेगा</string> + <string name="backup_review_units_tag">इकाई</string> + <string name="backup_review_unmuted">%1$s मौनावस्था हटाए गए</string> + <string name="backup_review_unmuted_explainer">ये पुनः आपके सूचनावलियों तक अभिगम्य</string> + <string name="backup_review_untrusted_tag">अविश्वसनीय</string> + <string name="backup_review_wallet_body">आपके धनकोष के टकसाल तथा कुंचिकाएँ आपके लिए रहस्यीकृत हैं। इसलिए उनकी तुलना वस्तु प्रति वस्तु यहाँ नहीं की जा सकती। पुनःस्थापन उन्हीं को लौट डालेगा जिनका अभिलेखन इस यन्त्र ने किया।</string> + <string name="backup_review_was_named">पूर्वतः %1$s था</string> + <string name="backup_review_words_and_hashtags">शब्द तथा विषयसूचक</string> + <string name="backup_type_app_settings">समचरणीकृत क्रमक स्थापना विकल्प</string> + <string name="backup_type_app_settings_explainer">अमेथिस्ट स्थापना विकल्प समचरणीकृत आपके यन्त्रों में। वे रहस्यीकृत हैं। इसलिए केवल उनकी उपस्थिति की तुलना की जा सकती है।</string> + <string name="backup_type_blocked_relays">बाधित पुनःप्रसारक सूची</string> + <string name="backup_type_blocked_relays_explainer">पुनःप्रसारक जिनसे यह क्रमक कभी भी संयोजन नहीं करता।</string> + <string name="backup_type_bolt12_offers">बोल्ट॰बारह प्रस्ताव</string> + <string name="backup_type_bolt12_offers_explainer">लैटनिंग प्रस्ताव जिनका उपयोग करके अन्य लोग आपको भुगतान करते हैं।</string> + <string name="backup_type_cashu_wallet">काशयू धनकोष</string> + <string name="backup_type_cashu_wallet_explainer">आपके ईकाश धनकोष स्थापना विकल्प तथा टकसाल। वे रहस्यीकृत हैं। इसलिए केवल उनकी उपस्थिति की तुलना की जा सकती है।</string> + <string name="backup_type_communities">समुदाय सूची</string> + <string name="backup_type_communities_explainer">समुदाय जिनसे आप जुडे।</string> + <string name="backup_type_concord_communities">कोंकोर्ड समुदाय सूची</string> + <string name="backup_type_concord_communities_explainer">कोंकोर्ड समुदाय जिनसे आप जुडे। वे रहस्यीकृत हैं। इसलिए केवल उनकी उपस्थिति की तुलना की जा सकती है।</string> + <string name="backup_type_dm_relays">सीधासन्देश पुनःप्रसारक सूची</string> + <string name="backup_type_dm_relays_explainer">जहाँ अन्य आपके निजी सन्देश भेजते हैं।</string> + <string name="backup_type_ephemeral_chats">अस्थायी चर्चा सूची</string> + <string name="backup_type_ephemeral_chats_explainer">अस्थायी चर्चा शालाएँ जिनसे आप जुडे।</string> + <string name="backup_type_favorite_algo_feeds">इष्ट सूचनावली सूची</string> + <string name="backup_type_favorite_algo_feeds_explainer">कलनविध्यात्मक सूचनावलियाँ जिनको आपने अभिलेखित किया।</string> + <string name="backup_type_follow_list">अनुचरण सूची</string> + <string name="backup_type_follow_list_explainer">निर्णय करता है किसके पत्र आपकी मुख्य सूचनावली में प्रकट होंगे।</string> + <string name="backup_type_geohashes">अनुचरित स्थान</string> + <string name="backup_type_geohashes_explainer">स्थान जहाँ से पत्र आपकी मुख्य सूचनावली में प्रकट होंगे।</string> + <string name="backup_type_hashtags">अनुचरित विषयसूचक</string> + <string name="backup_type_hashtags_explainer">विषयसूचक जिससे युक्त पत्र आपकी मुख्य सूचनावली में प्रकट होंगे।</string> + <string name="backup_type_indexer_relays">अनुक्रमणीकार पुनःप्रसारक सूची</string> + <string name="backup_type_indexer_relays_explainer">पुनःप्रसारक उपयुक्त अन्य लोगों के परिचय तथा पुनःप्रसारक सूचियों को प्राप्त करने के लिए।</string> + <string name="backup_type_key_package_relays">समूहचर्चा कुंचिका पुनःप्रसारक</string> + <string name="backup_type_key_package_relays_explainer">जहाँ अन्य उन कुंचिकाओं को प्राप्त करते हैं आपको रहस्यीकृत समूहचर्चाओं में जोडने के लिए।</string> + <string name="backup_type_mute_list">मौन सूची</string> + <string name="backup_type_mute_list_explainer">छिपाता है लोग शब्द विषयसूचक तथा सूत्र उन सब में से जो आप देखते हैं।</string> + <string name="backup_type_nutzap_info">नटज्साप प्राप्ती स्थापना विकल्प</string> + <string name="backup_type_nutzap_info_explainer">टकसाल तथा कुंचिका जिनका उपयोग अन्य करते हैं आपको नटज्साप भेजने के लिए।</string> + <string name="backup_type_other">सूची</string> + <string name="backup_type_other_explainer">इस यन्त्र पर अभिलेखित सूची।</string> + <string name="backup_type_outbox_inbox_relays">सार्वजनिक पुनःप्रसारक सूची</string> + <string name="backup_type_outbox_inbox_relays_explainer">सबको बताता है आपके पत्र कहाँ प्राप्त करें तथा आपको उत्तर तथा उल्लेखों को कहाँ भेजें।</string> + <string name="backup_type_payment_targets">भुगतान पते</string> + <string name="backup_type_payment_targets_explainer">विधियाँ जिनसे अन्य लोग आपको भुगतान कर सके।</string> + <string name="backup_type_private_outbox_relays">निजी पुनःप्रसारक सूची</string> + <string name="backup_type_private_outbox_relays_explainer">जहाँ आपके पाण्डुलिपियाँ तथा निजी जानकारी रखे जाते हैं।</string> + <string name="backup_type_profile">परिचय</string> + <string name="backup_type_profile_explainer">आपका नाम चित्र चरित तथा भुगतान पते। जैसे सब को दिखते हैं वैसे।</string> + <string name="backup_type_public_chats">सार्वजनिक चर्चा सूची</string> + <string name="backup_type_public_chats_explainer">सार्वजनिक चर्चाएँ जिनसे आप जुडे।</string> + <string name="backup_type_relay_feeds">इष्ट पुनःप्रसारक सूचनावलियाँ</string> + <string name="backup_type_relay_feeds_explainer">पुनःप्रसारक जिनका आप वीक्षण करते हैं सूचनावलियों के रूप में।</string> + <string name="backup_type_relay_groups">समूह सूची</string> + <string name="backup_type_relay_groups_explainer">पुनःप्रसारक आधारित समूह जिनसे आप जुडे।</string> + <string name="backup_type_search_relays">खोज पुनःप्रसारक सूची</string> + <string name="backup_type_search_relays_explainer">उपयुक्त पुनःप्रसारक जब आप खोजते हैं।</string> + <string name="backup_type_trust_providers">विश्वाससेवा प्रदाता सूची</string> + <string name="backup_type_trust_providers_explainer">सेवाएँ जिनपर आप विश्वास करते है आपके लिए लोगों का सत्यापन तथा श्रेणीकरण करने के लिए।</string> + <string name="backup_type_trusted_relays">विश्वसनीय पुनःप्रसारक सूची</string> + <string name="backup_type_trusted_relays_explainer">पुनःप्रसारक जिनपर आप पर्याप्त विश्वास करते हैं बिना पूछे उनसे संयोजन करने के लिए।</string> </resources> diff --git a/commonsUI/src/commonMain/composeResources/values-hu-rHU/strings.xml b/commonsUI/src/commonMain/composeResources/values-hu-rHU/strings.xml index 950cdfa8b9..4931f70f61 100644 --- a/commonsUI/src/commonMain/composeResources/values-hu-rHU/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-hu-rHU/strings.xml @@ -5287,4 +5287,322 @@ <string name="cyberspace_bag_unsigned">Aláírás nélküli, így ez a szerző csak állítás</string> <string name="cyberspace_bag_unknown_kind">Egy %1$d típusú elem, amelyet ez a kliens nem jelenít meg</string> <string name="cyberspace_bag_opaque">%1$d bájt valamiből, amit ez a kliens nem olvas</string> + <string name="backup_action_block_again">%1$s újbóli letiltása</string> + <string name="backup_action_keep_count">%1$s megtartása</string> + <string name="backup_action_rejoin">Újbóli csatlakozás ehhez: %1$s</string> + <string name="backup_action_remute">%1$s újbóli némítása</string> + <string name="backup_action_restore_count">%1$s visszaállítása</string> + <string name="backup_action_restore_feeds">Hírfolyamok visszaállítása</string> + <string name="backup_action_restore_places">Helyek visszaállítása</string> + <string name="backup_action_restore_profile">Profil visszaállítása</string> + <string name="backup_action_restore_providers">Szolgáltatók visszaállítása</string> + <string name="backup_action_restore_relays">Átjátszók visszaállítása</string> + <string name="backup_action_restore_topics">Témák visszaállítása</string> + <string name="backup_action_restore_wallet">Pénztárca visszaállítása</string> + <string name="backup_card_accept">Elfogadás</string> + <plurals name="backup_card_and_added"> + <item quantity="one">%1$s és %2$d hozzáadva</item> + <item quantity="other">%1$s és %2$d hozzáadva</item> + </plurals> + <plurals name="backup_card_and_blocked"> + <item quantity="one">%1$s és %2$d letiltva</item> + <item quantity="other">%1$s és %2$d letiltva</item> + </plurals> + <plurals name="backup_card_and_followed"> + <item quantity="one">%1$s és %2$d követve</item> + <item quantity="other">%1$s és %2$d követve</item> + </plurals> + <plurals name="backup_card_and_joined"> + <item quantity="one">%1$s és %2$d csatlakozva</item> + <item quantity="other">%1$s és %2$d csatlakozva</item> + </plurals> + <plurals name="backup_card_and_muted"> + <item quantity="one">%1$s és %2$d némítva</item> + <item quantity="other">%1$s és %2$d némítva</item> + </plurals> + <string name="backup_card_app_settings">A szinkronizált Amethyst alkalmazásbeállításai egy másik, nem Amethyst alkalmazásban módosultak. Ez problémákat okozhat.</string> + <string name="backup_card_concord_emptied">Ön kiürítette a Concord listáját</string> + <string name="backup_card_concord_rewritten">Ön módosította a Concord listáját</string> + <plurals name="backup_card_dm_relays_lost"> + <item quantity="one">Ön %1$d privát üzenetátjátszót távolított el</item> + <item quantity="other">%1$d privát üzenet átjátszót távolított el</item> + </plurals> + <plurals name="backup_card_feeds_removed"> + <item quantity="one">Ön %1$d kedvenc hírfolyamot távolított el</item> + <item quantity="other">%1$d kedvenc hírfolyamot távolított el</item> + </plurals> + <plurals name="backup_card_follows_dropped"> + <item quantity="one">%1$d személyt nem követ többé</item> + <item quantity="other">%1$d személyt nem követ többé</item> + </plurals> + <plurals name="backup_card_hashtags_unfollowed"> + <item quantity="one">Ön %1$d kulcsszót nem követ többé</item> + <item quantity="other">%1$d hashtaget nem követ többé</item> + </plurals> + <plurals name="backup_card_indexer_relays_lost"> + <item quantity="one">Ön %1$d indexelő átjátszót távolított el</item> + <item quantity="other">%1$d indexelő átjátszót távolított el</item> + </plurals> + <plurals name="backup_card_key_package_relays_lost"> + <item quantity="one">Ön %1$d csoportos csevegési kulcsátjátszót távolított el</item> + <item quantity="other">%1$d csoportcsevegés kulcs átjátszót távolított el</item> + </plurals> + <plurals name="backup_card_leave_chats"> + <item quantity="one">Ön %1$d nyilvános csevegést hagyott el</item> + <item quantity="other">%1$d nyilvános csevegést hagyott el</item> + </plurals> + <plurals name="backup_card_leave_communities"> + <item quantity="one">Ön %1$d közösséget hagyott el</item> + <item quantity="other">%1$d közösséget hagyott el</item> + </plurals> + <plurals name="backup_card_leave_groups"> + <item quantity="one">Ön %1$d csoportot hagyott el</item> + <item quantity="other">%1$d csoportot hagyott el</item> + </plurals> + <plurals name="backup_card_leave_rooms"> + <item quantity="one">Ön %1$d csevegőszobát hagyott el</item> + <item quantity="other">%1$d csevegőszobát hagyott el</item> + </plurals> + <plurals name="backup_card_mutes_visible"> + <item quantity="one">Ön %1$d dolog némítását oldotta fel</item> + <item quantity="other">%1$d dolog némítását oldotta fel</item> + </plurals> + <plurals name="backup_card_nip65_lost"> + <item quantity="one">Ön %1$d nyilvános átjátszót távolított el</item> + <item quantity="other">%1$d nyilvános átjátszót távolított el</item> + </plurals> + <string name="backup_card_nutzap_key_removed">Ön eltávolította a nutzap kulcsát</string> + <string name="backup_card_nutzap_key_replaced">Ön lecserélte a nutzap kulcsát</string> + <plurals name="backup_card_nutzap_mints_removed"> + <item quantity="one">Ön %1$d nutzap pénzverdét távolított el</item> + <item quantity="other">%1$d nutzap pénzverdét távolított el</item> + </plurals> + <plurals name="backup_card_offers_removed"> + <item quantity="one">Ön %1$d BOLT12 ajánlatot távolított el</item> + <item quantity="other">%1$d BOLT12 ajánlatot távolított el</item> + </plurals> + <plurals name="backup_card_payment_targets_removed"> + <item quantity="one">Ön %1$d fizetési módot távolított el</item> + <item quantity="other">%1$d fizetési módot távolított el</item> + </plurals> + <plurals name="backup_card_places_unfollowed"> + <item quantity="one">Ön %1$d helyet nem követ többé</item> + <item quantity="other">%1$d helyet nem követ többé</item> + </plurals> + <plurals name="backup_card_private_outbox_lost"> + <item quantity="one">Ön %1$d privát átjátszót távolított el</item> + <item quantity="other">%1$d privát átjátszót távolított el</item> + </plurals> + <plurals name="backup_card_profile_lost_fields"> + <item quantity="one">Ön %1$d profilmezőt távolított el</item> + <item quantity="other">%1$d profilmezőt távolított el</item> + </plurals> + <plurals name="backup_card_relay_feeds_lost"> + <item quantity="one">Ön %1$d átjátszó-hírfolyamot távolított el</item> + <item quantity="other">%1$d átjátszó hírfolyamot távolított el</item> + </plurals> + <plurals name="backup_card_search_relays_lost"> + <item quantity="one">Ön %1$d kereső átjátszót távolított el</item> + <item quantity="other">%1$d kereső átjátszót távolított el</item> + </plurals> + <plurals name="backup_card_trust_services_changed"> + <item quantity="one">Ön %1$d bizalmi szolgáltatás szolgáltatóját módosította</item> + <item quantity="other">%1$d bizalmi szolgáltatás szolgáltatóját változtatta meg</item> + </plurals> + <plurals name="backup_card_trusted_lost"> + <item quantity="one">Ön %1$d átjátszóban nem bízik többé</item> + <item quantity="other">%1$d átjátszóban nem bízik többé</item> + </plurals> + <plurals name="backup_card_unblocked"> + <item quantity="one">Ön %1$d átjátszó letiltását oldotta fel</item> + <item quantity="other">%1$d átjátszó blokkolását oldotta fel</item> + </plurals> + <string name="backup_card_wallet_emptied">Ön kiürítette a Cashu pénztárcáját</string> + <string name="backup_card_wallet_rewritten">Ön módosította a Cashu pénztárcáját</string> + <string name="backup_conflict_added">Hozzáadva (%1$s)</string> + <string name="backup_conflict_changed">Módosítva (%1$s)</string> + <string name="backup_conflict_count_added">%1$s hozzáadva</string> + <string name="backup_conflict_count_changed">%1$s módosítva</string> + <string name="backup_conflict_count_removed">%1$s eltávolítva</string> + <string name="backup_conflict_keep_new">Új verzió használata</string> + <string name="backup_conflict_private_added">Ön privát (titkosított) elemeket adott hozzá.</string> + <string name="backup_conflict_private_changed">Ön privát (titkosított) elemeket is módosított. Ezek itt nem hasonlíthatók össze.</string> + <string name="backup_conflict_private_cleared">Ön eltávolította az összes privát (titkosított) elemet.</string> + <string name="backup_conflict_relay_read_only">csak olvasható</string> + <string name="backup_conflict_relay_read_write">olvasás és írás</string> + <string name="backup_conflict_relay_write_only">csak írható</string> + <string name="backup_conflict_removed">Eltávolítva (%1$s)</string> + <string name="backup_conflict_restore_mine">Mentett verzió visszaállítása</string> + <string name="backup_conflict_title">Ön módosította a következőt: %1$s, egy másik alkalmazásban</string> + <string name="backup_entry_algo_feed">Hírfolyamok</string> + <string name="backup_entry_chat_room">Csevegőszobák</string> + <string name="backup_entry_community">Közösségek</string> + <string name="backup_entry_hashtag">Kulcsszavak</string> + <string name="backup_entry_identity_claim">Kapcsolt identitások</string> + <string name="backup_entry_location">Helyek</string> + <string name="backup_entry_mint">Pénzverdék</string> + <string name="backup_entry_nutzap_key">Nutzap kulcsok</string> + <string name="backup_entry_offer">Ajánlatok</string> + <string name="backup_entry_other">Egyéb bejegyzések</string> + <string name="backup_entry_payment_target">Fizetési címek</string> + <string name="backup_entry_person">Személyek</string> + <string name="backup_entry_profile_field">Profilmezők</string> + <string name="backup_entry_public_chat">Nyilvános csevegések</string> + <string name="backup_entry_relay">Átjátszók</string> + <string name="backup_entry_relay_group">Csoportok</string> + <string name="backup_entry_thread">Beszélgetések</string> + <string name="backup_entry_trust_provider">Bizalmi szolgáltatók</string> + <string name="backup_entry_word">Szavak</string> + <string name="backup_profile_field_about">Névjegy</string> + <string name="backup_profile_field_banner">Borító</string> + <string name="backup_profile_field_birthday">Születésnap</string> + <string name="backup_profile_field_bot">Bot jelölő</string> + <string name="backup_profile_field_clink_offer">CLINK-ajánlat</string> + <string name="backup_profile_field_display_name">Megjelenített név</string> + <string name="backup_profile_field_lud06">LNURL</string> + <string name="backup_profile_field_lud16">Lightning-cím</string> + <string name="backup_profile_field_name">Felhasználónév</string> + <string name="backup_profile_field_nip05">Nostr-cím</string> + <string name="backup_profile_field_picture">Kép</string> + <string name="backup_profile_field_pronouns">Névmások</string> + <string name="backup_profile_field_website">Weboldal</string> + <string name="backup_review_allowed_tag">Engedélyezve</string> + <string name="backup_review_changed_by_other_app">Módosítva egy másik alkalmazásban · %1$s</string> + <string name="backup_review_concord_body">A Concord közösségei Önhöz vannak titkosítva, így itt nem hasonlíthatók össze. A visszaállítás pontosan azt hozza vissza, amit ez az eszköz mentett.</string> + <string name="backup_review_demoted_tag">Áthelyezve</string> + <string name="backup_review_dropped_count">%1$s elvetve</string> + <string name="backup_review_dropped_tag">Elvetve</string> + <string name="backup_review_encrypted_added">Ön hozta létre</string> + <string name="backup_review_encrypted_changed">Ön módosította</string> + <string name="backup_review_encrypted_cleared">Ön kiürítette</string> + <string name="backup_review_feed">Hírfolyam</string> + <string name="backup_review_gone">Eltűnt</string> + <string name="backup_review_hashtags_you_follow">Ön által követett kulcsszavak</string> + <string name="backup_review_inbox">Bejövő</string> + <string name="backup_review_inbox_explainer">ahol mások elérik Önt</string> + <string name="backup_review_joined">csatlakozott</string> + <string name="backup_review_kept">megtartva</string> + <string name="backup_review_kept_count">%1$s megtartva</string> + <string name="backup_review_key_removed">Fogadási kulcs eltávolítva</string> + <string name="backup_review_key_replaced">Fogadási kulcs lecserélve</string> + <string name="backup_review_key_warning">A nutzapok ehhez az új kulcshoz lesznek kötve. Ha ez az eszköz nem rendelkezik vele, lehet, hogy itt nem tudja beváltani őket.</string> + <string name="backup_review_left">elhagyta a csoportot</string> + <string name="backup_review_left_tag">Elhagyva</string> + <string name="backup_review_listed_tag">Listázva</string> + <string name="backup_review_moved_tag">Áthelyezve</string> + <string name="backup_review_names_and_more">%1$s és %2$s további</string> + <string name="backup_review_new">Új verzió</string> + <string name="backup_review_new_count">%1$s új</string> + <string name="backup_review_new_count_caption">új</string> + <string name="backup_review_new_tag">Új</string> + <string name="backup_review_newly_blocked">Újonnan letiltva</string> + <string name="backup_review_newly_muted">%1$s újonnan némítva</string> + <string name="backup_review_no_longer_blocked">Már nincs letiltva</string> + <string name="backup_review_not_set">Nincs beállítva</string> + <string name="backup_review_nothing_here">Itt nincs semmi</string> + <string name="backup_review_offer">BOLT12 ajánlat</string> + <plurals name="backup_review_offers_gone"> + <item quantity="one">%1$d ajánlat eltávolítva</item> + <item quantity="other">%1$d ajánlat eltávolítva</item> + </plurals> + <string name="backup_review_outbox">Kimenő</string> + <string name="backup_review_outbox_explainer">ahol közzétesz</string> + <plurals name="backup_review_payment_targets_gone"> + <item quantity="one">%1$d fizetési mód eltávolítva</item> + <item quantity="other">%1$d fizetési mód eltávolítva</item> + </plurals> + <string name="backup_review_payments_explainer">A fizető emberek csak azt látják, ami ebben a listában van.</string> + <string name="backup_review_places_gone">eltűnt helyek</string> + <string name="backup_review_places_you_follow">Ön által követett helyek</string> + <string name="backup_review_private_added_body">Most már titkosítva vannak, valószínűleg ide kerültek a fenti nyilvános elemek.</string> + <string name="backup_review_private_added_title">Ön priváttá tette az elemeket</string> + <string name="backup_review_private_changed_body">Titkosítva vannak, így itt nem hasonlíthatók össze.</string> + <string name="backup_review_private_changed_title">Ön módosította a privát elemeket</string> + <string name="backup_review_private_wiped_body">Titkosítva vannak, így csak Ön láthatja, mik voltak. A visszaállítás mindet visszahozza.</string> + <string name="backup_review_private_wiped_title">Ön eltávolította az összes privát elemet</string> + <string name="backup_review_receiving_tag">Fogadás</string> + <string name="backup_review_renamed">átnevezte</string> + <string name="backup_review_renamed_tag">Átnevezve</string> + <string name="backup_review_saved">Ezen az eszközön</string> + <string name="backup_review_search_people">%1$s személy keresése</string> + <string name="backup_review_searching_tag">Keresés</string> + <string name="backup_review_service">Szolgáltatás</string> + <string name="backup_review_service_followers">Követők</string> + <string name="backup_review_service_rank">Rang</string> + <string name="backup_review_service_topics">Témák</string> + <string name="backup_review_services_changed">%2$s szolgáltatásból %1$s módosult</string> + <string name="backup_review_services_changed_explainer">Most már valaki mástól vagy senkitől sem származnak</string> + <string name="backup_review_still_blocked">Továbbra is le van tiltva</string> + <string name="backup_review_storing_tag">Tárolás</string> + <string name="backup_review_tab_dropped">Elvetve · %1$s</string> + <string name="backup_review_tab_edited">Szerkesztve · %1$s</string> + <string name="backup_review_tab_new">Új · %1$s</string> + <string name="backup_review_title_cleared">%1$s törölve</string> + <string name="backup_review_title_updated">%1$s frissítve</string> + <string name="backup_review_topics_gone">eltűnt témák</string> + <string name="backup_review_trust_warning">A látott rangok, követőszámok és a kéretlen tartalomszűrés ezektől a szolgáltatóktól származnak.</string> + <string name="backup_review_trusted_tag">Megbízható</string> + <string name="backup_review_unblocked">%1$s letiltása feloldva</string> + <string name="backup_review_unblocked_explainer">Az Amethyst újra kapcsolódni fog hozzájuk</string> + <string name="backup_review_units_tag">Egységek</string> + <string name="backup_review_unmuted">%1$s némítása feloldva</string> + <string name="backup_review_unmuted_explainer">Ezek újra megjelenhetnek a hírfolyamaiban</string> + <string name="backup_review_untrusted_tag">Nem megbízható</string> + <string name="backup_review_wallet_body">A saját pénztárca pénzverdéi és kulcsai Önhöz vannak titkosítva, így itt nem hasonlíthatók össze tételesen. A visszaállítás pontosan azt hozza vissza, amit ez az eszköz mentett.</string> + <string name="backup_review_was_named">„%1$s” volt</string> + <string name="backup_review_words_and_hashtags">Szavak és kulcsszavak</string> + <string name="backup_type_app_settings">szinkronizált alkalmazásbeállítások</string> + <string name="backup_type_app_settings_explainer">Az eszközök között szinkronizált Amethyst-beállítások. Titkosítva vannak, így csak a jelenlétük hasonlítható össze.</string> + <string name="backup_type_blocked_relays">letiltott átjátszólista</string> + <string name="backup_type_blocked_relays_explainer">Átjátszók, amelyekhez ez az alkalmazás soha nem kapcsolódik.</string> + <string name="backup_type_bolt12_offers">BOLT12 ajánlatok</string> + <string name="backup_type_bolt12_offers_explainer">A lightning ajánlatok, amelyeket mások a fizetéshez használnak az Ön számára.</string> + <string name="backup_type_cashu_wallet">Cashu pénztárca</string> + <string name="backup_type_cashu_wallet_explainer">Az ecash pénztárca beállításai és pénzverdéi. Titkosítva vannak, így csak a jelenlétük hasonlítható össze.</string> + <string name="backup_type_communities">közösséglista</string> + <string name="backup_type_communities_explainer">Közösségek, amelyekhez csatlakozott.</string> + <string name="backup_type_concord_communities">Concord közösséglista</string> + <string name="backup_type_concord_communities_explainer">A Concord közösségek, amelyekhez csatlakozott. Titkosítva vannak, így csak a jelenlétük hasonlítható össze.</string> + <string name="backup_type_dm_relays">privát üzenet átjátszólista</string> + <string name="backup_type_dm_relays_explainer">Ahová mások kézbesítik a privát üzeneteit.</string> + <string name="backup_type_ephemeral_chats">rövid életű csevegési lista</string> + <string name="backup_type_ephemeral_chats_explainer">Rövid életű csevegőszobák, amelyekhez csatlakozott.</string> + <string name="backup_type_favorite_algo_feeds">kedvenc hírfolyamlista</string> + <string name="backup_type_favorite_algo_feeds_explainer">Mentett algoritmus-hírfolyamok.</string> + <string name="backup_type_follow_list">követési lista</string> + <string name="backup_type_follow_list_explainer">Meghatározza, kinek a bejegyzései jelenjenek meg a kezdőlapon.</string> + <string name="backup_type_geohashes">követett helyek</string> + <string name="backup_type_geohashes_explainer">Helyek, amelyek bejegyzései megjelennek a hírfolyamaiban.</string> + <string name="backup_type_hashtags">követett kulcsszavak</string> + <string name="backup_type_hashtags_explainer">Kulcsszavak, amelyek bejegyzései megjelennek a hírfolyamban.</string> + <string name="backup_type_indexer_relays">indexelő átjátszólista</string> + <string name="backup_type_indexer_relays_explainer">Azok az átjátszók, amelyeket mások profiljainak és átjátszólistáinak megtalálásához használnak.</string> + <string name="backup_type_key_package_relays">csoportos csevegési kulcsátjátszók</string> + <string name="backup_type_key_package_relays_explainer">Ahol mások megtalálják a kulcsokat, hogy titkosított csoportos csevegésekhez adhassák Önt.</string> + <string name="backup_type_mute_list">némítási lista</string> + <string name="backup_type_mute_list_explainer">Elrejti a személyeket, szavakat, kulcsszavakat és beszélgetéseket mindenből, amit lát.</string> + <string name="backup_type_nutzap_info">nutzap fogadási beállítások</string> + <string name="backup_type_nutzap_info_explainer">A pénzverdék és a kulcs, amelyeket mások a nutzapok küldéséhez használnak az Ön számára.</string> + <string name="backup_type_other">lista</string> + <string name="backup_type_other_explainer">Egy listaelem ezen az eszközön mentve.</string> + <string name="backup_type_outbox_inbox_relays">nyilvános átjátszólista</string> + <string name="backup_type_outbox_inbox_relays_explainer">Megmondja mindenkinek, hol találhatók a bejegyzései, és hová küldjék a válaszokat és említéseket.</string> + <string name="backup_type_payment_targets">fizetési címek</string> + <string name="backup_type_payment_targets_explainer">A módok, ahogy mások fizethetnek Önnek.</string> + <string name="backup_type_private_outbox_relays">privát átjátszólista</string> + <string name="backup_type_private_outbox_relays_explainer">Ahol a piszkozatai és privát adatai tárolódnak.</string> + <string name="backup_type_profile">profil</string> + <string name="backup_type_profile_explainer">Az Ön neve, képe, bemutatkozása és fizetési címei, ahogy mindenki látja őket.</string> + <string name="backup_type_public_chats">nyilvános csevegési lista</string> + <string name="backup_type_public_chats_explainer">Nyilvános csevegések, amelyekhez csatlakozott.</string> + <string name="backup_type_relay_feeds">kedvenc átjátszóhírfolyamok</string> + <string name="backup_type_relay_feeds_explainer">Olyan átjátszók, amelyeket hírfolyamként böngész.</string> + <string name="backup_type_relay_groups">csoportlista</string> + <string name="backup_type_relay_groups_explainer">Átjátszó-alapú csoportok, amelyekhez csatlakozott.</string> + <string name="backup_type_search_relays">kereső átjátszólista</string> + <string name="backup_type_search_relays_explainer">Kereséskor használt átjátszók.</string> + <string name="backup_type_trust_providers">bizalmi szolgáltatólista</string> + <string name="backup_type_trust_providers_explainer">A szolgáltatások, amelyekben megbízik, hogy rangsorolják és ellenőrizzék Önnek az embereket.</string> + <string name="backup_type_trusted_relays">megbízható átjátszólista</string> + <string name="backup_type_trusted_relays_explainer">Átjátszók, amelyekhez kérdezés nélkül kapcsolódhat.</string> </resources> diff --git a/commonsUI/src/commonMain/composeResources/values-pl-rPL/strings.xml b/commonsUI/src/commonMain/composeResources/values-pl-rPL/strings.xml index 55a0998df3..6b3fd3bf9a 100644 --- a/commonsUI/src/commonMain/composeResources/values-pl-rPL/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-pl-rPL/strings.xml @@ -5507,4 +5507,382 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest <string name="cyberspace_bag_unsigned">Brak podpisu, więc autor to tylko domysł</string> <string name="cyberspace_bag_unknown_kind">Element typu %1$d, którego ten klient nie wyświetla</string> <string name="cyberspace_bag_opaque">%1$d bajtów danych, których ten klient nie odczytuje</string> + <string name="backup_action_block_again">Zablokuj %1$s ponownie</string> + <string name="backup_action_keep_count">Zachowaj %1$s</string> + <string name="backup_action_rejoin">Ponów %1$s</string> + <string name="backup_action_remute">Ponownie wycisz %1$s</string> + <string name="backup_action_restore_count">Przywróć %1$s</string> + <string name="backup_action_restore_feeds">Przywróć kanały</string> + <string name="backup_action_restore_places">Przywróć miejsca</string> + <string name="backup_action_restore_profile">Przywróć profil</string> + <string name="backup_action_restore_providers">Przywróć dostawców</string> + <string name="backup_action_restore_relays">Przywróć transmitery</string> + <string name="backup_action_restore_topics">Przywróć tematy</string> + <string name="backup_action_restore_wallet">Przywróć portfel</string> + <string name="backup_card_accept">Zaakceptuj</string> + <plurals name="backup_card_and_added"> + <item quantity="one">%1$s i dodano %2$d</item> + <item quantity="few">%1$s i dodano %2$d</item> + <item quantity="many">%1$s i dodano %2$d</item> + <item quantity="other">%1$s i dodano %2$d</item> + </plurals> + <plurals name="backup_card_and_blocked"> + <item quantity="one">%1$s i zablokowano %2$d</item> + <item quantity="few">%1$s i zablokowano %2$d</item> + <item quantity="many">%1$s i zablokowano %2$d</item> + <item quantity="other">%1$s i zablokowano %2$d</item> + </plurals> + <plurals name="backup_card_and_followed"> + <item quantity="one">%1$s i obserwowany %2$d</item> + <item quantity="few">%1$s i obserwowanych %2$d</item> + <item quantity="many">%1$s i obserwowanych %2$d</item> + <item quantity="other">%1$s i obserwowane %2$d</item> + </plurals> + <plurals name="backup_card_and_joined"> + <item quantity="one">%1$s i dołączono do %2$d</item> + <item quantity="few">%1$s i dołączono do %2$d</item> + <item quantity="many">%1$s i dołączono do %2$d</item> + <item quantity="other">%1$s i dołączono do %2$d</item> + </plurals> + <plurals name="backup_card_and_muted"> + <item quantity="one">%1$s i wyciszony %2$d</item> + <item quantity="few">%1$s i wyciszono %2$d</item> + <item quantity="many">%1$s i wyciszono %2$d</item> + <item quantity="other">%1$s i wyciszone %2$d</item> + </plurals> + <string name="backup_card_app_settings">Twoje zsynchronizowane ustawienia aplikacji Amethyst zostały zmienione w innej aplikacji, która nie jest Amethystem. To może powodować problemy.</string> + <string name="backup_card_concord_emptied">Opróżniłeś swoją listę Concorda</string> + <string name="backup_card_concord_rewritten">Zmieniłeś swoją listę Concorda</string> + <plurals name="backup_card_dm_relays_lost"> + <item quantity="one">Usunąłeś %1$d transmiter DM</item> + <item quantity="few">Usunąłeś %1$d transmiterów DM</item> + <item quantity="many">Usunąłeś %1$d transmiterów DM</item> + <item quantity="other">Usunąłeś %1$d transmitery DM</item> + </plurals> + <plurals name="backup_card_feeds_removed"> + <item quantity="one">Usunąłeś %1$d ulubiony kanał</item> + <item quantity="few">Usunąłeś %1$d ulubionych kanałów</item> + <item quantity="many">Usunąłeś %1$d ulubionych kanałów</item> + <item quantity="other">Usunięto %1$d ulubione kanały</item> + </plurals> + <plurals name="backup_card_follows_dropped"> + <item quantity="one">Przestałeś obserwować %1$d osobę</item> + <item quantity="few">Przestałeś obserwować %1$d osób</item> + <item quantity="many">Przestałeś obserwować %1$d osób</item> + <item quantity="other">Przestałeś obserwować %1$d osoby</item> + </plurals> + <plurals name="backup_card_hashtags_unfollowed"> + <item quantity="one">Przestałeś obserwować %1$d hashtag</item> + <item quantity="few">Przestałeś obserwować %1$d hashtagów</item> + <item quantity="many">Przestałeś obserwować %1$d hashtagów</item> + <item quantity="other">Przestałeś obserwować %1$d hashtagi</item> + </plurals> + <plurals name="backup_card_indexer_relays_lost"> + <item quantity="one">Usunięto %1$d transmiter indeksujący</item> + <item quantity="few">Usunięto %1$d transmiterów indeksujących</item> + <item quantity="many">Usunięto %1$d transmiterów indeksujących</item> + <item quantity="other">Usunięto %1$d transmitery indeksujące</item> + </plurals> + <plurals name="backup_card_key_package_relays_lost"> + <item quantity="one">Usunięto %1$d transmiter klucza grupowego czatu</item> + <item quantity="few">Usunięto %1$d transmiterów klucza grupowego czatu</item> + <item quantity="many">Usunięto %1$d transmiterów klucza grupowego czatu</item> + <item quantity="other">Usunięto %1$d transmitery klucza grupowego czatu</item> + </plurals> + <plurals name="backup_card_leave_chats"> + <item quantity="one">Opuściłeś %1$d czat publiczny</item> + <item quantity="few">Opuściłeś %1$d czatów publicznych</item> + <item quantity="many">Opuściłeś %1$d czatów publicznych</item> + <item quantity="other">Opuściłeś %1$d czaty publiczne</item> + </plurals> + <plurals name="backup_card_leave_communities"> + <item quantity="one">Opuściłeś %1$d społeczność</item> + <item quantity="few">Opuściłeś %1$d społeczności</item> + <item quantity="many">Opuściłeś %1$d społeczności</item> + <item quantity="other">Opuściłeś %1$d społeczności</item> + </plurals> + <plurals name="backup_card_leave_groups"> + <item quantity="one">Opuściłeś %1$d grupę</item> + <item quantity="few">Opuściłeś %1$d grup</item> + <item quantity="many">Opuściłeś %1$d grup</item> + <item quantity="other">Opuszczono %1$d grupy</item> + </plurals> + <plurals name="backup_card_leave_rooms"> + <item quantity="one">Opuściłeś %1$d pokój czatu</item> + <item quantity="few">Opuściłeś %1$d pokoi czatu</item> + <item quantity="many">Opuściłeś %1$d pokoi czatu</item> + <item quantity="other">Opuściłeś %1$d pokoje czatu</item> + </plurals> + <plurals name="backup_card_mutes_visible"> + <item quantity="one">Włączyłeś dźwięk dla %1$d elementu</item> + <item quantity="few">Włączyłeś dźwięk dla %1$d elementów</item> + <item quantity="many">Włączyłeś dźwięk dla %1$d elementów</item> + <item quantity="other">%1$d elementy mają włączony dźwięk</item> + </plurals> + <plurals name="backup_card_nip65_lost"> + <item quantity="one">Usunąłeś %1$d publiczny transmiter</item> + <item quantity="few">Usunąłeś %1$d publicznych transmiterów</item> + <item quantity="many">Usunąłeś %1$d publicznych transmiterów</item> + <item quantity="other">Usunąłeś %1$d publiczne transmitery</item> + </plurals> + <string name="backup_card_nutzap_key_removed">Usunąłeś swój klucz nutzap</string> + <string name="backup_card_nutzap_key_replaced">Wymieniłeś swój klucz Nutzap</string> + <plurals name="backup_card_nutzap_mints_removed"> + <item quantity="one">Usunięto %1$d nutzap mint</item> + <item quantity="few">Usunięto %1$d nutzap mintów</item> + <item quantity="many">Usunięto %1$d nutzap mintów</item> + <item quantity="other">Usunięto %1$d nutzap minty</item> + </plurals> + <plurals name="backup_card_offers_removed"> + <item quantity="one">Usunięto %1$d ofertę BOLT12</item> + <item quantity="few">Usunięto %1$d ofert BOLT12</item> + <item quantity="many">Usunięto %1$d ofert BOLT12</item> + <item quantity="other">Usunięto %1$d oferty BOLT12</item> + </plurals> + <plurals name="backup_card_payment_targets_removed"> + <item quantity="one">Usunięto %1$d sposób na otrzymanie zapłaty</item> + <item quantity="few">Usunięto %1$d sposobów otrzymywania zapłaty</item> + <item quantity="many">Usunięto %1$d sposobów otrzymywania zapłaty</item> + <item quantity="other">Usunięto %1$d sposoby otrzymywania zapłaty</item> + </plurals> + <plurals name="backup_card_places_unfollowed"> + <item quantity="one">Przestałeś obserwować %1$d miejsce</item> + <item quantity="few">Przestałeś obserwować %1$d miejsc</item> + <item quantity="many">Przestałeś obserwować %1$d miejsc</item> + <item quantity="other">Przestałeś obserwować %1$d miejsca</item> + </plurals> + <plurals name="backup_card_private_outbox_lost"> + <item quantity="one">Usunięto %1$d prywatny transmiter</item> + <item quantity="few">Usunięto %1$d prywatnych transmiterów</item> + <item quantity="many">Usunięto %1$d prywatnych transmiterów</item> + <item quantity="other">Usunięto %1$d prywatne transmitery</item> + </plurals> + <plurals name="backup_card_profile_lost_fields"> + <item quantity="one">Usunąłeś %1$d pole profilu</item> + <item quantity="few">Usunąłeś %1$d pól profilowych</item> + <item quantity="many">Usunąłeś %1$d pól profilowych</item> + <item quantity="other">Usunąłeś %1$d pola profilowe</item> + </plurals> + <plurals name="backup_card_relay_feeds_lost"> + <item quantity="one">Usunięto %1$d kanał transmitera</item> + <item quantity="few">Usunięto %1$d kanałów transmitera</item> + <item quantity="many">Usunięto %1$d kanałów transmitera</item> + <item quantity="other">Usunięto %1$d kanały transmitera</item> + </plurals> + <plurals name="backup_card_search_relays_lost"> + <item quantity="one">Usunięto %1$d transmiter wyszukiwania</item> + <item quantity="few">Usunięto %1$d transmiterów wyszukiwania</item> + <item quantity="many">Usunięto %1$d transmiterów wyszukiwania</item> + <item quantity="other">Usunięto %1$d transmitery wyszukiwania</item> + </plurals> + <plurals name="backup_card_trust_services_changed"> + <item quantity="one">Zmieniłeś dostawcę %1$d zaufanego serwisu</item> + <item quantity="few">Zmieniłeś dostawcę %1$d zaufanych serwisów</item> + <item quantity="many">Zmieniłeś dostawcę %1$d zaufanych serwisów</item> + <item quantity="other">Zmieniłeś dostawcę %1$d zaufanych serwisów</item> + </plurals> + <plurals name="backup_card_trusted_lost"> + <item quantity="one">Przestałeś ufać %1$d transmiterowi</item> + <item quantity="few">Przestałeś ufać %1$d transmiterom</item> + <item quantity="many">Przestałeś ufać %1$d transmiterom</item> + <item quantity="other">Przestałeś ufać %1$d transmiterom</item> + </plurals> + <plurals name="backup_card_unblocked"> + <item quantity="one">Odblokowałeś %1$d transmiter</item> + <item quantity="few">Odblokowałeś %1$d transmiterów</item> + <item quantity="many">Odblokowałeś %1$d transmiterów</item> + <item quantity="other">Odblokowałeś %1$d transmitery</item> + </plurals> + <string name="backup_card_wallet_emptied">Opróżniłeś swój portfel Cashu</string> + <string name="backup_card_wallet_rewritten">Zmieniłeś swój portfel Cashu</string> + <string name="backup_conflict_added">Dodano (%1$s)</string> + <string name="backup_conflict_changed">Zmieniono (%1$s)</string> + <string name="backup_conflict_count_added">Dodano %1$s</string> + <string name="backup_conflict_count_changed">%1$s zmieniono</string> + <string name="backup_conflict_count_removed">Usunięto %1$s</string> + <string name="backup_conflict_keep_new">Użyj nowej wersji</string> + <string name="backup_conflict_private_added">Dodano prywatne (zaszyfrowane) pozycje.</string> + <string name="backup_conflict_private_changed">Zmieniłeś również elementy prywatne (zaszyfrowane). Nie można ich tutaj porównać.</string> + <string name="backup_conflict_private_cleared">Usunąłeś wszystkie prywatne (zaszyfrowane) elementy.</string> + <string name="backup_conflict_relay_read_only">tylko do odczytu</string> + <string name="backup_conflict_relay_read_write">odczyt i zapis</string> + <string name="backup_conflict_relay_write_only">tylko do zapisu</string> + <string name="backup_conflict_removed">Usunięto (%1$s)</string> + <string name="backup_conflict_restore_mine">Przywróć zapisaną wersję</string> + <string name="backup_conflict_title">Zmieniłeś %1$s w innej aplikacji</string> + <string name="backup_entry_algo_feed">Kanały</string> + <string name="backup_entry_chat_room">Czat pokoje</string> + <string name="backup_entry_community">Społeczności</string> + <string name="backup_entry_hashtag">Hashtagi</string> + <string name="backup_entry_identity_claim">Powiązane tożsamości</string> + <string name="backup_entry_location">Lokalizacje</string> + <string name="backup_entry_mint">Minty</string> + <string name="backup_entry_nutzap_key">Klucze Nutzap</string> + <string name="backup_entry_offer">Oferty</string> + <string name="backup_entry_other">Inne wpisy</string> + <string name="backup_entry_payment_target">Adresy płatności</string> + <string name="backup_entry_person">Osoby</string> + <string name="backup_entry_profile_field">Pola profilu</string> + <string name="backup_entry_public_chat">Czaty publiczne</string> + <string name="backup_entry_relay">Transmitery</string> + <string name="backup_entry_relay_group">Grupy</string> + <string name="backup_entry_thread">Wątki</string> + <string name="backup_entry_trust_provider">Zaufani dostawcy</string> + <string name="backup_entry_word">Słowa</string> + <string name="backup_profile_field_about">O profilu</string> + <string name="backup_profile_field_banner">Baner</string> + <string name="backup_profile_field_birthday">Data urodzin</string> + <string name="backup_profile_field_bot">Flaga bota</string> + <string name="backup_profile_field_clink_offer">Oferta CLINK</string> + <string name="backup_profile_field_display_name">Nazwa wyświetlana</string> + <string name="backup_profile_field_lud06">LNURL</string> + <string name="backup_profile_field_lud16">Lightning Adres</string> + <string name="backup_profile_field_name">Nazwa użytkownika</string> + <string name="backup_profile_field_nip05">Adres Nostr</string> + <string name="backup_profile_field_picture">Zdjęcie</string> + <string name="backup_profile_field_pronouns">Zaimki</string> + <string name="backup_profile_field_website">Strona www</string> + <string name="backup_review_allowed_tag">Dozwolona</string> + <string name="backup_review_changed_by_other_app">Zmieniono w innej aplikacji · %1$s</string> + <string name="backup_review_concord_body">Twoje społeczności w Concord są dla Ciebie zaszyfrowane, więc nie można ich tutaj porównać. Przywrócenie danych powoduje przywrócenie dokładnie tego, co zostało zapisane na tym urządzeniu.</string> + <string name="backup_review_demoted_tag">Przeniesiona</string> + <string name="backup_review_dropped_count">%1$s odrzuconych</string> + <string name="backup_review_dropped_tag">Usunięta</string> + <string name="backup_review_encrypted_added">Utworzyłeś to</string> + <string name="backup_review_encrypted_changed">Zmieniłeś to</string> + <string name="backup_review_encrypted_cleared">Opróżniłeś to</string> + <string name="backup_review_feed">Kanał</string> + <string name="backup_review_gone">Zniknęła</string> + <string name="backup_review_hashtags_you_follow">Hashtagi, które obserwujesz</string> + <string name="backup_review_inbox">Skrzynka odbiorcza</string> + <string name="backup_review_inbox_explainer">gdzie inni docierają do Ciebie</string> + <string name="backup_review_joined">dołączył(a)</string> + <string name="backup_review_kept">zachowane</string> + <string name="backup_review_kept_count">Zachowano %1$s</string> + <string name="backup_review_key_removed">Usunięto klucz odbierający</string> + <string name="backup_review_key_replaced">Wymieniono klucz odbierający</string> + <string name="backup_review_key_warning">Nutzapy zostaną przypisane do tego nowego klucza. Jeśli urządzenie nie posiada tego klucza, może się okazać, że nie będzie można ich tutaj zrealizować.</string> + <string name="backup_review_left">zostało</string> + <string name="backup_review_left_tag">Zostało</string> + <string name="backup_review_listed_tag">Na Liście</string> + <string name="backup_review_moved_tag">Przeniesiona</string> + <string name="backup_review_names_and_more">%1$s i %2$s więcej</string> + <string name="backup_review_new">Nowa wersja</string> + <string name="backup_review_new_count">%1$s nowych</string> + <string name="backup_review_new_count_caption">nowe</string> + <string name="backup_review_new_tag">Nowa</string> + <string name="backup_review_newly_blocked">Nowo zablokowana</string> + <string name="backup_review_newly_muted">%1$s nowo wyciszonych</string> + <string name="backup_review_no_longer_blocked">Nie jest już zablokowana</string> + <string name="backup_review_not_set">Nie ustawiono</string> + <string name="backup_review_nothing_here">Nic tu nie ma!</string> + <string name="backup_review_offer">Oferta BOLT12</string> + <plurals name="backup_review_offers_gone"> + <item quantity="one">%1$d oferta usunięta</item> + <item quantity="few">%1$d ofert usunięto</item> + <item quantity="many">%1$d ofert usunięto</item> + <item quantity="other">%1$d oferty usunięte</item> + </plurals> + <string name="backup_review_outbox">Skrzynka nadawcza</string> + <string name="backup_review_outbox_explainer">gdzie publikujesz</string> + <plurals name="backup_review_payment_targets_gone"> + <item quantity="one">%1$d sposób płatności został usunięty</item> + <item quantity="few">%1$d sposobów płatności zostało usuniętych</item> + <item quantity="many">%1$d sposobów płatności zostało usuniętych</item> + <item quantity="other">%1$d sposoby płatności zostały usunięte</item> + </plurals> + <string name="backup_review_payments_explainer">Osoby, które Ci płacą, widzą tylko to, co znajduje się na tej liście.</string> + <string name="backup_review_places_gone"> miejsca zniknęły</string> + <string name="backup_review_places_you_follow">Miejsca które obserwujesz</string> + <string name="backup_review_private_added_body">Są teraz zaszyfrowane, co prawdopodobnie wyjaśnia, gdzie podziały się powyższe elementy publiczne.</string> + <string name="backup_review_private_added_title">Ustawiłeś te pozycje jako prywatne</string> + <string name="backup_review_private_changed_body">Są zaszyfrowane, więc nie można ich tu porównać.</string> + <string name="backup_review_private_changed_title">Zmieniłeś prywatne elementy</string> + <string name="backup_review_private_wiped_body">Są zaszyfrowane, więc tylko Ty możesz zobaczyć, co to było. Odtworzenie przywróci je wszystkie.</string> + <string name="backup_review_private_wiped_title">Usunąłeś wszystkie prywatne elementy</string> + <string name="backup_review_receiving_tag">Pobieranie</string> + <string name="backup_review_renamed">zmieniono nazwę</string> + <string name="backup_review_renamed_tag">Zmieniono nazwę</string> + <string name="backup_review_saved">Na tym urządzeniu</string> + <string name="backup_review_search_people">Szukaj %1$s osób</string> + <string name="backup_review_searching_tag">Szukam</string> + <string name="backup_review_service">Serwis</string> + <string name="backup_review_service_followers">Obserwujący</string> + <string name="backup_review_service_rank">Ranking</string> + <string name="backup_review_service_topics">Tematy</string> + <string name="backup_review_services_changed">Zmieniono %1$s z %2$s serwisów</string> + <string name="backup_review_services_changed_explainer">Teraz pochodzą od kogoś innego albo od nikogo</string> + <string name="backup_review_still_blocked">Nadal zablokowana</string> + <string name="backup_review_storing_tag">Przechowywanie</string> + <string name="backup_review_tab_dropped">Usunięte · %1$s</string> + <string name="backup_review_tab_edited">Edytowane · %1$s</string> + <string name="backup_review_tab_new">Nowe · %1$s</string> + <string name="backup_review_title_cleared">%1$s wyczyszczono</string> + <string name="backup_review_title_updated">%1$s zaktualizowano</string> + <string name="backup_review_topics_gone">tematy zniknęły</string> + <string name="backup_review_trust_warning">Rankingi, liczba obserwujących i filtrowanie spamu, które widzisz, pochodzą od tych dostawców.</string> + <string name="backup_review_trusted_tag">Zaufane</string> + <string name="backup_review_unblocked">%1$s odblokowane</string> + <string name="backup_review_unblocked_explainer">Ametyst ponownie połączy się z nimi</string> + <string name="backup_review_units_tag">Jednostek</string> + <string name="backup_review_unmuted">%1$s odblokowanych</string> + <string name="backup_review_unmuted_explainer">Mogą one ponownie pojawić się w Twoich informacjach</string> + <string name="backup_review_untrusted_tag">Niezaufane</string> + <string name="backup_review_wallet_body">Minty i klucze w Twoim portfelu są zaszyfrowane dla Ciebie, więc nie można ich tutaj porównać pozycja po pozycji. Przywrócenie danych powoduje przywrócenie dokładnie tego, co zostało zapisane na tym urządzeniu.</string> + <string name="backup_review_was_named">było „%1$s”</string> + <string name="backup_review_words_and_hashtags">Wyrazy & hasztagi</string> + <string name="backup_type_app_settings">zsynchronizowane ustawienia aplikacji</string> + <string name="backup_type_app_settings_explainer">Ustawienia Amethyst są synchronizowane na wszystkich Twoich urządzeniach. Są one zaszyfrowane, więc można porównać jedynie sam fakt ich obecności.</string> + <string name="backup_type_blocked_relays">lista zablokowanych przekaźników</string> + <string name="backup_type_blocked_relays_explainer">Transmitery, z którymi ta aplikacja nigdy się nie łączy.</string> + <string name="backup_type_bolt12_offers">Oferta BOLT12</string> + <string name="backup_type_bolt12_offers_explainer">Ta możliwość pozwala innym płacić Ci za pomocą Lightning.</string> + <string name="backup_type_cashu_wallet">Portfel Cashu</string> + <string name="backup_type_cashu_wallet_explainer">Ustawienia portfela ecash oraz minty. Są one zaszyfrowane, więc można porównać jedynie sam fakt ich istnienia.</string> + <string name="backup_type_communities">lista społeczności</string> + <string name="backup_type_communities_explainer">Społeczności, do których dołączyłeś.</string> + <string name="backup_type_concord_communities">Lista społeczności Concord</string> + <string name="backup_type_concord_communities_explainer">Społeczności Concord, do których dołączyłeś. Są one zaszyfrowane, więc można porównać jedynie fakt ich istnienia.</string> + <string name="backup_type_dm_relays">Lista transmiterów DM</string> + <string name="backup_type_dm_relays_explainer">Gdzie inni dostarczają Twoje prywatne wiadomości.</string> + <string name="backup_type_ephemeral_chats">lista czatów efemerycznych</string> + <string name="backup_type_ephemeral_chats_explainer">Tymczasowe czaty, do których dołączyłeś.</string> + <string name="backup_type_favorite_algo_feeds">lista ulubionych kanałów</string> + <string name="backup_type_favorite_algo_feeds_explainer">Zapisane przez Ciebie kanały algorytmiczne.</string> + <string name="backup_type_follow_list">lista obserwowanych</string> + <string name="backup_type_follow_list_explainer">Określa, czyje posty pojawiają się w Twoim kanale głównym.</string> + <string name="backup_type_geohashes">obserwowane lokalizacje</string> + <string name="backup_type_geohashes_explainer">Miejsca, z których posty pojawiają się na Twoich kanałach.</string> + <string name="backup_type_hashtags">obserwowane hashtagi</string> + <string name="backup_type_hashtags_explainer">Hashtagi, z których pochodzą posty wyświetlane w Twoich kanałach.</string> + <string name="backup_type_indexer_relays">lista transmiterów indeksujących</string> + <string name="backup_type_indexer_relays_explainer">Transmitery służące do wyszukiwania profili innych osób oraz list transmiterów.</string> + <string name="backup_type_key_package_relays">transmitery kluczy czatu grupowego</string> + <string name="backup_type_key_package_relays_explainer">Gdzie inni znajdą klucze do dodawania cię do zaszyfrowanych czatów grupowych.</string> + <string name="backup_type_mute_list">lista zablokowanych</string> + <string name="backup_type_mute_list_explainer">Ukrywa osoby, słowa, hashtagi i wątki we wszystkich wyświetlanych treściach.</string> + <string name="backup_type_nutzap_info">ustawienia odbioru nutzap</string> + <string name="backup_type_nutzap_info_explainer">Minty i klucz, które inni używają do wysyłania ci „nutzapów”.</string> + <string name="backup_type_other">lista</string> + <string name="backup_type_other_explainer">Lista zapisana na tym urządzeniu.</string> + <string name="backup_type_outbox_inbox_relays">lista publicznych transmiterów</string> + <string name="backup_type_outbox_inbox_relays_explainer">Informuje wszystkich, gdzie mogą znaleźć Twoje posty oraz gdzie mają wysyłać Ci odpowiedzi i wzmianki.</string> + <string name="backup_type_payment_targets">adresy płatności</string> + <string name="backup_type_payment_targets_explainer">Sposoby, w jakie inni mogą Ci zapłacić.</string> + <string name="backup_type_private_outbox_relays">lista prywatnych transmiterów</string> + <string name="backup_type_private_outbox_relays_explainer">Gdzie przechowywane są Twoje projekty i prywatne dane.</string> + <string name="backup_type_profile">profil</string> + <string name="backup_type_profile_explainer">Twoje imię i nazwisko, zdjęcie, biografia oraz adresy do płatności – tak, jak widzą je wszyscy.</string> + <string name="backup_type_public_chats">lista czatów publicznych</string> + <string name="backup_type_public_chats_explainer">Publiczne czaty, do których dołączyłeś.</string> + <string name="backup_type_relay_feeds">ulubione kanały transmitera</string> + <string name="backup_type_relay_feeds_explainer">Transmitery, które przeglądasz jako kanały informacyjne.</string> + <string name="backup_type_relay_groups">lista grup</string> + <string name="backup_type_relay_groups_explainer">Grupy oparte na transmiterach, do których dołączyłeś.</string> + <string name="backup_type_search_relays">lista transmiterów wyszukiwania</string> + <string name="backup_type_search_relays_explainer">Transmitery używane podczas wyszukiwania.</string> + <string name="backup_type_trust_providers">lista zaufanych dostawców</string> + <string name="backup_type_trust_providers_explainer">Serwisy, którym ufasz, a które zajmują się klasyfikowaniem i weryfikacją osób w Twoim imieniu.</string> + <string name="backup_type_trusted_relays">lista zaufanych transmiterów</string> + <string name="backup_type_trusted_relays_explainer">Zaufane transmitery, z którymi bez wahania nawiązujesz połączenie.</string> </resources> diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index a0b587b986..f8a7c986c4 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -5482,4 +5482,574 @@ <string name="cyberspace_bag_unsigned">Unsigned, so this author is a claim</string> <string name="cyberspace_bag_unknown_kind">An item of kind %1$d, which this client does not draw</string> <string name="cyberspace_bag_opaque">%1$d bytes of something this client does not read</string> + <string name="backup_action_block_again">Block %1$s again</string> + <string name="backup_action_keep_count">Keep %1$s</string> + <string name="backup_action_rejoin">Rejoin %1$s</string> + <string name="backup_action_remute">Re-mute %1$s</string> + <string name="backup_action_restore_count">Restore %1$s</string> + <string name="backup_action_restore_feeds">Restore feeds</string> + <string name="backup_action_restore_places">Restore places</string> + <string name="backup_action_restore_profile">Restore profile</string> + <string name="backup_action_restore_providers">Restore providers</string> + <string name="backup_action_restore_relays">Restore relays</string> + <string name="backup_action_restore_topics">Restore topics</string> + <string name="backup_action_restore_wallet">Restore wallet</string> + <string name="backup_card_accept">Accept</string> + <plurals name="backup_card_and_added"> + <item quantity="one">%1$s and added %2$d</item> + <item quantity="other">%1$s and added %2$d</item> + </plurals> + <plurals name="backup_card_and_blocked"> + <item quantity="one">%1$s and blocked %2$d</item> + <item quantity="other">%1$s and blocked %2$d</item> + </plurals> + <plurals name="backup_card_and_followed"> + <item quantity="one">%1$s and followed %2$d</item> + <item quantity="other">%1$s and followed %2$d</item> + </plurals> + <plurals name="backup_card_and_joined"> + <item quantity="one">%1$s and joined %2$d</item> + <item quantity="other">%1$s and joined %2$d</item> + </plurals> + <plurals name="backup_card_and_muted"> + <item quantity="one">%1$s and muted %2$d</item> + <item quantity="other">%1$s and muted %2$d</item> + </plurals> + <string name="backup_card_app_settings">Your Amethyst synced app settings changed in another app that is not Amethyst. This can create problems.</string> + <string name="backup_card_concord_emptied">You emptied your Concord list</string> + <string name="backup_card_concord_rewritten">You changed your Concord list</string> + <plurals name="backup_card_dm_relays_lost"> + <item quantity="one">You removed %1$d DM relay</item> + <item quantity="other">You removed %1$d DM relays</item> + </plurals> + <plurals name="backup_card_feeds_removed"> + <item quantity="one">You removed %1$d favorite feed</item> + <item quantity="other">You removed %1$d favorite feeds</item> + </plurals> + <plurals name="backup_card_follows_dropped"> + <item quantity="one">You unfollowed %1$d person</item> + <item quantity="other">You unfollowed %1$d people</item> + </plurals> + <plurals name="backup_card_hashtags_unfollowed"> + <item quantity="one">You unfollowed %1$d hashtag</item> + <item quantity="other">You unfollowed %1$d hashtags</item> + </plurals> + <plurals name="backup_card_indexer_relays_lost"> + <item quantity="one">You removed %1$d indexer relay</item> + <item quantity="other">You removed %1$d indexer relays</item> + </plurals> + <plurals name="backup_card_key_package_relays_lost"> + <item quantity="one">You removed %1$d group-chat key relay</item> + <item quantity="other">You removed %1$d group-chat key relays</item> + </plurals> + <plurals name="backup_card_leave_chats"> + <item quantity="one">You left %1$d public chat</item> + <item quantity="other">You left %1$d public chats</item> + </plurals> + <plurals name="backup_card_leave_communities"> + <item quantity="one">You left %1$d community</item> + <item quantity="other">You left %1$d communities</item> + </plurals> + <plurals name="backup_card_leave_groups"> + <item quantity="one">You left %1$d group</item> + <item quantity="other">You left %1$d groups</item> + </plurals> + <plurals name="backup_card_leave_rooms"> + <item quantity="one">You left %1$d chat room</item> + <item quantity="other">You left %1$d chat rooms</item> + </plurals> + <plurals name="backup_card_mutes_visible"> + <item quantity="one">You unmuted %1$d thing</item> + <item quantity="other">You unmuted %1$d things</item> + </plurals> + <plurals name="backup_card_nip65_lost"> + <item quantity="one">You removed %1$d public relay</item> + <item quantity="other">You removed %1$d public relays</item> + </plurals> + <string name="backup_card_nutzap_key_removed">You removed your nutzap key</string> + <string name="backup_card_nutzap_key_replaced">You replaced your nutzap key</string> + <plurals name="backup_card_nutzap_mints_removed"> + <item quantity="one">You removed %1$d nutzap mint</item> + <item quantity="other">You removed %1$d nutzap mints</item> + </plurals> + <plurals name="backup_card_offers_removed"> + <item quantity="one">You removed %1$d BOLT12 offer</item> + <item quantity="other">You removed %1$d BOLT12 offers</item> + </plurals> + <plurals name="backup_card_payment_targets_removed"> + <item quantity="one">You removed %1$d way to get paid</item> + <item quantity="other">You removed %1$d ways to get paid</item> + </plurals> + <plurals name="backup_card_places_unfollowed"> + <item quantity="one">You unfollowed %1$d place</item> + <item quantity="other">You unfollowed %1$d places</item> + </plurals> + <plurals name="backup_card_private_outbox_lost"> + <item quantity="one">You removed %1$d private relay</item> + <item quantity="other">You removed %1$d private relays</item> + </plurals> + <plurals name="backup_card_profile_lost_fields"> + <item quantity="one">You removed %1$d profile field</item> + <item quantity="other">You removed %1$d profile fields</item> + </plurals> + <plurals name="backup_card_relay_feeds_lost"> + <item quantity="one">You removed %1$d relay feed</item> + <item quantity="other">You removed %1$d relay feeds</item> + </plurals> + <plurals name="backup_card_search_relays_lost"> + <item quantity="one">You removed %1$d search relay</item> + <item quantity="other">You removed %1$d search relays</item> + </plurals> + <plurals name="backup_card_trust_services_changed"> + <item quantity="one">You changed the provider of %1$d trust service</item> + <item quantity="other">You changed the provider of %1$d trust services</item> + </plurals> + <plurals name="backup_card_trusted_lost"> + <item quantity="one">You stopped trusting %1$d relay</item> + <item quantity="other">You stopped trusting %1$d relays</item> + </plurals> + <plurals name="backup_card_unblocked"> + <item quantity="one">You unblocked %1$d relay</item> + <item quantity="other">You unblocked %1$d relays</item> + </plurals> + <string name="backup_card_wallet_emptied">You emptied your Cashu wallet</string> + <string name="backup_card_wallet_rewritten">You changed your Cashu wallet</string> + <string name="backup_conflict_added">Added (%1$s)</string> + <string name="backup_conflict_changed">Changed (%1$s)</string> + <string name="backup_conflict_count_added">%1$s added</string> + <string name="backup_conflict_count_changed">%1$s changed</string> + <string name="backup_conflict_count_removed">%1$s removed</string> + <string name="backup_conflict_keep_new">Use new version</string> + <string name="backup_conflict_private_added">You added private (encrypted) items.</string> + <string name="backup_conflict_private_changed">You also changed private (encrypted) items. They can't be compared here.</string> + <string name="backup_conflict_private_cleared">You removed all private (encrypted) items.</string> + <string name="backup_conflict_relay_read_only">read only</string> + <string name="backup_conflict_relay_read_write">read and write</string> + <string name="backup_conflict_relay_write_only">write only</string> + <string name="backup_conflict_removed">Removed (%1$s)</string> + <string name="backup_conflict_restore_mine">Restore saved version</string> + <string name="backup_conflict_title">You changed your %1$s in another app</string> + <string name="backup_entry_algo_feed">Feeds</string> + <string name="backup_entry_chat_room">Chat rooms</string> + <string name="backup_entry_community">Communities</string> + <string name="backup_entry_hashtag">Hashtags</string> + <string name="backup_entry_identity_claim">Linked identities</string> + <string name="backup_entry_location">Locations</string> + <string name="backup_entry_mint">Mints</string> + <string name="backup_entry_nutzap_key">Nutzap keys</string> + <string name="backup_entry_offer">Offers</string> + <string name="backup_entry_other">Other entries</string> + <string name="backup_entry_payment_target">Payment addresses</string> + <string name="backup_entry_person">People</string> + <string name="backup_entry_profile_field">Profile fields</string> + <string name="backup_entry_public_chat">Public chats</string> + <string name="backup_entry_relay">Relays</string> + <string name="backup_entry_relay_group">Groups</string> + <string name="backup_entry_thread">Threads</string> + <string name="backup_entry_trust_provider">Trust providers</string> + <string name="backup_entry_word">Words</string> + <string name="backup_profile_field_about">About</string> + <string name="backup_profile_field_banner">Banner</string> + <string name="backup_profile_field_birthday">Birthday</string> + <string name="backup_profile_field_bot">Bot flag</string> + <string name="backup_profile_field_clink_offer">CLINK offer</string> + <string name="backup_profile_field_display_name">Display name</string> + <string name="backup_profile_field_lud06">LNURL</string> + <string name="backup_profile_field_lud16">Lightning address</string> + <string name="backup_profile_field_name">Username</string> + <string name="backup_profile_field_nip05">Nostr address</string> + <string name="backup_profile_field_picture">Picture</string> + <string name="backup_profile_field_pronouns">Pronouns</string> + <string name="backup_profile_field_website">Website</string> + <string name="backup_review_allowed_tag">Allowed</string> + <string name="backup_review_changed_by_other_app">Changed in another app · %1$s</string> + <string name="backup_review_concord_body">Your Concord communities are encrypted to you, so they can't be compared here. Restoring puts back exactly what this device saved.</string> + <string name="backup_review_demoted_tag">Moved</string> + <string name="backup_review_dropped_count">%1$s dropped</string> + <string name="backup_review_dropped_tag">Dropped</string> + <string name="backup_review_encrypted_added">You created it</string> + <string name="backup_review_encrypted_changed">You changed it</string> + <string name="backup_review_encrypted_cleared">You emptied it</string> + <string name="backup_review_feed">Feed</string> + <string name="backup_review_gone">Gone</string> + <string name="backup_review_hashtags_you_follow">Hashtags you follow</string> + <string name="backup_review_inbox">Inbox</string> + <string name="backup_review_inbox_explainer">where others reach you</string> + <string name="backup_review_joined">joined</string> + <string name="backup_review_kept">kept</string> + <string name="backup_review_kept_count">%1$s kept</string> + <string name="backup_review_key_removed">Receiving key removed</string> + <string name="backup_review_key_replaced">Receiving key replaced</string> + <string name="backup_review_key_warning">Nutzaps will be locked to this new key. If this device doesn't have it, you may not be able to redeem them here.</string> + <string name="backup_review_left">left</string> + <string name="backup_review_left_tag">Left</string> + <string name="backup_review_listed_tag">Listed</string> + <string name="backup_review_moved_tag">Moved</string> + <string name="backup_review_names_and_more">%1$s and %2$s more</string> + <string name="backup_review_new">New version</string> + <string name="backup_review_new_count">%1$s new</string> + <string name="backup_review_new_count_caption">new</string> + <string name="backup_review_new_tag">New</string> + <string name="backup_review_newly_blocked">Newly blocked</string> + <string name="backup_review_newly_muted">%1$s newly muted</string> + <string name="backup_review_no_longer_blocked">No longer blocked</string> + <string name="backup_review_not_set">Not set</string> + <string name="backup_review_nothing_here">Nothing here</string> + <string name="backup_review_offer">BOLT12 offer</string> + <plurals name="backup_review_offers_gone"> + <item quantity="one">%1$d offer removed</item> + <item quantity="other">%1$d offers removed</item> + </plurals> + <string name="backup_review_outbox">Outbox</string> + <string name="backup_review_outbox_explainer">where you publish</string> + <plurals name="backup_review_payment_targets_gone"> + <item quantity="one">%1$d way to pay you removed</item> + <item quantity="other">%1$d ways to pay you removed</item> + </plurals> + <string name="backup_review_payments_explainer">People paying you only see what's in this list.</string> + <string name="backup_review_places_gone">places gone</string> + <string name="backup_review_places_you_follow">Places you follow</string> + <string name="backup_review_private_added_body">They're encrypted now, which is likely where the public items above went.</string> + <string name="backup_review_private_added_title">You made items private</string> + <string name="backup_review_private_changed_body">They're encrypted, so they can't be compared here.</string> + <string name="backup_review_private_changed_title">You changed private items</string> + <string name="backup_review_private_wiped_body">They're encrypted, so only you can see what they were. Restoring brings them all back.</string> + <string name="backup_review_private_wiped_title">You removed all private items</string> + <string name="backup_review_receiving_tag">Receiving</string> + <string name="backup_review_renamed">renamed</string> + <string name="backup_review_renamed_tag">Renamed</string> + <string name="backup_review_saved">On this device</string> + <string name="backup_review_search_people">Search %1$s people</string> + <string name="backup_review_searching_tag">Searching</string> + <string name="backup_review_service">Service</string> + <string name="backup_review_service_followers">Followers</string> + <string name="backup_review_service_rank">Rank</string> + <string name="backup_review_service_topics">Topics</string> + <string name="backup_review_services_changed">%1$s of %2$s services changed</string> + <string name="backup_review_services_changed_explainer">They now come from someone else, or no one</string> + <string name="backup_review_still_blocked">Still blocked</string> + <string name="backup_review_storing_tag">Storing</string> + <string name="backup_review_tab_dropped">Dropped · %1$s</string> + <string name="backup_review_tab_edited">Edited · %1$s</string> + <string name="backup_review_tab_new">New · %1$s</string> + <string name="backup_review_title_cleared">%1$s cleared</string> + <string name="backup_review_title_updated">%1$s updated</string> + <string name="backup_review_topics_gone">topics gone</string> + <string name="backup_review_trust_warning">Ranks, follower counts and spam filtering you see come from these providers.</string> + <string name="backup_review_trusted_tag">Trusted</string> + <string name="backup_review_unblocked">%1$s unblocked</string> + <string name="backup_review_unblocked_explainer">Amethyst will connect to them again</string> + <string name="backup_review_units_tag">Units</string> + <string name="backup_review_unmuted">%1$s unmuted</string> + <string name="backup_review_unmuted_explainer">These can reach your feeds again</string> + <string name="backup_review_untrusted_tag">Untrusted</string> + <string name="backup_review_wallet_body">Your wallet's mints and keys are encrypted to you, so they can't be compared item by item here. Restoring puts back exactly what this device saved.</string> + <string name="backup_review_was_named">was “%1$s”</string> + <string name="backup_review_words_and_hashtags">Words & hashtags</string> + <string name="backup_type_app_settings">synced app settings</string> + <string name="backup_type_app_settings_explainer">Amethyst settings synced across your devices. They are encrypted, so only their presence can be compared.</string> + <string name="backup_type_blocked_relays">blocked relay list</string> + <string name="backup_type_blocked_relays_explainer">Relays this app never connects to.</string> + <string name="backup_type_bolt12_offers">BOLT12 offers</string> + <string name="backup_type_bolt12_offers_explainer">The lightning offers others use to pay you.</string> + <string name="backup_type_cashu_wallet">Cashu wallet</string> + <string name="backup_type_cashu_wallet_explainer">Your ecash wallet settings and mints. They are encrypted, so only their presence can be compared.</string> + <string name="backup_type_communities">community list</string> + <string name="backup_type_communities_explainer">The communities you joined.</string> + <string name="backup_type_concord_communities">Concord community list</string> + <string name="backup_type_concord_communities_explainer">The Concord communities you joined. They are encrypted, so only their presence can be compared.</string> + <string name="backup_type_dm_relays">DM relay list</string> + <string name="backup_type_dm_relays_explainer">Where others deliver your private messages.</string> + <string name="backup_type_ephemeral_chats">ephemeral chat list</string> + <string name="backup_type_ephemeral_chats_explainer">The ephemeral chat rooms you joined.</string> + <string name="backup_type_favorite_algo_feeds">favorite feeds list</string> + <string name="backup_type_favorite_algo_feeds_explainer">The algorithmic feeds you saved.</string> + <string name="backup_type_follow_list">follow list</string> + <string name="backup_type_follow_list_explainer">Decides whose posts appear in your home feed.</string> + <string name="backup_type_geohashes">followed locations</string> + <string name="backup_type_geohashes_explainer">Locations whose posts appear in your feeds.</string> + <string name="backup_type_hashtags">followed hashtags</string> + <string name="backup_type_hashtags_explainer">Hashtags whose posts appear in your feeds.</string> + <string name="backup_type_indexer_relays">indexer relay list</string> + <string name="backup_type_indexer_relays_explainer">The relays used to find other people's profiles and relay lists.</string> + <string name="backup_type_key_package_relays">group-chat key relays</string> + <string name="backup_type_key_package_relays_explainer">Where others find the keys to add you to encrypted group chats.</string> + <string name="backup_type_mute_list">mute list</string> + <string name="backup_type_mute_list_explainer">Hides people, words, hashtags and threads from everything you see.</string> + <string name="backup_type_nutzap_info">nutzap receiving settings</string> + <string name="backup_type_nutzap_info_explainer">The mints and key others use to send you nutzaps.</string> + <string name="backup_type_other">list</string> + <string name="backup_type_other_explainer">A list saved on this device.</string> + <string name="backup_type_outbox_inbox_relays">public relay list</string> + <string name="backup_type_outbox_inbox_relays_explainer">Tells everyone where to find your posts and where to send you replies and mentions.</string> + <string name="backup_type_payment_targets">payment addresses</string> + <string name="backup_type_payment_targets_explainer">The ways other people can pay you.</string> + <string name="backup_type_private_outbox_relays">private relay list</string> + <string name="backup_type_private_outbox_relays_explainer">Where your drafts and private data are stored.</string> + <string name="backup_type_profile">profile</string> + <string name="backup_type_profile_explainer">Your name, picture, bio and payment addresses, as everyone sees them.</string> + <string name="backup_type_public_chats">public chat list</string> + <string name="backup_type_public_chats_explainer">The public chats you joined.</string> + <string name="backup_type_relay_feeds">favorite relay feeds</string> + <string name="backup_type_relay_feeds_explainer">The relays you browse as feeds.</string> + <string name="backup_type_relay_groups">group list</string> + <string name="backup_type_relay_groups_explainer">The relay-based groups you joined.</string> + <string name="backup_type_search_relays">search relay list</string> + <string name="backup_type_search_relays_explainer">The relays used when you search.</string> + <string name="backup_type_trust_providers">trust provider list</string> + <string name="backup_type_trust_providers_explainer">The services you trust to rank and verify people for you.</string> + <string name="backup_type_trusted_relays">trusted relay list</string> + <string name="backup_type_trusted_relays_explainer">Relays you trust to connect to without asking.</string> + <string name="cordn_action_delete">Delete</string> + <string name="cordn_action_edit">Edit</string> + <string name="cordn_action_editing">Editing your message</string> + <string name="cordn_action_go_to_message">Go to message</string> + <string name="cordn_action_pin">Pin</string> + <string name="cordn_action_reply">Reply</string> + <string name="cordn_action_unpin">Unpin</string> + <string name="cordn_admin_action_failed">That change could not be made.</string> + <string name="cordn_admin_coordinator_silent">The coordinator did not answer, so nothing was changed. Try again in a moment.</string> + <string name="cordn_admin_no_key_package">%1$s has not published a key package to this coordinator, so they cannot be added yet. Ask them to open cordn on this coordinator first.</string> + <string name="cordn_admin_no_self_remove">You cannot remove yourself from a cordn group.</string> + <string name="cordn_admin_no_welcome">%1$s could not be given a way into the group, so the add was abandoned.</string> + <string name="cordn_admin_not_a_member">%1$s is not in this group.</string> + <string name="cordn_admin_someone">That person</string> + <string name="cordn_admin_wrong_key_package">This coordinator served a key package that belongs to someone else, so %1$s was not added. Nothing was changed.</string> + <string name="cordn_backup_contents_body">Your group encryption keys, your key packages, and every message in those groups. Anyone who opens it can read the conversations. The passphrase is the only thing protecting it once it leaves Amethyst.</string> + <string name="cordn_backup_contents_title">What is in the file</string> + <string name="cordn_backup_explainer">Your nsec cannot bring cordn groups back. A cordn group lives as encryption state on this device and an ordered stream on a coordinator that cannot read it — lose the device without a backup and the group is gone, including everything already said in it.</string> + <string name="cordn_backup_export">Export</string> + <string name="cordn_backup_exported">Backup saved.</string> + <string name="cordn_backup_failed">That did not work. Check the passphrase and the file.</string> + <string name="cordn_backup_passphrase">Passphrase</string> + <string name="cordn_backup_restore">Restore</string> + <string name="cordn_backup_restore_body">Restoring replaces the cordn groups on this device with the ones in the file. It is for a phone that has taken over from another — not a way to use the same groups on two devices at once.</string> + <string name="cordn_backup_restore_confirm_body">Every cordn group now on this device will be removed and replaced by the ones in the file.\n\nDo not restore onto a second phone while the first is still in use. Both would hold the same group keys, and once both have sent a message the group breaks for everyone — silently, and with no way back.</string> + <string name="cordn_backup_restore_confirm_title">Replace this device's cordn groups?</string> + <string name="cordn_backup_restore_title">Restore</string> + <string name="cordn_backup_restored">Restored.</string> + <string name="cordn_chat_empty_description">Anything sent here is encrypted for this group. The coordinator relays it without being able to read it.</string> + <string name="cordn_chat_empty_title">No messages yet</string> + <string name="cordn_chat_unread_divider">New messages</string> + <string name="cordn_chat_yesterday">Yesterday</string> + <string name="cordn_composer_hint">Message</string> + <string name="cordn_coordinators_add_action">Add</string> + <string name="cordn_coordinators_add_failed">Could not open that coordinator.</string> + <string name="cordn_coordinators_copy_key">Copy the coordinator's npub</string> + <string name="cordn_coordinators_discover_action">Look for coordinators</string> + <string name="cordn_coordinators_discover_add">Add</string> + <string name="cordn_coordinators_discover_explainer">Reads announcements your relays already carry. No coordinator is contacted, so none of them learns you looked.</string> + <string name="cordn_coordinators_discover_failed">Could not read announcements</string> + <string name="cordn_coordinators_discover_none">Nobody is announcing on your relays.</string> + <string name="cordn_coordinators_discover_seen">Announced %1$s ago</string> + <string name="cordn_coordinators_discover_seen_on">Last announced %1$s</string> + <string name="cordn_coordinators_discover_unheard">Nothing found, and %1$d of your relays did not answer.</string> + <string name="cordn_coordinators_explainer">A coordinator orders one group's messages and is the only thing that can. Losing it loses the ordering, and a second one does not mirror the first — so these are named things you choose, not interchangeable relays.</string> + <string name="cordn_coordinators_health_down">Not answering — %1$d calls in a row have failed. Groups on it are not syncing.</string> + <string name="cordn_coordinators_health_ok">Answering.</string> + <string name="cordn_coordinators_health_retrying">A call failed; retrying.</string> + <string name="cordn_coordinators_health_unknown">Nothing asked of it yet.</string> + <string name="cordn_coordinators_hide_older">Hide the ones that stopped announcing</string> + <string name="cordn_coordinators_identify">Ask who it is</string> + <string name="cordn_coordinators_key">Coordinator key</string> + <string name="cordn_coordinators_label">A name for it (yours, optional)</string> + <string name="cordn_coordinators_label_note">This name is yours and stays on this device. A coordinator cannot prove a name, so it is never asked for one.</string> + <string name="cordn_coordinators_more">More actions</string> + <string name="cordn_coordinators_none">No coordinators yet.</string> + <string name="cordn_coordinators_purge">Purge</string> + <string name="cordn_coordinators_purge_body">Removing keeps the groups on this device, so adding the coordinator back brings them with it. Purging deletes them: the group keys, the read positions and your key packages all go, and those groups can only be re-entered by a fresh invitation.\n\nThe coordinator is not told and keeps whatever it already had.</string> + <string name="cordn_coordinators_purge_title">Purge this coordinator?</string> + <string name="cordn_coordinators_relays">%1$s</string> + <string name="cordn_coordinators_relays_label">Answers on</string> + <string name="cordn_coordinators_relays_more">%1$s +%2$d more</string> + <string name="cordn_coordinators_remove">Remove</string> + <string name="cordn_coordinators_rename">Rename</string> + <string name="cordn_coordinators_server">Says it is: %1$s</string> + <string name="cordn_coordinators_server_claim">A coordinator's name and version are strings it chose. Its public key is the only thing that identifies it.</string> + <string name="cordn_coordinators_server_silent">Answered, but named nothing.</string> + <string name="cordn_coordinators_show_all">Show all %1$d</string> + <string name="cordn_coordinators_show_fewer">Show fewer</string> + <string name="cordn_coordinators_show_older">Show %1$d that stopped announcing</string> + <string name="cordn_coordinators_stale_note">These have not announced in over a month. Creating a group on one that has gone away will fail.</string> + <string name="cordn_create_action">Create group</string> + <plurals name="cordn_create_action_invite"> + <item quantity="one">Create and invite %1$d person</item> + <item quantity="other">Create and invite %1$d people</item> + </plurals> + <string name="cordn_create_add_member">Add someone</string> + <string name="cordn_create_admin">Admin</string> + <plurals name="cordn_create_admin_count"> + <item quantity="one">%1$d person can add and remove</item> + <item quantity="other">%1$d people can add and remove</item> + </plurals> + <string name="cordn_create_admin_only_me">Only I manage this group</string> + <string name="cordn_create_admin_only_me_on">Only you can invite, remove members or change the group's details. You can hand that to someone else later.</string> + <string name="cordn_create_coordinator">Coordinator</string> + <string name="cordn_create_coordinator_change">Change</string> + <string name="cordn_create_coordinator_discover">Find coordinators</string> + <string name="cordn_create_coordinator_hide">Done</string> + <string name="cordn_create_coordinator_new">Another coordinator</string> + <string name="cordn_create_coordinator_pubkey">Coordinator public key (hex or npub)</string> + <string name="cordn_create_coordinator_relays">Relays it answers on, one per line</string> + <string name="cordn_create_coverage_all">Reaches all %1$d of your people</string> + <string name="cordn_create_coverage_partial">Reaches %1$d of %2$d — the rest would be left out</string> + <string name="cordn_create_coverage_unknown">Not checked — picking it will ask</string> + <string name="cordn_create_description">Description (optional)</string> + <string name="cordn_create_egalitarian">Everyone can add and remove</string> + <string name="cordn_create_egalitarian_note">Everyone in the group can invite, remove members and change its details. Any member can name admins later, which ends that.</string> + <string name="cordn_create_explainer">A cordn group is ordered by one coordinator you choose. Creating the group happens on this device — the coordinator only learns the group exists once you invite someone or send a message.</string> + <string name="cordn_create_failed">The group could not be created.</string> + <string name="cordn_create_name">Group name</string> + <string name="cordn_create_no_coordinator">No coordinator chosen</string> + <string name="cordn_create_outcome_explainer">Each invitation is a Welcome left on the coordinator, so they can fail one at a time. Nobody has been notified: they see it next time they open their app.</string> + <string name="cordn_create_outcome_failed">The coordinator refused the invitation</string> + <string name="cordn_create_outcome_link">Send a link</string> + <string name="cordn_create_outcome_no_welcome">Added to the group, but the invitation did not reach them — try again from the group screen</string> + <string name="cordn_create_outcome_open">Open the chat</string> + <string name="cordn_create_outcome_skipped">No key here, so no Welcome could be left</string> + <string name="cordn_create_outcome_title">The group exists</string> + <string name="cordn_create_outcome_waiting">Waiting for them</string> + <string name="cordn_create_people_done">Done</string> + <string name="cordn_create_people_empty">Nobody added yet. A group can be created empty and invitations sent later, but naming people now is what shows which coordinator can reach them.</string> + <string name="cordn_create_people_none">Nobody yet — tap to add people</string> + <string name="cordn_create_people_note">Naming people now is what tells us which coordinator can reach them. Each one gets a Welcome left on that coordinator, which they pick up next time they open their app.</string> + <plurals name="cordn_create_reach_count"> + <item quantity="one">Reachable on %1$d coordinator</item> + <item quantity="other">Reachable on %1$d coordinators</item> + </plurals> + <string name="cordn_create_reach_none">No key on any coordinator you use</string> + <string name="cordn_create_reach_unknown">Not checked yet</string> + <string name="cordn_create_step_admins">Who can add and remove</string> + <string name="cordn_create_step_name">Name it</string> + <string name="cordn_create_step_people">Who is in it</string> + <string name="cordn_create_step_where">Where it lives</string> + <string name="cordn_create_title">New Cordn group</string> + <plurals name="cordn_create_unreachable_count"> + <item quantity="one">%1$d person cannot be reached by any coordinator you use</item> + <item quantity="other">%1$d people cannot be reached by any coordinator you use</item> + </plurals> + <string name="cordn_create_you">You, always an admin</string> + <string name="cordn_delete_confirm_body">Everyone in the group will see this message as withdrawn, and Amethyst will stop showing its text. The coordinator still holds the encrypted copy it was sent as, and anyone who already read it has already read it.</string> + <string name="cordn_delete_confirm_title">Withdraw message</string> + <string name="cordn_delivery_accepted">Accepted by the coordinator</string> + <string name="cordn_exposure_close">Got it</string> + <string name="cordn_exposure_open">What this coordinator can see</string> + <string name="cordn_group_info">Group info</string> + <string name="cordn_group_unavailable">This group is not available. Its coordinator may have been removed.</string> + <string name="cordn_group_unavailable_detail">Add a coordinator in Settings to start using cordn groups.</string> + <plurals name="cordn_groups_count"> + <item quantity="one">%1$d group</item> + <item quantity="other">%1$d groups</item> + </plurals> + <string name="cordn_groups_down_note">Nothing is arriving from these groups. Amethyst keeps retrying.</string> + <string name="cordn_groups_manage">Coordinators</string> + <string name="cordn_groups_none">No cordn groups yet</string> + <string name="cordn_groups_none_detail">A cordn group lives on one coordinator that holds its membership and passes messages along, without ever seeing what anyone writes.</string> + <string name="cordn_groups_start">Start one</string> + <string name="cordn_info_add_member">Add someone</string> + <string name="cordn_info_add_member_none">Nobody found by that name.</string> + <string name="cordn_info_add_member_note">They can only be added if they published a key package to this coordinator.</string> + <string name="cordn_info_add_member_placeholder">Name, npub or name@domain</string> + <string name="cordn_info_admin_badge">Admin</string> + <plurals name="cordn_info_admins_kept"> + <item quantity="one">The group's %1$d admin stays as it is.</item> + <item quantity="other">The group's %1$d admins stay as they are.</item> + </plurals> + <string name="cordn_info_coordinator">Coordinator</string> + <string name="cordn_info_coordinator_key">Coordinator key</string> + <string name="cordn_info_coordinator_nprofile">Coordinator link</string> + <string name="cordn_info_copy_nprofile">Copy the coordinator's nprofile</string> + <string name="cordn_info_edit_details">Edit details</string> + <string name="cordn_info_egalitarian">This group has no admins: everyone can add, remove and rename, permanently.</string> + <string name="cordn_info_epoch">Epoch</string> + <string name="cordn_info_gid">Group id</string> + <string name="cordn_info_has_key_package">Can be added on this coordinator</string> + <string name="cordn_info_members">Members</string> + <string name="cordn_info_remove_confirm_body">%1$s stops receiving messages from here. They keep what they have already read, and this cannot be undone — they would have to be invited again.</string> + <string name="cordn_info_remove_confirm_title">Remove from group?</string> + <string name="cordn_info_remove_member">Remove</string> + <string name="cordn_info_save">Save</string> + <string name="cordn_info_technical">Technical details</string> + <string name="cordn_invitations_accept">Join</string> + <string name="cordn_invitations_decline">Decline</string> + <string name="cordn_invitations_decline_warning">Declining is permanent. Getting back in means being invited again.</string> + <string name="cordn_invitations_explainer">Checked when you open this screen, and at no other time — asking a coordinator whether anyone invited you tells it you are here, so Amethyst does not do it in the background.</string> + <string name="cordn_invitations_load_failed">Could not read invitations.</string> + <plurals name="cordn_invitations_members"> + <item quantity="one">%1$d person is already in this group</item> + <item quantity="other">%1$d people are already in this group</item> + </plurals> + <string name="cordn_invitations_members_more">+%1$d more</string> + <string name="cordn_invitations_no_coordinators">You have no coordinator open. cordn invitations arrive through a coordinator, so there is nowhere to look yet.</string> + <string name="cordn_invitations_none">No invitations waiting.</string> + <string name="cordn_invitations_refresh">Check again</string> + <string name="cordn_invitations_skipped">An invitation was left for another device</string> + <string name="cordn_invitations_title">Cordn invitations</string> + <string name="cordn_invitations_unreachable">%1$s did not answer</string> + <string name="cordn_invitations_via">Through %1$s</string> + <string name="cordn_keypackages_disclosure">Publishing signs a record under your own account key. That coordinator then knows this account exists and is invitable — permanently, and whether or not anyone invites you.</string> + <string name="cordn_keypackages_explainer">Others can only add you to a cordn group by taking a key package you published to that coordinator. cordn has no other place to keep one, so with none published nobody can invite you — and nothing tells them why.</string> + <string name="cordn_keypackages_failed">Could not read the key packages.</string> + <string name="cordn_keypackages_last_resort_no">No last-resort package. Once the single-use ones run out, invitations fail.</string> + <string name="cordn_keypackages_last_resort_yes">A last-resort package is published, so an invitation can still be made once the single-use ones run out.</string> + <plurals name="cordn_keypackages_orphans"> + <item quantity="one">%1$d package this device cannot open</item> + <item quantity="other">%1$d packages this device cannot open</item> + </plurals> + <string name="cordn_keypackages_orphans_body">The coordinator will hand these to anyone inviting you, but the private half is not on this device — it belongs to another install. If that install is gone, an invitation using one of these produces a welcome nobody can ever open.</string> + <string name="cordn_keypackages_publish">Publish one</string> + <string name="cordn_keypackages_publish_last_resort">Publish last-resort</string> + <plurals name="cordn_keypackages_summary"> + <item quantity="one">%1$d single-use package available.</item> + <item quantity="other">%1$d single-use packages available.</item> + </plurals> + <string name="cordn_keypackages_topup_note">Once you have published here, Amethyst quietly replaces packages as they are used up. It never publishes the first one for you.</string> + <string name="cordn_keypackages_withdraw_all">Withdraw all</string> + <string name="cordn_keypackages_withdraw_orphans">Withdraw those</string> + <string name="cordn_link_request">Ask to join this group</string> + <string name="cordn_link_request_disclosure">This publishes a key package under your own account key and tells the coordinator you want into this group — whether or not anyone answers.</string> + <string name="cordn_link_request_failed">Could not ask to join.</string> + <string name="cordn_link_request_sent">Asked. A member of the group has to add you; the invitation will show up under cordn invitations.</string> + <string name="cordn_link_scan">Scan</string> + <string name="cordn_media_download_failed">Could not open that file.</string> + <string name="cordn_media_no_server">No media server is set for this account, so there is nowhere to put the file. Pick one in Settings.</string> + <string name="cordn_media_unreadable">That file could not be read.</string> + <string name="cordn_media_upload_failed">Could not send that file.</string> + <plurals name="cordn_member_count"> + <item quantity="one">%1$d member</item> + <item quantity="other">%1$d members</item> + </plurals> + <string name="cordn_message_deleted">Message deleted</string> + <string name="cordn_message_details_coordinator">Coordinator</string> + <string name="cordn_message_details_cursor">Cursor</string> + <string name="cordn_message_details_sent_at">Sent</string> + <string name="cordn_message_details_title">Message details</string> + <string name="cordn_message_edited">edited</string> + <string name="cordn_pinned_by">Pinned by %1$s</string> + <plurals name="cordn_pinned_count"> + <item quantity="one">%1$d pinned message in this group</item> + <item quantity="other">%1$d pinned messages in this group</item> + </plurals> + <string name="cordn_pinned_next">Next pinned message</string> + <string name="cordn_pinned_previous">Previous pinned message</string> + <string name="cordn_pinned_show_all">Show all pinned messages</string> + <string name="cordn_pinned_title">Pinned messages</string> + <string name="cordn_reactions_title">Reactions</string> + <string name="cordn_requests_accept">Add</string> + <string name="cordn_requests_admin_only">Only an admin can accept. In a group with no admins, that is everyone.</string> + <string name="cordn_requests_check">Check for requests</string> + <string name="cordn_requests_decline">Dismiss</string> + <string name="cordn_requests_failed">Could not read the requests.</string> + <string name="cordn_requests_none">Nobody is waiting to join.</string> + <string name="cordn_requests_title">Requests to join</string> + <string name="cordn_send">Send</string> + <string name="cordn_send_failed">That did not send.</string> + <string name="cordn_send_no_session">This group's coordinator is not open, so nothing can be sent yet.</string> + <string name="cordn_share_copied">Copied</string> + <string name="cordn_share_copy">Copy link</string> + <string name="cordn_share_explainer">Anyone with this link can ask to join. An admin still has to let them in.</string> + <string name="cordn_share_title">Share this group</string> + <string name="cordn_voice_play">Play voice note</string> + <string name="cordn_voice_record">Record a voice note</string> + <string name="cordn_voice_stop">Stop and send</string> </resources> From 9b26de4515fab797300d7ee84d26a09613cbf992 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sat, 26 Sep 2026 21:13:14 +0000 Subject: [PATCH 48/50] refactor(commonsUI): move the remaining movable layouts, feed helpers and link components Most of the sweep's "Batch 4" had already landed in other PRs; this moves what was left that had no real blocker. - NewItemsBubble -> commonsUI components. - ChatHeaderLayout, LeftPictureLayout, listItem/SlimListItemLayout -> commonsUI layouts. Their @Previews use app drawables, TimeAgo and TextCount, so the previews stay in the app as *Preview.kt files. - WatchScrollToTop (list/grid/pager) -> commonsUI feeds; the CardFeedContentState overload stays in the app. The app's StickToTopOnPrepend was a copy of the commons one and is deleted; the commons one now collects with collectAsStateWithLifecycle, as the app copy did. - ChannelFeedState, ChannelFeedContentState -> commons feeds (headless). checkNotInMainThread() becomes LocalCache.appHost.assertNotMainThread(), which Android wires to the same check. - ClickableBuzzInviteLink, ClickableConcordInviteLink, ClickableRelayGroupLink, ClickableRelayUrl, OutlinedThinPaddingTextField -> commonsUI components. - The app's ClickableEmail/ClickablePhone are deleted in favour of the commons ones, which open mailto:/tel: through LocalUriHandler rather than ACTION_SEND/ACTION_DIAL intents. The commons ClickablePhone is new. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UjQQN9CgWHVtNCSWqnKoqY --- .../amethyst/ui/actions/EditPostView.kt | 2 +- .../ui/components/AmethystRichText.kt | 6 + .../amethyst/ui/components/ClickableEmail.kt | 60 ------ .../ui/components/ConcordInviteCard.kt | 1 + .../amethyst/ui/components/RelayGroupCard.kt | 1 + .../amethyst/ui/components/RichTextViewer.kt | 6 + .../amethyst/ui/feeds/FeedContentStateView.kt | 2 + .../amethyst/ui/feeds/WatchScrollToTop.kt | 204 ------------------ ...erLayout.kt => ChatHeaderLayoutPreview.kt} | 53 +---- ...eLayout.kt => LeftPictureLayoutPreview.kt} | 53 +---- .../layouts/listItem/SlimListItemPreview.kt | 132 ++++++++++++ .../amethyst/ui/note/UserCompose.kt | 2 +- .../ui/note/creators/notify/AudienceSheet.kt | 2 +- .../userSuggestions/ShowUserSuggestionList.kt | 2 +- .../note/creators/zapsplits/ForwardZapTo.kt | 2 +- .../amethyst/ui/screen/FeedView.kt | 4 +- .../amethyst/ui/screen/UserFeedView.kt | 2 +- .../loggedIn/AccountFeedContentStates.kt | 2 +- .../calendars/CalendarCollectionsView.kt | 2 +- .../loggedIn/calendars/CalendarFeedView.kt | 2 +- .../chats/rooms/ChatroomHeaderCompose.kt | 9 +- .../RenderPublicChatChannelThumb.kt | 2 +- .../nip72Communities/CommunityCard.kt | 2 +- .../loggedIn/discover/nip90DVMs/DVMCard.kt | 2 +- .../browse/BrowseEmojiSetsScreen.kt | 4 +- .../ui/screen/loggedIn/home/HomeScreen.kt | 4 +- .../loggedIn/notifications/CardFeedView.kt | 2 +- .../notifications/NotificationScreen.kt | 1 + .../profile/newthreads/TabNotesNewThreads.kt | 4 +- .../loggedIn/profile/zaps/ZapNoteCompose.kt | 2 +- .../relays/nip86/RelayManagementScreen.kt | 2 +- .../commons}/feeds/ChannelFeedContentState.kt | 14 +- .../commons}/feeds/ChannelFeedState.kt | 3 +- .../ui/components/ClickableBuzzInviteLink.kt | 2 +- .../components/ClickableConcordInviteLink.kt | 2 +- .../commons/ui/components/ClickableLinks.kt | 24 +++ .../ui/components/ClickableRelayGroupLink.kt | 2 +- .../ui/components/ClickableRelayUrl.kt | 2 +- .../commons/ui/components/NewItemsBubble.kt | 29 +-- .../OutlinedThinPaddingTextField.kt | 2 +- .../commons/ui/feeds/StickToTopOnPrepend.kt | 17 +- .../commons/ui/feeds/WatchScrollToTop.kt | 75 +++++++ .../commons/ui/layouts/ChatHeaderLayout.kt | 78 +++++++ .../commons/ui/layouts/LeftPictureLayout.kt | 77 +++++++ .../ui/layouts/listItem/SlimListItemLayout.kt | 126 +---------- 45 files changed, 471 insertions(+), 556 deletions(-) delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableEmail.kt rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/{ChatHeaderLayout.kt => ChatHeaderLayoutPreview.kt} (77%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/{LeftPictureLayout.kt => LeftPictureLayoutPreview.kt} (72%) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/listItem/SlimListItemPreview.kt rename {amethyst/src/main/java/com/vitorpamplona/amethyst/ui => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons}/feeds/ChannelFeedContentState.kt (92%) rename {amethyst/src/main/java/com/vitorpamplona/amethyst/ui => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons}/feeds/ChannelFeedState.kt (93%) rename {amethyst/src/main/java/com/vitorpamplona/amethyst => commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons}/ui/components/ClickableBuzzInviteLink.kt (98%) rename {amethyst/src/main/java/com/vitorpamplona/amethyst => commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons}/ui/components/ClickableConcordInviteLink.kt (98%) rename {amethyst/src/main/java/com/vitorpamplona/amethyst => commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons}/ui/components/ClickableRelayGroupLink.kt (98%) rename {amethyst/src/main/java/com/vitorpamplona/amethyst => commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons}/ui/components/ClickableRelayUrl.kt (97%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickablePhone.kt => commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/NewItemsBubble.kt (63%) rename {amethyst/src/main/java/com/vitorpamplona/amethyst => commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons}/ui/components/OutlinedThinPaddingTextField.kt (99%) create mode 100644 commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/WatchScrollToTop.kt create mode 100644 commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/ChatHeaderLayout.kt create mode 100644 commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/LeftPictureLayout.kt rename {amethyst/src/main/java/com/vitorpamplona/amethyst => commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons}/ui/layouts/listItem/SlimListItemLayout.kt (81%) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt index 5db9dcbbd4..f7f574dbd6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt @@ -78,6 +78,7 @@ import com.vitorpamplona.amethyst.commons.resources.lightning_create_and_add_inv import com.vitorpamplona.amethyst.commons.resources.lightning_invoice import com.vitorpamplona.amethyst.commons.resources.what_s_on_your_mind import com.vitorpamplona.amethyst.commons.richtext.RichTextParser +import com.vitorpamplona.amethyst.commons.ui.components.OutlinedThinPaddingTextField import com.vitorpamplona.amethyst.commons.ui.insets.imePaddingSafe import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.PostingTopBar @@ -98,7 +99,6 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.components.BechLink import com.vitorpamplona.amethyst.ui.components.LoadUrlPreview -import com.vitorpamplona.amethyst.ui.components.OutlinedThinPaddingTextField import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.note.creators.invoice.InvoiceRequest import com.vitorpamplona.amethyst.ui.note.creators.uploads.ImageVideoDescription diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/AmethystRichText.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/AmethystRichText.kt index 2f8261e466..176ba467b5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/AmethystRichText.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/AmethystRichText.kt @@ -52,6 +52,12 @@ import com.vitorpamplona.amethyst.commons.richtext.RichTextViewerState import com.vitorpamplona.amethyst.commons.richtext.SecretEmoji import com.vitorpamplona.amethyst.commons.richtext.Segment import com.vitorpamplona.amethyst.commons.richtext.WithdrawSegment +import com.vitorpamplona.amethyst.commons.ui.components.ClickableBuzzInviteLink +import com.vitorpamplona.amethyst.commons.ui.components.ClickableConcordInviteLink +import com.vitorpamplona.amethyst.commons.ui.components.ClickableEmail +import com.vitorpamplona.amethyst.commons.ui.components.ClickablePhone +import com.vitorpamplona.amethyst.commons.ui.components.ClickableRelayGroupLink +import com.vitorpamplona.amethyst.commons.ui.components.ClickableRelayUrl import com.vitorpamplona.amethyst.commons.ui.components.NowhereLinkCard import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.richtext.LocalRichTextInteractions 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 deleted file mode 100644 index 6e25f4be6b..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableEmail.kt +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.ui.components - -import android.content.ActivityNotFoundException -import android.content.Context -import android.content.Intent -import androidx.compose.runtime.Composable -import androidx.compose.ui.platform.LocalContext -import com.vitorpamplona.amethyst.commons.ui.components.ClickableTextPrimary -import kotlinx.coroutines.CancellationException - -@Composable -fun ClickableEmail(email: String) { - val stripped = email.replaceFirst("mailto:", "") - val context = LocalContext.current - - ClickableTextPrimary( - text = stripped, - onClick = { runCatching { context.sendMail(stripped) } }, - ) -} - -fun Context.sendMail( - to: String, - subject: String? = null, -) { - try { - val intent = Intent(Intent.ACTION_SEND) - intent.type = "vnd.android.cursor.item/email" // or "message/rfc822" - intent.putExtra(Intent.EXTRA_EMAIL, arrayOf(to)) - if (subject != null) { - intent.putExtra(Intent.EXTRA_SUBJECT, subject) - } - startActivity(intent) - } catch (e: ActivityNotFoundException) { - // TODO: Handle case where no email app is available - } catch (t: Throwable) { - if (t is CancellationException) throw t - // TODO: Handle potential other type of exceptions - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ConcordInviteCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ConcordInviteCard.kt index 8935486076..714fc0af02 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ConcordInviteCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ConcordInviteCard.kt @@ -49,6 +49,7 @@ import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.concord_home_title import com.vitorpamplona.amethyst.commons.resources.concord_invite_card_join import com.vitorpamplona.amethyst.commons.resources.concord_invite_card_subtitle +import com.vitorpamplona.amethyst.commons.ui.components.ClickableConcordInviteLink import com.vitorpamplona.amethyst.commons.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.screen.LocalDisplaySettings diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RelayGroupCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RelayGroupCard.kt index cda380757e..e2a636f8c7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RelayGroupCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RelayGroupCard.kt @@ -53,6 +53,7 @@ import com.vitorpamplona.amethyst.commons.resources.relay_group_badge_invite_onl import com.vitorpamplona.amethyst.commons.resources.relay_group_badge_private import com.vitorpamplona.amethyst.commons.resources.relay_group_member_count import com.vitorpamplona.amethyst.commons.resources.relay_group_open +import com.vitorpamplona.amethyst.commons.ui.components.ClickableRelayGroupLink import com.vitorpamplona.amethyst.commons.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.pluralStringRes 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 ede5174be2..bfa2899272 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 @@ -102,6 +102,12 @@ import com.vitorpamplona.amethyst.commons.richtext.Segment import com.vitorpamplona.amethyst.commons.richtext.VideoSegment import com.vitorpamplona.amethyst.commons.richtext.WithdrawSegment import com.vitorpamplona.amethyst.commons.ui.components.AnimatedBorderTextCornerRadius +import com.vitorpamplona.amethyst.commons.ui.components.ClickableBuzzInviteLink +import com.vitorpamplona.amethyst.commons.ui.components.ClickableConcordInviteLink +import com.vitorpamplona.amethyst.commons.ui.components.ClickableEmail +import com.vitorpamplona.amethyst.commons.ui.components.ClickablePhone +import com.vitorpamplona.amethyst.commons.ui.components.ClickableRelayGroupLink +import com.vitorpamplona.amethyst.commons.ui.components.ClickableRelayUrl import com.vitorpamplona.amethyst.commons.ui.components.ClickableTextPrimary import com.vitorpamplona.amethyst.commons.ui.components.CrossfadeIfEnabled import com.vitorpamplona.amethyst.commons.ui.components.NowhereLinkCard diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedContentStateView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedContentStateView.kt index ce29dc0daf..85dc9c6610 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedContentStateView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedContentStateView.kt @@ -35,6 +35,8 @@ import com.vitorpamplona.amethyst.commons.ui.feeds.FeedEmpty import com.vitorpamplona.amethyst.commons.ui.feeds.FeedError import com.vitorpamplona.amethyst.commons.ui.feeds.LoadingFeed import com.vitorpamplona.amethyst.commons.ui.feeds.RefresheableBox +import com.vitorpamplona.amethyst.commons.ui.feeds.StickToTopOnPrepend +import com.vitorpamplona.amethyst.commons.ui.feeds.WatchScrollToTop import com.vitorpamplona.amethyst.commons.ui.feeds.rememberForeverLazyGridState import com.vitorpamplona.amethyst.commons.ui.feeds.rememberForeverLazyListState import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/WatchScrollToTop.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/WatchScrollToTop.kt index b9580ea8ba..d0d7daa8c5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/WatchScrollToTop.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/WatchScrollToTop.kt @@ -21,52 +21,11 @@ package com.vitorpamplona.amethyst.ui.feeds import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.grid.LazyGridState -import androidx.compose.foundation.pager.PagerState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.runtime.snapshotFlow import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.commons.feeds.FeedContentState -import com.vitorpamplona.amethyst.commons.feeds.FeedState import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.CardFeedContentState -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.map - -@Composable -fun WatchScrollToTop( - feedContentState: FeedContentState, - listState: LazyListState, -) { - val scrollToTop by feedContentState.scrollToTop.collectAsStateWithLifecycle() - - LaunchedEffect(scrollToTop) { - if (scrollToTop > 0 && feedContentState.scrollToTopPending) { - listState.scrollToItem(index = 0) - feedContentState.sentToTop() - } - } -} - -@Composable -fun WatchScrollToTop( - feedContentState: FeedContentState, - listState: LazyGridState, -) { - val scrollToTop by feedContentState.scrollToTop.collectAsStateWithLifecycle() - - LaunchedEffect(scrollToTop) { - if (scrollToTop > 0 && feedContentState.scrollToTopPending) { - listState.scrollToItem(index = 0) - feedContentState.sentToTop() - } - } -} @Composable fun WatchScrollToTop( @@ -82,166 +41,3 @@ fun WatchScrollToTop( } } } - -@Composable -fun WatchScrollToTop( - videoFeedContentState: FeedContentState, - pagerState: PagerState, -) { - val scrollToTop by videoFeedContentState.scrollToTop.collectAsStateWithLifecycle() - - LaunchedEffect(scrollToTop) { - if (scrollToTop > 0 && videoFeedContentState.scrollToTopPending) { - pagerState.scrollToPage(page = 0) - videoFeedContentState.sentToTop() - } - } -} - -/** - * Keeps the user pinned to index 0 when new items prepend to a feed, but - * only if they were already at the very top right before the update. - * - * Why this is non-trivial: every feed uses stable `key = item.idHex` in - * its lazy list, which makes Compose preserve the visual anchor across - * data changes. When N items prepend, the user's previously-visible - * top item is still on screen but its index is now N — so - * `firstVisibleItemIndex` shifts from 0 to N without any user gesture. - * A naive `if (firstVisibleItemIndex <= 1) scrollToItem(0)` check inside - * `LaunchedEffect(items.firstOrNull())` therefore fails as soon as more - * than one item arrives in the same batch. - * - * The trick: track "was at top" continuously via [snapshotFlow], but - * only flip it from true → false when [LazyListState.isScrollInProgress] - * is true (i.e. the user is actively scrolling). Compose's keyed-item - * shift after a data update does not set that flag — only real touch - * gestures and `animate*` calls do — so data-driven index shifts can - * never poison the cached value. When [firstItemKey] changes (head of - * the list moved), if the cached value is still true, snap back to 0 - * with an instant (non-animated) scroll so the prepend appears as - * in-place growth rather than a visible jump-then-scroll. - * - * Most callers should not invoke this directly: [SaveableFeedContentState], - * [SaveableGridFeedContentState], and the analogous wrappers in - * `ui/screen/FeedView.kt` already apply auto-stick to every feed they - * own. Invoke the explicit overload only when the listState is - * constructed outside one of those wrappers, or when the key that - * should trigger the snap is not the default `items.list[0].idHex` - * (e.g. notifications, chats, or feeds keyed on something other than a - * Note's hex id). - */ -@Composable -fun StickToTopOnPrepend( - listState: LazyListState, - firstItemKey: Any?, -) { - stickToTopOnPrepend( - stateKey = listState, - firstItemKey = firstItemKey, - initialAtTop = { - listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0 - }, - sampler = { - snapshotFlow { - listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0 - } - }, - isScrollInProgress = { listState.isScrollInProgress }, - firstVisibleItemIndex = { listState.firstVisibleItemIndex }, - scrollToTop = { listState.scrollToItem(0) }, - ) -} - -@Composable -fun StickToTopOnPrepend( - gridState: LazyGridState, - firstItemKey: Any?, -) { - stickToTopOnPrepend( - stateKey = gridState, - firstItemKey = firstItemKey, - initialAtTop = { - gridState.firstVisibleItemIndex == 0 && gridState.firstVisibleItemScrollOffset == 0 - }, - sampler = { - snapshotFlow { - gridState.firstVisibleItemIndex == 0 && gridState.firstVisibleItemScrollOffset == 0 - } - }, - isScrollInProgress = { gridState.isScrollInProgress }, - firstVisibleItemIndex = { gridState.firstVisibleItemIndex }, - scrollToTop = { gridState.scrollToItem(0) }, - ) -} - -/** - * Auto-stick wired straight to a [FeedContentState]: derives the head - * key from `feedContent → Loaded.feed → list.firstOrNull()?.idHex` so - * callers don't have to collect the inner feed flow themselves. Used - * by the Saveable* wrappers; suitable for any Note-keyed feed. - */ -@Composable -fun StickToTopOnPrepend( - feedContentState: FeedContentState, - listState: LazyListState, -) { - StickToTopOnPrepend(listState, rememberFirstItemIdHex(feedContentState)) -} - -@Composable -fun StickToTopOnPrepend( - feedContentState: FeedContentState, - gridState: LazyGridState, -) { - StickToTopOnPrepend(gridState, rememberFirstItemIdHex(feedContentState)) -} - -@OptIn(ExperimentalCoroutinesApi::class) -@Composable -private fun rememberFirstItemIdHex(feedContentState: FeedContentState): String? { - val flow = - remember(feedContentState) { - feedContentState.feedContent.flatMapLatest { state -> - when (state) { - is FeedState.Loaded -> state.feed.map { it.list.firstOrNull()?.idHex } - else -> flowOf(null) - } - } - } - val key by flow.collectAsStateWithLifecycle(initialValue = null) - return key -} - -@Composable -private fun stickToTopOnPrepend( - stateKey: Any, - firstItemKey: Any?, - initialAtTop: () -> Boolean, - sampler: () -> Flow<Boolean>, - isScrollInProgress: () -> Boolean, - firstVisibleItemIndex: () -> Int, - scrollToTop: suspend () -> Unit, -) { - // Plain holder instead of mutableStateOf — we only read this inside - // effects, never in composition, so we don't need snapshot tracking. - // Seed from the actual restored scroll position: when the user returns - // to a feed via rememberForeverLazyListState, the saved offset is - // already in place, and a hardcoded `true` would mis-snap them to 0. - val wasAtTop = remember(stateKey) { booleanArrayOf(initialAtTop()) } - - LaunchedEffect(stateKey) { - sampler().collect { atTop -> - if (atTop) { - wasAtTop[0] = true - } else if (isScrollInProgress()) { - wasAtTop[0] = false - } - } - } - - LaunchedEffect(firstItemKey) { - if (firstItemKey != null && wasAtTop[0] && firstVisibleItemIndex() > 0) { - scrollToTop() - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ChatHeaderLayout.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ChatHeaderLayoutPreview.kt similarity index 77% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ChatHeaderLayout.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ChatHeaderLayoutPreview.kt index 5abf8308cf..1f49f5ac52 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ChatHeaderLayout.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ChatHeaderLayoutPreview.kt @@ -20,16 +20,10 @@ */ package com.vitorpamplona.amethyst.ui.layouts -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image -import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.ListItem import androidx.compose.material3.Text @@ -38,20 +32,19 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.profile_banner +import com.vitorpamplona.amethyst.commons.ui.components.NewItemsBubble +import com.vitorpamplona.amethyst.commons.ui.layouts.ChatHeaderLayout +import com.vitorpamplona.amethyst.commons.ui.layouts.listItem.SlimListItem import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness -import com.vitorpamplona.amethyst.commons.ui.theme.DoubleHorzSpacer import com.vitorpamplona.amethyst.commons.ui.theme.Height4dpModifier import com.vitorpamplona.amethyst.commons.ui.theme.Size55Modifier import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn -import com.vitorpamplona.amethyst.ui.layouts.listItem.SlimListItem import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo import com.vitorpamplona.amethyst.ui.painterRes -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.NewItemsBubble import com.vitorpamplona.quartz.utils.TimeUtils private const val PREVIEW_AUTHOR = "This is my author" @@ -136,43 +129,3 @@ fun ChannelNamePreview() { } } } - -@OptIn(ExperimentalFoundationApi::class) -@Composable -fun ChatHeaderLayout( - channelPicture: @Composable () -> Unit, - firstRow: @Composable RowScope.() -> Unit, - secondRow: @Composable RowScope.() -> Unit, - onClick: () -> Unit, - onLongClick: (() -> Unit)? = null, -) { - Row( - modifier = - Modifier - .combinedClickable(onClick = onClick, onLongClick = onLongClick) - .padding(10.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Box(Size55Modifier) { channelPicture() } - - Spacer(modifier = DoubleHorzSpacer) - - Column( - modifier = Modifier.fillMaxWidth(), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - firstRow() - } - - Spacer(modifier = Height4dpModifier) - - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - secondRow() - } - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/LeftPictureLayout.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/LeftPictureLayoutPreview.kt similarity index 72% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/LeftPictureLayout.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/LeftPictureLayoutPreview.kt index 167bbc4a51..2f26c2e73e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/LeftPictureLayout.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/LeftPictureLayoutPreview.kt @@ -21,18 +21,11 @@ package com.vitorpamplona.amethyst.ui.layouts import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale @@ -43,10 +36,10 @@ import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.profile_banner +import com.vitorpamplona.amethyst.commons.ui.layouts.LeftPictureLayout import com.vitorpamplona.amethyst.commons.ui.note.LikeIcon import com.vitorpamplona.amethyst.commons.ui.note.ZappedIcon import com.vitorpamplona.amethyst.commons.ui.stringRes -import com.vitorpamplona.amethyst.commons.ui.theme.DoubleHorzSpacer import com.vitorpamplona.amethyst.commons.ui.theme.QuoteBorder import com.vitorpamplona.amethyst.commons.ui.theme.Size16Modifier import com.vitorpamplona.amethyst.commons.ui.theme.Size20Modifier @@ -104,47 +97,3 @@ fun LeftPictureLayoutPreviewCard() { onBottomRow = { Text("This is my Moderator List") }, ) } - -@Composable -fun LeftPictureLayout( - onImage: @Composable () -> Unit, - onTitleRow: @Composable RowScope.() -> Unit, - onDescription: @Composable () -> Unit, - onBottomRow: @Composable RowScope.() -> Unit, - imageFraction: Float = 0.25f, -) { - Row(Modifier.aspectRatio(ratio = 1 / imageFraction)) { - Column( - modifier = Modifier.fillMaxWidth(imageFraction).aspectRatio(ratio = 1f), - ) { - onImage() - } - - Spacer(modifier = DoubleHorzSpacer) - - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.SpaceBetween, - ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - onTitleRow() - } - - Row( - modifier = Modifier.fillMaxWidth().weight(1f), - ) { - onDescription() - } - - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - onBottomRow() - } - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/listItem/SlimListItemPreview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/listItem/SlimListItemPreview.kt new file mode 100644 index 0000000000..f4529fc097 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/listItem/SlimListItemPreview.kt @@ -0,0 +1,132 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.layouts.listItem + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.ListItem +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment.Companion.CenterVertically +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.tooling.preview.Preview +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.profile_banner +import com.vitorpamplona.amethyst.commons.ui.components.NewItemsBubble +import com.vitorpamplona.amethyst.commons.ui.layouts.ChatHeaderLayout +import com.vitorpamplona.amethyst.commons.ui.layouts.listItem.SlimListItem +import com.vitorpamplona.amethyst.commons.ui.stringRes +import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.commons.ui.theme.Height4dpModifier +import com.vitorpamplona.amethyst.commons.ui.theme.Size55Modifier +import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn +import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo +import com.vitorpamplona.amethyst.ui.painterRes +import com.vitorpamplona.quartz.utils.TimeUtils + +private const val PREVIEW_AUTHOR = "This is my author" +private const val PREVIEW_MESSAGE = "This is a message from this person" + +@Composable +@Preview +fun ChannelNamePreview() { + ThemeComparisonColumn { + Column { + ChatHeaderLayout( + channelPicture = { + Image( + painter = painterRes(R.drawable.github, 1), + contentDescription = stringRes(id = Res.string.profile_banner), + contentScale = ContentScale.FillWidth, + ) + }, + firstRow = { + Text(PREVIEW_AUTHOR, Modifier.weight(1f)) + TimeAgo(TimeUtils.now()) + }, + secondRow = { + Text(PREVIEW_MESSAGE, Modifier.weight(1f)) + NewItemsBubble() + }, + onClick = {}, + ) + + HorizontalDivider(thickness = DividerThickness) + + SlimListItem( + headlineContent = { + Row(verticalAlignment = CenterVertically) { + Text(PREVIEW_AUTHOR, Modifier.weight(1f)) + TimeAgo(TimeUtils.now()) + } + }, + supportingContent = { + Row(verticalAlignment = CenterVertically) { + Text(PREVIEW_MESSAGE, Modifier.weight(1f)) + Spacer(modifier = Height4dpModifier) + NewItemsBubble() + } + }, + leadingContent = { + Image( + painter = painterRes(R.drawable.github, 2), + contentDescription = stringRes(id = Res.string.profile_banner), + contentScale = ContentScale.FillWidth, + modifier = Size55Modifier, + ) + }, + ) + + HorizontalDivider(thickness = DividerThickness) + + ListItem( + headlineContent = { + Row(verticalAlignment = CenterVertically) { + Text(PREVIEW_AUTHOR, Modifier.weight(1f)) + TimeAgo(TimeUtils.now()) + } + }, + supportingContent = { + Row(verticalAlignment = CenterVertically) { + Text(PREVIEW_MESSAGE, Modifier.weight(1f)) + Spacer(modifier = Height4dpModifier) + NewItemsBubble() + } + }, + leadingContent = { + Image( + painter = painterRes(R.drawable.github, 2), + contentDescription = stringRes(id = Res.string.profile_banner), + contentScale = ContentScale.FillWidth, + modifier = Size55Modifier, + ) + }, + ) + + HorizontalDivider(thickness = DividerThickness) + } + } +} 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 46771e5f29..f002b59bef 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 @@ -43,6 +43,7 @@ import com.vitorpamplona.amethyst.commons.resources.follow import com.vitorpamplona.amethyst.commons.resources.login_with_a_private_key_to_be_able_to_follow import com.vitorpamplona.amethyst.commons.resources.login_with_a_private_key_to_be_able_to_unfollow import com.vitorpamplona.amethyst.commons.resources.read_only_user +import com.vitorpamplona.amethyst.commons.ui.layouts.listItem.SlimListItem import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.note.ShowUserButton import com.vitorpamplona.amethyst.commons.ui.theme.Size55dp @@ -51,7 +52,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.observeAccountIsHiddenUser import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserAboutMe import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserIsFollowing -import com.vitorpamplona.amethyst.ui.layouts.listItem.SlimListItem import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/AudienceSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/AudienceSheet.kt index bb78a2b1b1..e2aedac116 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/AudienceSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/AudienceSheet.kt @@ -88,6 +88,7 @@ import com.vitorpamplona.amethyst.commons.resources.discover_follows import com.vitorpamplona.amethyst.commons.resources.follow_sets import com.vitorpamplona.amethyst.commons.resources.num_selected import com.vitorpamplona.amethyst.commons.resources.select_all +import com.vitorpamplona.amethyst.commons.ui.components.OutlinedThinPaddingTextField import com.vitorpamplona.amethyst.commons.ui.pluralStringRes import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness @@ -95,7 +96,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.Size24dp import com.vitorpamplona.amethyst.commons.ui.theme.grayText import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText import com.vitorpamplona.amethyst.commons.ui.theme.warningColor -import com.vitorpamplona.amethyst.ui.components.OutlinedThinPaddingTextField import com.vitorpamplona.amethyst.ui.note.BaseUserPicture import com.vitorpamplona.amethyst.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/ShowUserSuggestionList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/ShowUserSuggestionList.kt index b13c5e1386..ee8e180270 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/ShowUserSuggestionList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/ShowUserSuggestionList.kt @@ -53,6 +53,7 @@ import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.Nip05State import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.user_suggestion_in_this_chat +import com.vitorpamplona.amethyst.commons.ui.layouts.listItem.SlimListItem import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness import com.vitorpamplona.amethyst.commons.ui.theme.Font14SP @@ -60,7 +61,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.NIP05IconSize import com.vitorpamplona.amethyst.commons.ui.theme.Size55dp import com.vitorpamplona.amethyst.commons.ui.theme.nip05 import com.vitorpamplona.amethyst.service.relayClient.searchCommand.UserSearchDataSourceSubscription -import com.vitorpamplona.amethyst.ui.layouts.listItem.SlimListItem import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture import com.vitorpamplona.amethyst.ui.note.ObserveAndRenderNIP05VerifiedSymbol import com.vitorpamplona.amethyst.ui.note.UsernameDisplay diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapsplits/ForwardZapTo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapsplits/ForwardZapTo.kt index 6554cdcabe..ad2b1c3ea5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapsplits/ForwardZapTo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapsplits/ForwardZapTo.kt @@ -45,6 +45,7 @@ import com.vitorpamplona.amethyst.commons.resources.zap_split_explainer import com.vitorpamplona.amethyst.commons.resources.zap_split_search_and_add_user import com.vitorpamplona.amethyst.commons.resources.zap_split_search_and_add_user_placeholder import com.vitorpamplona.amethyst.commons.resources.zap_split_title +import com.vitorpamplona.amethyst.commons.ui.components.OutlinedThinPaddingTextField import com.vitorpamplona.amethyst.commons.ui.note.creators.zapsplits.IZapField import com.vitorpamplona.amethyst.commons.ui.note.creators.zapsplits.ZapSplitIcon import com.vitorpamplona.amethyst.commons.ui.stringRes @@ -55,7 +56,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.Size55dp import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation -import com.vitorpamplona.amethyst.ui.components.OutlinedThinPaddingTextField import com.vitorpamplona.amethyst.ui.note.BaseUserPicture import com.vitorpamplona.amethyst.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt index 421549ffb6..bcb73b461a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt @@ -35,14 +35,14 @@ import com.vitorpamplona.amethyst.commons.ui.feeds.FeedEmpty import com.vitorpamplona.amethyst.commons.ui.feeds.FeedError import com.vitorpamplona.amethyst.commons.ui.feeds.LoadingFeed import com.vitorpamplona.amethyst.commons.ui.feeds.RefresheableBox +import com.vitorpamplona.amethyst.commons.ui.feeds.StickToTopOnPrepend +import com.vitorpamplona.amethyst.commons.ui.feeds.WatchScrollToTop import com.vitorpamplona.amethyst.commons.ui.feeds.rememberForeverLazyGridState import com.vitorpamplona.amethyst.commons.ui.feeds.rememberForeverLazyListState import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.viewmodels.FeedViewModel import com.vitorpamplona.amethyst.ui.feeds.FeedLoaded import com.vitorpamplona.amethyst.ui.feeds.PrefetchLoadedFeedMedia -import com.vitorpamplona.amethyst.ui.feeds.StickToTopOnPrepend -import com.vitorpamplona.amethyst.ui.feeds.WatchScrollToTop import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @Composable 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 880e12de22..4ed610bed1 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 @@ -35,11 +35,11 @@ import com.vitorpamplona.amethyst.commons.ui.feeds.FeedEmpty import com.vitorpamplona.amethyst.commons.ui.feeds.FeedError import com.vitorpamplona.amethyst.commons.ui.feeds.LoadingFeed import com.vitorpamplona.amethyst.commons.ui.feeds.RefresheableBox +import com.vitorpamplona.amethyst.commons.ui.feeds.StickToTopOnPrepend import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness import com.vitorpamplona.amethyst.commons.ui.theme.FeedPadding -import com.vitorpamplona.amethyst.ui.feeds.StickToTopOnPrepend import com.vitorpamplona.amethyst.ui.note.UserCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt index dd46388f4d..a62a265f18 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt @@ -22,13 +22,13 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn import android.content.ComponentCallbacks2 import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.commons.feeds.ChannelFeedContentState import com.vitorpamplona.amethyst.commons.feeds.FeedContentState import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.service.checkNotInMainThread -import com.vitorpamplona.amethyst.ui.feeds.ChannelFeedContentState import com.vitorpamplona.amethyst.ui.screen.TopNavFilterState import com.vitorpamplona.amethyst.ui.screen.loggedIn.articles.dal.ArticlesFeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.dal.BadgesFeedFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarCollectionsView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarCollectionsView.kt index 644d10c45e..c84bbfbd80 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarCollectionsView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarCollectionsView.kt @@ -64,13 +64,13 @@ import com.vitorpamplona.amethyst.commons.resources.calendar_export_event import com.vitorpamplona.amethyst.commons.resources.calendar_export_share_title import com.vitorpamplona.amethyst.commons.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.commons.ui.feeds.ScrollStateKeys +import com.vitorpamplona.amethyst.commons.ui.feeds.WatchScrollToTop import com.vitorpamplona.amethyst.commons.ui.feeds.rememberForeverLazyListState import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.pluralStringRes import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.FeedPadding -import com.vitorpamplona.amethyst.ui.feeds.WatchScrollToTop import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.UserCardHeader import com.vitorpamplona.quartz.nip01Core.core.Address diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarFeedView.kt index 443c0b0b0d..0d62fc715b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarFeedView.kt @@ -47,11 +47,11 @@ import com.vitorpamplona.amethyst.commons.resources.calendar_empty_feed_title import com.vitorpamplona.amethyst.commons.resources.calendar_section_past import com.vitorpamplona.amethyst.commons.resources.calendar_section_upcoming import com.vitorpamplona.amethyst.commons.ui.feeds.RefresheableBox +import com.vitorpamplona.amethyst.commons.ui.feeds.WatchScrollToTop import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.FeedPadding -import com.vitorpamplona.amethyst.ui.feeds.WatchScrollToTop import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.utils.TimeUtils 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 8b2ba48747..f758b7bdcc 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 @@ -106,7 +106,9 @@ import com.vitorpamplona.amethyst.commons.resources.relay_group_server_label import com.vitorpamplona.amethyst.commons.resources.remove_from_messages import com.vitorpamplona.amethyst.commons.resources.unmute_notifications import com.vitorpamplona.amethyst.commons.resources.unpin_conversation +import com.vitorpamplona.amethyst.commons.ui.components.NewItemsBubble import com.vitorpamplona.amethyst.commons.ui.components.RobohashFallbackAsyncImage +import com.vitorpamplona.amethyst.commons.ui.layouts.ChatHeaderLayout import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.note.HeaderPill import com.vitorpamplona.amethyst.commons.ui.screen.LocalDisplaySettings @@ -118,7 +120,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.Size15Modifier import com.vitorpamplona.amethyst.commons.ui.theme.Size55dp import com.vitorpamplona.amethyst.commons.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.commons.ui.theme.grayText -import com.vitorpamplona.amethyst.commons.ui.theme.newItemBubbleModifier import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText import com.vitorpamplona.amethyst.model.buzz.toMembershipNotice import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo @@ -126,7 +127,6 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observe import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteHasEvent import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderByParentFilterAssemblerSubscription import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserName -import com.vitorpamplona.amethyst.ui.layouts.ChatHeaderLayout import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.note.BlankNote import com.vitorpamplona.amethyst.ui.note.LoadDecryptedContentOrNull @@ -1377,8 +1377,3 @@ private fun TimeAgo(channelLastTime: Long?) { color = MaterialTheme.colorScheme.grayText, ) } - -@Composable -fun NewItemsBubble() { - Box(MaterialTheme.colorScheme.newItemBubbleModifier) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/RenderPublicChatChannelThumb.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/RenderPublicChatChannelThumb.kt index 4623df34fe..fda82b5723 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/RenderPublicChatChannelThumb.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/RenderPublicChatChannelThumb.kt @@ -53,6 +53,7 @@ import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.Mute import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsByProxyTopNavFilter import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.chat_about_topic +import com.vitorpamplona.amethyst.commons.ui.layouts.LeftPictureLayout import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.HalfTopPadding @@ -61,7 +62,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.RowColSpacing import com.vitorpamplona.amethyst.commons.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.commons.ui.theme.grayText import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel -import com.vitorpamplona.amethyst.ui.layouts.LeftPictureLayout import com.vitorpamplona.amethyst.ui.note.DisplayAuthorBanner import com.vitorpamplona.amethyst.ui.note.Gallery import com.vitorpamplona.amethyst.ui.note.LikeReaction diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/CommunityCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/CommunityCard.kt index 0a4dc47515..43dfdebcbe 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/CommunityCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/CommunityCard.kt @@ -55,6 +55,7 @@ import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.Mute import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsByProxyTopNavFilter import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.community_about_topic +import com.vitorpamplona.amethyst.commons.ui.layouts.LeftPictureLayout import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.HalfTopPadding @@ -63,7 +64,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.RowColSpacing import com.vitorpamplona.amethyst.commons.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.commons.ui.theme.grayText import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent -import com.vitorpamplona.amethyst.ui.layouts.LeftPictureLayout import com.vitorpamplona.amethyst.ui.note.DisplayAuthorBanner import com.vitorpamplona.amethyst.ui.note.Gallery import com.vitorpamplona.amethyst.ui.note.LikeReaction diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DVMCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DVMCard.kt index 64b35607fc..89efcd5e61 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DVMCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DVMCard.kt @@ -41,6 +41,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.commons.model.AddressableNote import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.ui.layouts.LeftPictureLayout import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.theme.HalfTopPadding import com.vitorpamplona.amethyst.commons.ui.theme.RowColSpacing5dp @@ -51,7 +52,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.grayText import com.vitorpamplona.amethyst.commons.ui.theme.nip05 import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.components.MyAsyncImage -import com.vitorpamplona.amethyst.ui.layouts.LeftPictureLayout import com.vitorpamplona.amethyst.ui.note.LikeReaction import com.vitorpamplona.amethyst.ui.note.ZapReaction import com.vitorpamplona.amethyst.ui.note.elements.BannerImage diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/BrowseEmojiSetsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/BrowseEmojiSetsScreen.kt index 4aa7c25131..ddcb0b073f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/BrowseEmojiSetsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/BrowseEmojiSetsScreen.kt @@ -46,12 +46,12 @@ import com.vitorpamplona.amethyst.commons.ui.feeds.FeedError import com.vitorpamplona.amethyst.commons.ui.feeds.LoadingFeed import com.vitorpamplona.amethyst.commons.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.commons.ui.feeds.ScrollStateKeys +import com.vitorpamplona.amethyst.commons.ui.feeds.StickToTopOnPrepend import com.vitorpamplona.amethyst.commons.ui.feeds.WatchLifecycleAndUpdateModel +import com.vitorpamplona.amethyst.commons.ui.feeds.WatchScrollToTop import com.vitorpamplona.amethyst.commons.ui.feeds.rememberForeverLazyGridState import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav -import com.vitorpamplona.amethyst.ui.feeds.StickToTopOnPrepend -import com.vitorpamplona.amethyst.ui.feeds.WatchScrollToTop import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel 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 66ebccef97..14b4ff9783 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 @@ -61,6 +61,8 @@ import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.tooling.preview.Preview import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.commons.feeds.ChannelFeedContentState +import com.vitorpamplona.amethyst.commons.feeds.ChannelFeedState import com.vitorpamplona.amethyst.commons.feeds.FeedContentState import com.vitorpamplona.amethyst.commons.feeds.FeedState import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel @@ -96,8 +98,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.commons.ui.theme.TabRowHeight import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonRow import com.vitorpamplona.amethyst.service.location.LocationState -import com.vitorpamplona.amethyst.ui.feeds.ChannelFeedContentState -import com.vitorpamplona.amethyst.ui.feeds.ChannelFeedState import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt index e471e8f3c7..bec6b7c1fa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt @@ -60,6 +60,7 @@ import com.vitorpamplona.amethyst.commons.ui.feeds.LoadingFeed import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachCursor import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachDetailDialog import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachMarkers +import com.vitorpamplona.amethyst.commons.ui.feeds.StickToTopOnPrepend import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.note.CloseIcon @@ -73,7 +74,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.Size10dp import com.vitorpamplona.amethyst.commons.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.commons.ui.theme.imageModifier import com.vitorpamplona.amethyst.logTime -import com.vitorpamplona.amethyst.ui.feeds.StickToTopOnPrepend import com.vitorpamplona.amethyst.ui.note.BadgeCompose import com.vitorpamplona.amethyst.ui.note.MessageSetCompose import com.vitorpamplona.amethyst.ui.note.MultiSetCompose 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 cd772a90de..208c12bb85 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 @@ -40,6 +40,7 @@ import com.vitorpamplona.amethyst.commons.resources.notification_tab_following import com.vitorpamplona.amethyst.commons.ui.feeds.PagerStateKeys import com.vitorpamplona.amethyst.commons.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.commons.ui.feeds.ScrollStateKeys +import com.vitorpamplona.amethyst.commons.ui.feeds.WatchScrollToTop import com.vitorpamplona.amethyst.commons.ui.feeds.rememberForeverLazyListState import com.vitorpamplona.amethyst.commons.ui.feeds.rememberForeverPagerState import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/TabNotesNewThreads.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/TabNotesNewThreads.kt index abed0924d7..d600bd16fd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/TabNotesNewThreads.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/TabNotesNewThreads.kt @@ -40,12 +40,12 @@ import com.vitorpamplona.amethyst.commons.ui.feeds.FeedEmpty import com.vitorpamplona.amethyst.commons.ui.feeds.FeedError import com.vitorpamplona.amethyst.commons.ui.feeds.LoadingFeed import com.vitorpamplona.amethyst.commons.ui.feeds.RefresheableBox +import com.vitorpamplona.amethyst.commons.ui.feeds.StickToTopOnPrepend +import com.vitorpamplona.amethyst.commons.ui.feeds.WatchScrollToTop import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness import com.vitorpamplona.amethyst.commons.ui.theme.FeedPadding import com.vitorpamplona.amethyst.ui.feeds.PrefetchLoadedFeedMedia -import com.vitorpamplona.amethyst.ui.feeds.StickToTopOnPrepend -import com.vitorpamplona.amethyst.ui.feeds.WatchScrollToTop import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.newthreads.dal.UserProfileNewThreadsFeedViewModel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/ZapNoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/ZapNoteCompose.kt index ed93534498..f3d45d8565 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/ZapNoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/ZapNoteCompose.kt @@ -35,12 +35,12 @@ import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.model.navigation.routeFor +import com.vitorpamplona.amethyst.commons.ui.layouts.listItem.SlimListItem import com.vitorpamplona.amethyst.commons.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.theme.BitcoinOrange import com.vitorpamplona.amethyst.commons.ui.theme.Size55dp import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn -import com.vitorpamplona.amethyst.ui.layouts.listItem.SlimListItem import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.note.UserActionOptions import com.vitorpamplona.amethyst.ui.note.UserPicture diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip86/RelayManagementScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip86/RelayManagementScreen.kt index fd0e1f5a7c..0498e1996f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip86/RelayManagementScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip86/RelayManagementScreen.kt @@ -122,6 +122,7 @@ import com.vitorpamplona.amethyst.commons.resources.relay_management_tab_pubkeys import com.vitorpamplona.amethyst.commons.resources.relay_management_tab_settings import com.vitorpamplona.amethyst.commons.resources.relay_management_title import com.vitorpamplona.amethyst.commons.resources.search_and_add_a_user +import com.vitorpamplona.amethyst.commons.ui.layouts.listItem.SlimListItem import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.note.ClearTextIcon import com.vitorpamplona.amethyst.commons.ui.stringRes @@ -133,7 +134,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.SmallBorder import com.vitorpamplona.amethyst.commons.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.commons.ui.theme.nip05 import com.vitorpamplona.amethyst.service.relayClient.searchCommand.UserSearchDataSourceSubscription -import com.vitorpamplona.amethyst.ui.layouts.listItem.SlimListItem import com.vitorpamplona.amethyst.ui.note.AboutDisplay import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture import com.vitorpamplona.amethyst.ui.note.ObserveAndRenderNIP05VerifiedSymbol diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/ChannelFeedContentState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/ChannelFeedContentState.kt similarity index 92% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/ChannelFeedContentState.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/ChannelFeedContentState.kt index 611d27ff59..296893376c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/ChannelFeedContentState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/ChannelFeedContentState.kt @@ -18,26 +18,24 @@ * 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.feeds +package com.vitorpamplona.amethyst.commons.feeds import androidx.compose.runtime.MutableState import androidx.compose.runtime.Stable import androidx.compose.runtime.mutableStateOf -import com.vitorpamplona.amethyst.commons.feeds.AdditiveComplexFeedFilter -import com.vitorpamplona.amethyst.commons.feeds.InvalidatableContent -import com.vitorpamplona.amethyst.commons.feeds.LoadedFeedState import com.vitorpamplona.amethyst.commons.model.Channel import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.service.BundledInsert import com.vitorpamplona.amethyst.commons.service.BundledUpdate -import com.vitorpamplona.amethyst.service.checkNotInMainThread -import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists +import com.vitorpamplona.amethyst.commons.util.equalImmutableLists import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.flattenToSet import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch @@ -75,7 +73,7 @@ class ChannelFeedContentState( } fun refreshSuspended() { - checkNotInMainThread() + LocalCache.appHost.assertNotMainThread() isRefreshing.value = true try { @@ -178,7 +176,7 @@ class ChannelFeedContentState( } fun destroy() { - Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" } + Log.d("Init") { "OnCleared: ${this::class.simpleName}" } bundlerInsert.cancel() bundler.cancel() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/ChannelFeedState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/ChannelFeedState.kt similarity index 93% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/ChannelFeedState.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/ChannelFeedState.kt index be96af6803..b3cb58b1e9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/ChannelFeedState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/ChannelFeedState.kt @@ -18,10 +18,9 @@ * 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.feeds +package com.vitorpamplona.amethyst.commons.feeds import androidx.compose.runtime.Stable -import com.vitorpamplona.amethyst.commons.feeds.LoadedFeedState import com.vitorpamplona.amethyst.commons.model.Channel import kotlinx.coroutines.flow.MutableStateFlow diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableBuzzInviteLink.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ClickableBuzzInviteLink.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableBuzzInviteLink.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ClickableBuzzInviteLink.kt index 7779422d4b..089ff137a3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableBuzzInviteLink.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ClickableBuzzInviteLink.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.ui.components +package com.vitorpamplona.amethyst.commons.ui.components import androidx.compose.foundation.combinedClickable import androidx.compose.material3.MaterialTheme diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableConcordInviteLink.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ClickableConcordInviteLink.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableConcordInviteLink.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ClickableConcordInviteLink.kt index 3212e52163..e18ff60ff8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableConcordInviteLink.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ClickableConcordInviteLink.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.ui.components +package com.vitorpamplona.amethyst.commons.ui.components import androidx.compose.foundation.combinedClickable import androidx.compose.material3.MaterialTheme diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ClickableLinks.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ClickableLinks.kt index beedb404fc..8a48da7f7d 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ClickableLinks.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ClickableLinks.kt @@ -91,3 +91,27 @@ fun ClickableEmail( }, ) } + +/** + * A primary-colored clickable phone number that opens the platform dialer via + * [LocalUriHandler] (`tel:`). Strips a leading `tel:` from the display text. + * [underline] + hover cursor are the Desktop mouse-first affordances. + */ +@Composable +fun ClickablePhone( + number: String, + modifier: Modifier = Modifier, + underline: Boolean = false, +) { + val uriHandler = LocalUriHandler.current + val display = remember(number) { number.removePrefix("tel:") } + Text( + text = display, + color = MaterialTheme.colorScheme.primary, + textDecoration = if (underline) TextDecoration.Underline else null, + modifier = + modifier.pointerHoverIcon(PointerIcon.Hand).clickable { + runCatching { uriHandler.openUri("tel:$display") } + }, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRelayGroupLink.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ClickableRelayGroupLink.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRelayGroupLink.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ClickableRelayGroupLink.kt index d968ab300b..72a1104bed 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRelayGroupLink.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ClickableRelayGroupLink.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.ui.components +package com.vitorpamplona.amethyst.commons.ui.components import androidx.compose.foundation.combinedClickable import androidx.compose.material3.MaterialTheme diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRelayUrl.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ClickableRelayUrl.kt similarity index 97% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRelayUrl.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ClickableRelayUrl.kt index 303fd67251..60e3668e5c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRelayUrl.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ClickableRelayUrl.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.ui.components +package com.vitorpamplona.amethyst.commons.ui.components import androidx.compose.foundation.combinedClickable import androidx.compose.material3.MaterialTheme diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickablePhone.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/NewItemsBubble.kt similarity index 63% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickablePhone.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/NewItemsBubble.kt index 3eb265a4e7..0d7b070240 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickablePhone.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/NewItemsBubble.kt @@ -18,30 +18,15 @@ * 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.commons.ui.components -import android.content.Context -import android.content.Intent -import android.net.Uri +import androidx.compose.foundation.layout.Box +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable -import androidx.compose.ui.platform.LocalContext -import com.vitorpamplona.amethyst.commons.ui.components.ClickableTextPrimary +import com.vitorpamplona.amethyst.commons.ui.theme.newItemBubbleModifier +/** The small dot that marks a row with unread items. */ @Composable -fun ClickablePhone(phone: String) { - val context = LocalContext.current - - ClickableTextPrimary( - text = phone, - onClick = { context.dial(phone) }, - ) -} - -fun Context.dial(phone: String) { - try { - val intent = Intent(Intent.ACTION_DIAL, Uri.fromParts("tel", phone, null)) - startActivity(intent) - } catch (t: Throwable) { - // TODO: Handle potential exceptions - } +fun NewItemsBubble() { + Box(MaterialTheme.colorScheme.newItemBubbleModifier) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/OutlinedThinPaddingTextField.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/OutlinedThinPaddingTextField.kt similarity index 99% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/OutlinedThinPaddingTextField.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/OutlinedThinPaddingTextField.kt index 3ee7f3cc0a..d7e7a537ec 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/OutlinedThinPaddingTextField.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/OutlinedThinPaddingTextField.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.ui.components +package com.vitorpamplona.amethyst.commons.ui.components import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.interaction.MutableInteractionSource diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/StickToTopOnPrepend.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/StickToTopOnPrepend.kt index 9074a494dd..7ba0fb5eed 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/StickToTopOnPrepend.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/StickToTopOnPrepend.kt @@ -24,10 +24,10 @@ import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.grid.LazyGridState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.snapshotFlow +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.commons.feeds.FeedContentState import com.vitorpamplona.amethyst.commons.feeds.FeedState import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -59,11 +59,14 @@ import kotlinx.coroutines.flow.map * with an instant (non-animated) scroll so the prepend appears as * in-place growth rather than a visible jump-then-scroll. * - * Commons port of `amethyst/.../WatchScrollToTop.kt` so Desktop and any - * other multiplatform front-end can use the same auto-stick behavior. - * Uses plain `collectAsState` instead of the Android-only - * `collectAsStateWithLifecycle` — equivalent here because the effect's - * lifecycle is already bound to composition via `LaunchedEffect`. + * Most callers should not invoke this directly: `SaveableFeedContentState`, + * `SaveableGridFeedContentState`, and the analogous wrappers in the app's + * `ui/screen/FeedView.kt` already apply auto-stick to every feed they + * own. Invoke the explicit overload only when the listState is + * constructed outside one of those wrappers, or when the key that + * should trigger the snap is not the default `items.list[0].idHex` + * (e.g. notifications, chats, or feeds keyed on something other than a + * Note's hex id). */ @Composable fun StickToTopOnPrepend( @@ -142,7 +145,7 @@ private fun rememberFirstItemIdHex(feedContentState: FeedContentState): String? } } } - val key by flow.collectAsState(initial = null) + val key by flow.collectAsStateWithLifecycle(initialValue = null) return key } diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/WatchScrollToTop.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/WatchScrollToTop.kt new file mode 100644 index 0000000000..5bd267e8f5 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/WatchScrollToTop.kt @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.feeds + +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.foundation.pager.PagerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.feeds.FeedContentState + +@Composable +fun WatchScrollToTop( + feedContentState: FeedContentState, + listState: LazyListState, +) { + val scrollToTop by feedContentState.scrollToTop.collectAsStateWithLifecycle() + + LaunchedEffect(scrollToTop) { + if (scrollToTop > 0 && feedContentState.scrollToTopPending) { + listState.scrollToItem(index = 0) + feedContentState.sentToTop() + } + } +} + +@Composable +fun WatchScrollToTop( + feedContentState: FeedContentState, + listState: LazyGridState, +) { + val scrollToTop by feedContentState.scrollToTop.collectAsStateWithLifecycle() + + LaunchedEffect(scrollToTop) { + if (scrollToTop > 0 && feedContentState.scrollToTopPending) { + listState.scrollToItem(index = 0) + feedContentState.sentToTop() + } + } +} + +@Composable +fun WatchScrollToTop( + videoFeedContentState: FeedContentState, + pagerState: PagerState, +) { + val scrollToTop by videoFeedContentState.scrollToTop.collectAsStateWithLifecycle() + + LaunchedEffect(scrollToTop) { + if (scrollToTop > 0 && videoFeedContentState.scrollToTopPending) { + pagerState.scrollToPage(page = 0) + videoFeedContentState.sentToTop() + } + } +} diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/ChatHeaderLayout.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/ChatHeaderLayout.kt new file mode 100644 index 0000000000..76fc5b6425 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/ChatHeaderLayout.kt @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.layouts + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.ui.theme.DoubleHorzSpacer +import com.vitorpamplona.amethyst.commons.ui.theme.Height4dpModifier +import com.vitorpamplona.amethyst.commons.ui.theme.Size55Modifier + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun ChatHeaderLayout( + channelPicture: @Composable () -> Unit, + firstRow: @Composable RowScope.() -> Unit, + secondRow: @Composable RowScope.() -> Unit, + onClick: () -> Unit, + onLongClick: (() -> Unit)? = null, +) { + Row( + modifier = + Modifier + .combinedClickable(onClick = onClick, onLongClick = onLongClick) + .padding(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(Size55Modifier) { channelPicture() } + + Spacer(modifier = DoubleHorzSpacer) + + Column( + modifier = Modifier.fillMaxWidth(), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + firstRow() + } + + Spacer(modifier = Height4dpModifier) + + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + secondRow() + } + } + } +} diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/LeftPictureLayout.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/LeftPictureLayout.kt new file mode 100644 index 0000000000..2d7102877c --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/LeftPictureLayout.kt @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.layouts + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import com.vitorpamplona.amethyst.commons.ui.theme.DoubleHorzSpacer + +@Composable +fun LeftPictureLayout( + onImage: @Composable () -> Unit, + onTitleRow: @Composable RowScope.() -> Unit, + onDescription: @Composable () -> Unit, + onBottomRow: @Composable RowScope.() -> Unit, + imageFraction: Float = 0.25f, +) { + Row(Modifier.aspectRatio(ratio = 1 / imageFraction)) { + Column( + modifier = Modifier.fillMaxWidth(imageFraction).aspectRatio(ratio = 1f), + ) { + onImage() + } + + Spacer(modifier = DoubleHorzSpacer) + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.SpaceBetween, + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + onTitleRow() + } + + Row( + modifier = Modifier.fillMaxWidth().weight(1f), + ) { + onDescription() + } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + onBottomRow() + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/listItem/SlimListItemLayout.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/listItem/SlimListItemLayout.kt similarity index 81% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/listItem/SlimListItemLayout.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/listItem/SlimListItemLayout.kt index ed6f0b1a5d..b7ac7505f8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/listItem/SlimListItemLayout.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/listItem/SlimListItemLayout.kt @@ -18,31 +18,22 @@ * 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.layouts.listItem +package com.vitorpamplona.amethyst.commons.ui.layouts.listItem -import androidx.annotation.VisibleForTesting -import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Box -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.material3.HorizontalDivider -import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemColors import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalTextStyle 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.remember import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.FirstBaseline import androidx.compose.ui.layout.IntrinsicMeasurable import androidx.compose.ui.layout.IntrinsicMeasureScope @@ -55,117 +46,20 @@ import androidx.compose.ui.layout.MultiContentMeasurePolicy import androidx.compose.ui.layout.Placeable import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.offset import androidx.compose.ui.unit.sp -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.commons.resources.Res -import com.vitorpamplona.amethyst.commons.resources.profile_banner -import com.vitorpamplona.amethyst.commons.ui.stringRes -import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness -import com.vitorpamplona.amethyst.commons.ui.theme.Height4dpModifier -import com.vitorpamplona.amethyst.commons.ui.theme.Size55Modifier -import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn -import com.vitorpamplona.amethyst.ui.layouts.ChatHeaderLayout -import com.vitorpamplona.amethyst.ui.layouts.listItem.ListTokens.ListItemContainerElevation -import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo -import com.vitorpamplona.amethyst.ui.painterRes -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.NewItemsBubble -import com.vitorpamplona.quartz.utils.TimeUtils +import com.vitorpamplona.amethyst.commons.ui.layouts.listItem.ListTokens.ListItemContainerElevation +import kotlin.jvm.JvmInline import kotlin.math.max /** * This is a copy of Material3's ListItemLayout.kt file, with the only change being the padding change from 16.dp to 10.dp */ -private const val PREVIEW_AUTHOR = "This is my author" -private const val PREVIEW_MESSAGE = "This is a message from this person" - -@Composable -@Preview -fun ChannelNamePreview() { - ThemeComparisonColumn { - Column { - ChatHeaderLayout( - channelPicture = { - Image( - painter = painterRes(R.drawable.github, 1), - contentDescription = stringRes(id = Res.string.profile_banner), - contentScale = ContentScale.FillWidth, - ) - }, - firstRow = { - Text(PREVIEW_AUTHOR, Modifier.weight(1f)) - TimeAgo(TimeUtils.now()) - }, - secondRow = { - Text(PREVIEW_MESSAGE, Modifier.weight(1f)) - NewItemsBubble() - }, - onClick = {}, - ) - - HorizontalDivider(thickness = DividerThickness) - - SlimListItem( - headlineContent = { - Row(verticalAlignment = CenterVertically) { - Text(PREVIEW_AUTHOR, Modifier.weight(1f)) - TimeAgo(TimeUtils.now()) - } - }, - supportingContent = { - Row(verticalAlignment = CenterVertically) { - Text(PREVIEW_MESSAGE, Modifier.weight(1f)) - Spacer(modifier = Height4dpModifier) - NewItemsBubble() - } - }, - leadingContent = { - Image( - painter = painterRes(R.drawable.github, 2), - contentDescription = stringRes(id = Res.string.profile_banner), - contentScale = ContentScale.FillWidth, - modifier = Size55Modifier, - ) - }, - ) - - HorizontalDivider(thickness = DividerThickness) - - ListItem( - headlineContent = { - Row(verticalAlignment = CenterVertically) { - Text(PREVIEW_AUTHOR, Modifier.weight(1f)) - TimeAgo(TimeUtils.now()) - } - }, - supportingContent = { - Row(verticalAlignment = CenterVertically) { - Text(PREVIEW_MESSAGE, Modifier.weight(1f)) - Spacer(modifier = Height4dpModifier) - NewItemsBubble() - } - }, - leadingContent = { - Image( - painter = painterRes(R.drawable.github, 2), - contentDescription = stringRes(id = Res.string.profile_banner), - contentScale = ContentScale.FillWidth, - modifier = Size55Modifier, - ) - }, - ) - - HorizontalDivider(thickness = DividerThickness) - } - } -} - @Composable fun SlimListItem( headlineContent: @Composable () -> Unit, @@ -688,22 +582,22 @@ object ListTokens { // Container related defaults // TODO: Make sure these values stay up to date until replaced with tokens. -@VisibleForTesting internal val ListItemVerticalPadding = 8.dp +internal val ListItemVerticalPadding = 8.dp -@VisibleForTesting internal val ListItemThreeLineVerticalPadding = 12.dp +internal val ListItemThreeLineVerticalPadding = 12.dp -@VisibleForTesting internal val ListItemStartPadding = 10.dp +internal val ListItemStartPadding = 10.dp -@VisibleForTesting internal val ListItemEndPadding = 10.dp +internal val ListItemEndPadding = 10.dp // Icon related defaults. // TODO: Make sure these values stay up to date until replaced with tokens. -@VisibleForTesting internal val LeadingContentEndPadding = Modifier.padding(end = 10.dp) +internal val LeadingContentEndPadding = Modifier.padding(end = 10.dp) // Icon related defaults. // TODO: Make sure these values stay up to date until replaced with tokens. -@VisibleForTesting internal val SupportingContentTopPadding = Modifier.padding(top = 2.dp) +internal val SupportingContentTopPadding = Modifier.padding(top = 2.dp) // Trailing related defaults. // TODO: Make sure these values stay up to date until replaced with tokens. -@VisibleForTesting internal val TrailingContentStartPadding = Modifier.padding(start = 10.dp) +internal val TrailingContentStartPadding = Modifier.padding(start = 10.dp) From f1582b391b060e63515799c2d5bf10005d50f6b8 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sat, 26 Sep 2026 21:13:16 +0000 Subject: [PATCH 49/50] docs(commons): record the Tier 4 unlocks, the strings drift fix and the UI stragglers Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UjQQN9CgWHVtNCSWqnKoqY --- .../2026-08-30-commons-migration-sweep.md | 101 +++++++++++++++++- 1 file changed, 99 insertions(+), 2 deletions(-) diff --git a/commons/plans/2026-08-30-commons-migration-sweep.md b/commons/plans/2026-08-30-commons-migration-sweep.md index 20982ddae2..d1a325d81e 100644 --- a/commons/plans/2026-08-30-commons-migration-sweep.md +++ b/commons/plans/2026-08-30-commons-migration-sweep.md @@ -771,11 +771,11 @@ Options, for the maintainer to pick: ### Next work, in recommended order (needs maintainer go-ahead per item) -- **Tier 4 unlocks** (small, mechanical): `NappletProtocolJson` +- **Tier 4 unlocks** — **done 2026-09-26**, see the last section. (Was: small, mechanical: `NappletProtocolJson` `java.util.Base64` → `kotlin.io.encoding.Base64` (frees `NappletIdentityWatch`); `ScheduledPostStore` Jackson+`java.io.File` → kotlinx-serialization+okio (frees `ScheduledPostWorkGate`). - `LargeSoftCache` stays parked (needs a WeakReference expect/actual). + `LargeSoftCache` stays parked (needs a WeakReference expect/actual).) - **Wave 2: LocalCache move-group** — **part A landed (2026-09-19)**, see the section below. Part B (repoint `desktopApp`, delete `DesktopLocalCache.kt`) is the remaining half. @@ -1302,3 +1302,100 @@ through an `ingest` helper that pins what the cache built for the lifetime of the test instance, the way a screen holds the notes it is showing in the app, and keeps the forced GC in the test that failed so the contract is asserted rather than left to the heap. + +## 2026-09-26 — Tier 4 unlocks, the strings drift, and the UI stragglers + +### Tier 4: two jvmAndroid groups promoted to `commonMain` + +- **Napplet wire protocol.** `NappletProtocolJson`, `NappletIdentityWatch` and + `NappletRequestRouter` moved to `commons/commonMain/…/napplet`. The only + pins were `java.util.Base64` and one `System.currentTimeMillis()`. Base64 is + now `kotlin.io.encoding.Base64`, decoding with `PaddingOption.PRESENT_OPTIONAL` + because `java.util.Base64.getDecoder()` accepted unpadded input and applets + may send it. The clock is `TimeUtils.now()`. +- **Scheduled posts.** `ScheduledPostStore` had already dropped Jackson for + kotlinx-serialization; what pinned it was `java.io.File` and a POSIX + `chmod`. It now takes an okio `Path` plus a `FileSystem` (default + `platformFileSystem`), and a `String` constructor so callers need no okio, + the same shape as `FileSystemNip95BlobStore`. The owner-only permission is a + new `expect fun restrictFileToOwner(path, tag)`: + `Files.setPosixFilePermissions` on jvmAndroid, POSIX `chmod(0600)` on iOS. + The temp-file swap is `FileSystem.atomicMove`, which replaces an existing + target on every platform, so the old "rename, else delete and rename again" + fallback went with it. `ScheduledPostWorkGate`, `ScheduledPostPublisher` and + `ScheduledPostNotifier` came along; none had a JVM pin of its own. + +### The strings drift, fixed + +Between the 2026-09-22 move and today, the cordn UI and the backup-conflict +review added **447 keys** to `amethyst/src/main/res` (`cordn_*`, `backup_*`), +taking it from 190 keys back to 597. All 447 moved with +`tools/strings-migrate/migrate.py` (1,131 elements over 57 locales) and the 18 +files that read them were repointed. The non-mechanical sites: + +- `stringRes(context, …)` inside `suspend` senders (`sendAttachment`, + `sendVoiceNote`) → `loadStringRes(…)`. `adminFailureText` became `suspend`; + its only caller is already in `scope.launch`. +- The backup review kept its labels as `@StringRes Int` (`DiffGroup.label`, + `ReviewRow.*Res`, `eventTypeName()`, the `countTiles`/`moneyHero`/`laneItems` + params) → `StringResource` / `PluralStringResource`. +- `rememberPresentation` resolved labels inside `remember { }` through + `LocalResources`. Compose resources have no non-suspend accessor, so the 16 + labels `presentationOf` writes into item text are resolved in composition into + a map, and the map is the `remember` key. +- `androidx.compose.ui.res.pluralStringResource` → the compose-resources one. + +Six moved keys have no reader today (`cordn_backup_restore_title`, +`cordn_coordinators_relays`, `cordn_create_admin_only_me`, +`cordn_create_coordinator`, `cordn_send`, `cordn_voice_play`). They moved +rather than being deleted; dropping them is the feature owner's call. +`pow_notification_sending` stays in Android `res/`: the PoW foreground service +reads it synchronously. + +The rule is now written down in `.claude/CLAUDE.md` ("Strings"), in +`commonsUI/ARCHITECTURE.md`, and in the `android-expert` and +`find-missing-translations` skills: new strings go in `commonsUI` +composeResources, even for Android-only screens. + +### UI stragglers moved to `commonsUI` + +A survey against `main` found that most of "Batch 4" had already landed in +other PRs (`dde228d2`, `1a563cf2`, `8dd409cb`, `1f1b6dab`): the theme +constants (in `commonsUI/…/theme/Shape.kt`, not the `Sizes.kt` this plan +named), the feed shell, the top bars, `M3ActionDialog`, `NoteComposeLayout`, +`RepostLayout` and the toast queue. This round moved what was left that had no +real blocker: + +| Moved | To | Note | +|---|---|---| +| `NewItemsBubble` (from `ChatroomHeaderCompose.kt`) | `commonsUI/…/ui/components` | | +| `ChatHeaderLayout`, `LeftPictureLayout` | `commonsUI/…/ui/layouts` | Their `@Preview`s use app drawables, `TimeAgo` and `TextCount`, so the previews stay app-side in `*Preview.kt` files | +| `listItem/SlimListItemLayout` | `commonsUI/…/ui/layouts/listItem` | Same preview split; `@VisibleForTesting` dropped (no androidx.annotation in commonMain) | +| `WatchScrollToTop` (list/grid/pager overloads) | `commonsUI/…/ui/feeds` | The `CardFeedContentState` overload stays in the app | +| app `StickToTopOnPrepend` | deleted | It was a copy of the commons one. The commons copy now collects with `collectAsStateWithLifecycle`, as the app copy did | +| `ChannelFeedState`, `ChannelFeedContentState` | `commons/commonMain/…/feeds` | `checkNotInMainThread()` → `LocalCache.appHost.assertNotMainThread()`, which Android wires to the same check | +| `ClickableBuzzInviteLink`, `ClickableConcordInviteLink`, `ClickableRelayGroupLink`, `ClickableRelayUrl`, `OutlinedThinPaddingTextField` | `commonsUI/…/ui/components` | | +| app `ClickableEmail`, `ClickablePhone` | deleted | Android now uses the commons ones (`mailto:` / `tel:` through `LocalUriHandler`) instead of `ACTION_SEND` / `ACTION_DIAL` intents. The commons `ClickablePhone` is new. Desktop still renders phone numbers as plain text, on purpose | + +**Still app-side, with the reason:** + +- `AmethystClickableIcon`: 9 lines around an Android debug action; moving it + only pushes `LocalContext` into its two callers. +- `ScreenLayout`: needs `LocalConfiguration` → `LocalWindowInfo`, and the + window-size-class breakpoints inlined. +- `SlidingCarousel`: needs `animatedViewerChromeInset` hoisted out of the + viewer-chrome file. +- `AudioWaveformReadOnly`: needs two enums from the Android-only waveform + library replaced. +- `FileAttachmentCard`: needs `extractFilename` hoisted. +- `PdfFetcher`: needs the disk cache injected. +- `ClickablePhone` / `ClickableEmail` on Desktop: deliberately unchanged. +- **`AmethystTheme` split** (the pure scheme/typography half to commonsUI; the + `Amethyst.instance` overload and the system-bar `SideEffect` stay). +- **`TimeAgo` split**: the labels, `NowProvider` and the style enum are pure; + the absolute and short formatters need expect/actual over + `android.text.format`. +- **Palette unification**: Desktop's `PlatformColorScheme` and the app palette + differ in primary, secondary, surfaces and outlines, and every shared token + (`placeholderText`, `chatBubbleThem`, …) is frozen from the app palette. This + is a design decision, not a move. From 3d03c82970d3c932c9952d5518b3e4a7bb7b8831 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sat, 26 Sep 2026 22:03:54 +0000 Subject: [PATCH 50/50] fix: audit follow-ups for the commons move - Compose string whitespace, backup labels, store edge cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The one real regression predates this branch: Compose resources draw a value's XML whitespace verbatim, where aapt collapsed it. Values wrapped over indented lines rendered with a leading line break and eight spaces, which turns the account-backup tips' markdown heading into a code block, and Crowdin's stray translator edge spaces (" miejsca zniknęły", Hindi's " दि॰" suffix) showed up. fix_escapes.py now applies aapt's rule to line-break/tab runs and trims an edge space a translation has but its source lacks. The Crowdin workflow already runs it after each sync, and compose_escaping_check.py now fails on raw line breaks. 598 values across 54 files repaired. Also: - rememberPresentation resolves only the labels its diff type writes, instead of all 16 on every recomposition. A test pins the list against presentationOf. - ScheduledPostStore: a failed stat is "no file" (okio posix throws on EACCES where File.exists() did not), symlinked stores still count, no chmod against an injected non-system FileSystem, the cleanup log keeps its throwable. - ClickableEmail percent-encodes '%' in the mailto: URI. - Dropped a misleading WatchScrollToTop import, a stray blank line, and a stale doc path; noted the Base64 pad-bit difference beside the decoder. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UjQQN9CgWHVtNCSWqnKoqY --- .claude/hooks/compose_escaping_check.py | 14 +++ .../backups/BackupConflictPresentation.kt | 29 +++++-- .../chats/cordnGroup/CordnGroupInfoScreen.kt | 1 - .../notifications/NotificationScreen.kt | 1 - .../backups/BackupPresentationLabelsTest.kt | 60 +++++++++++++ .../2026-08-30-commons-migration-sweep.md | 33 +++++++ .../napplet/protocol/NappletProtocolJson.kt | 2 + .../scheduledposts/ScheduledPostStore.kt | 41 +++++++-- .../commons/util/OwnerOnlyFile.ios.kt | 3 +- .../commons/util/OwnerOnlyFile.jvmAndroid.kt | 2 +- .../values-ar-rSA/strings.xml | 62 ++++---------- .../values-bn-rBD/strings.xml | 37 ++------ .../composeResources/values-cs/strings.xml | 34 ++------ .../values-de-rDE/strings.xml | 36 ++------ .../composeResources/values-de/strings.xml | 44 +++------- .../values-el-rGR/strings.xml | 40 ++------- .../values-eo-rUY/strings.xml | 39 ++------- .../composeResources/values-eo/strings.xml | 42 ++------- .../values-es-rES/strings.xml | 39 ++------- .../values-es-rMX/strings.xml | 37 ++------ .../values-es-rUS/strings.xml | 37 ++------ .../composeResources/values-es/strings.xml | 39 ++------- .../values-fa-rIR/strings.xml | 60 ++++--------- .../composeResources/values-fa/strings.xml | 53 +++--------- .../values-fi-rFI/strings.xml | 36 ++------ .../values-fr-rCA/strings.xml | 31 ++----- .../values-fr-rFR/strings.xml | 31 ++----- .../composeResources/values-fr/strings.xml | 31 ++----- .../values-hi-rIN/strings.xml | 43 +++------- .../values-hu-rHU/strings.xml | 48 +++-------- .../values-in-rID/strings.xml | 46 +++------- .../composeResources/values-in/strings.xml | 42 ++------- .../values-it-rIT/strings.xml | 37 ++------ .../values-ja-rJP/strings.xml | 39 ++------- .../composeResources/values-ja/strings.xml | 42 ++------- .../values-ko-rKR/strings.xml | 37 ++------ .../values-lv-rLV/strings.xml | 37 ++------ .../composeResources/values-nl/strings.xml | 17 +--- .../values-pl-rPL/strings.xml | 38 ++------- .../values-pt-rBR/strings.xml | 40 +++------ .../values-pt-rPT/strings.xml | 40 +++------ .../values-ru-rRU/strings.xml | 39 ++------- .../values-ru-rUA/strings.xml | 37 ++------ .../composeResources/values-ru/strings.xml | 41 ++------- .../values-sl-rSI/strings.xml | 85 ++++++------------- .../values-sr-rSP/strings.xml | 37 ++------ .../values-sv-rSE/strings.xml | 38 ++------- .../composeResources/values-sw/strings.xml | 37 ++------ .../values-ta-rIN/strings.xml | 49 +++-------- .../composeResources/values-ta/strings.xml | 52 +++--------- .../values-th-rTH/strings.xml | 26 ++---- .../composeResources/values-th/strings.xml | 26 ++---- .../values-tr-rTR/strings.xml | 39 ++------- .../composeResources/values-tr/strings.xml | 42 ++------- .../values-uk-rUA/strings.xml | 39 ++------- .../composeResources/values-uk/strings.xml | 42 ++------- .../values-uz-rUZ/strings.xml | 37 ++------ .../values-vi-rVN/strings.xml | 37 ++------ .../values-zh-rCN/strings.xml | 39 ++------- .../values-zh-rHK/strings.xml | 41 ++------- .../values-zh-rSG/strings.xml | 39 ++------- .../values-zh-rTW/strings.xml | 31 ++----- .../composeResources/values-zh/strings.xml | 39 ++------- .../composeResources/values/strings.xml | 40 ++------- .../commons/ui/components/ClickableLinks.kt | 3 +- .../plans/2026-06-21-napplet-desktop-host.md | 2 +- tools/strings-migrate/fix_escapes.py | 57 ++++++++++++- 67 files changed, 699 insertions(+), 1715 deletions(-) create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupPresentationLabelsTest.kt diff --git a/.claude/hooks/compose_escaping_check.py b/.claude/hooks/compose_escaping_check.py index cd9b054eef..b5721481ae 100755 --- a/.claude/hooks/compose_escaping_check.py +++ b/.claude/hooks/compose_escaping_check.py @@ -27,6 +27,9 @@ Repair with: idempotent, quote-unwrapping is not, and a second unwrap strips the real display quotes from values like `import_follows_tips`. +Raw line breaks and tabs inside a value are the whitespace half of the same +mismatch: aapt collapsed them, Compose renders them (the same repair fixes them). + Only the Compose catalog is scanned. In an Android res tree the same escaping is correct and must be left alone. """ @@ -41,6 +44,10 @@ CATALOG = "*/src/*/composeResources/values*/strings.xml" # Compose resolves those itself. ANDROID_ESCAPE = re.compile(r"(?<!\\)\\(['\"?@])") TOOLS_ATTR = re.compile(r'tools:[\w.-]+="') +# A value whose text holds a raw line break or tab. aapt collapsed that XML layout to +# one space; Compose draws it, and a markdown value indented by it becomes a code block. +STRING_TEXT = re.compile(r"<(string|item)\b[^>]*>(.*?)</\1>", re.S) +LAYOUT_WS = re.compile(r"[\r\n\t]") def find_violations(root: Path): @@ -52,6 +59,13 @@ def find_violations(root: Path): n = len(TOOLS_ATTR.findall(text)) if n: found[path]["tools: attribute"] += n + n = sum( + 1 + for m in STRING_TEXT.finditer(text) + if "<![CDATA[" not in m.group(2) and LAYOUT_WS.search(m.group(2)) + ) + if n: + found[path]["raw line break/tab in a value"] += n return found diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictPresentation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictPresentation.kt index ff28dfa6c3..0b4bb8af80 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictPresentation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictPresentation.kt @@ -332,15 +332,23 @@ private fun arrow( */ @Composable fun rememberPresentation(diff: EventDiff): DiffPresentation { - // Compose resources resolve only in composition (or suspended), so the few labels - // [presentationOf] writes into item text are resolved here and handed in. The map - // compares by value, so a recomposition with the same locale keeps the cached result. - val labels = PRESENTATION_LABELS.associateWith { stringRes(it) } + // Compose resources resolve only in composition (or suspended), so the labels + // [presentationOf] writes into item text are resolved here and handed in. Only the + // diff types that write labels get any, so the rest pay for no resource reads. The + // map compares by value: a recomposition with the same locale keeps the cached result. + val labels = labelsWrittenBy(diff).associateWith { stringRes(it) } return remember(diff, labels) { presentationOf(diff) { labels.getValue(it) } } } -/** Every label [presentationOf] resolves through its `str` parameter. */ -private val PRESENTATION_LABELS = +/** Every label [presentationOf] resolves through its `str` parameter for [diff]. */ +internal fun labelsWrittenBy(diff: EventDiff): List<StringResource> = + when (diff) { + is MetadataDiff -> PROFILE_FIELD_LABELS + is AdvertisedRelayListDiff -> RELAY_MARKER_LABELS + else -> emptyList() + } + +private val PROFILE_FIELD_LABELS = listOf( Res.string.backup_profile_field_name, Res.string.backup_profile_field_display_name, @@ -355,12 +363,19 @@ private val PRESENTATION_LABELS = Res.string.backup_profile_field_pronouns, Res.string.backup_profile_field_birthday, Res.string.backup_profile_field_bot, + ) + +private val RELAY_MARKER_LABELS = + listOf( Res.string.backup_conflict_relay_read_write, Res.string.backup_conflict_relay_read_only, Res.string.backup_conflict_relay_write_only, ) -/** Turns each event's own diff into typed, labelled groups. [str] resolves the labels. */ +/** + * Turns each event's own diff into typed, labelled groups. [str] resolves the labels; a + * label it asks for must be in [labelsWrittenBy] for that diff type. + */ fun presentationOf( diff: EventDiff, str: (StringResource) -> String, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnGroupInfoScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnGroupInfoScreen.kt index b08e44b341..e134442396 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnGroupInfoScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnGroupInfoScreen.kt @@ -283,7 +283,6 @@ private fun CordnGroupInfo( * roster, and a rename stayed the old name. The runtime's own methods pair * each commit with that refresh. */ - fun runAdmin( who: String? = null, block: suspend (CordnRuntime) -> Unit, 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 208c12bb85..cd772a90de 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 @@ -40,7 +40,6 @@ import com.vitorpamplona.amethyst.commons.resources.notification_tab_following import com.vitorpamplona.amethyst.commons.ui.feeds.PagerStateKeys import com.vitorpamplona.amethyst.commons.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.commons.ui.feeds.ScrollStateKeys -import com.vitorpamplona.amethyst.commons.ui.feeds.WatchScrollToTop import com.vitorpamplona.amethyst.commons.ui.feeds.rememberForeverLazyListState import com.vitorpamplona.amethyst.commons.ui.feeds.rememberForeverPagerState import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupPresentationLabelsTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupPresentationLabelsTest.kt new file mode 100644 index 0000000000..bd28de71ee --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupPresentationLabelsTest.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.backups + +import com.vitorpamplona.quartz.nip01Core.diff.EventDiff +import com.vitorpamplona.quartz.nip01Core.diff.ListDiff +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataDiff +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListDiff +import org.jetbrains.compose.resources.StringResource +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * rememberPresentation resolves labels in composition and hands presentationOf a lookup + * over exactly [labelsWrittenBy]; a label presentationOf asks for that is not in that list + * crashes the review screen. presentationOf asks for every label of a diff type + * regardless of which fields changed, so an empty diff of each type exercises them all. + */ +class BackupPresentationLabelsTest { + private fun <T> empty() = ListDiff<T>(emptyList(), emptyList(), emptyList()) + + private fun asked(diff: EventDiff): Set<StringResource> { + val asked = mutableSetOf<StringResource>() + presentationOf(diff) { + asked.add(it) + "" + } + return asked + } + + @Test + fun metadataLabelsAreAllProvided() { + val diff = MetadataDiff(null, null, null, null, null, null, null, null, null, null, null, null, null, empty(), empty()) + assertEquals(labelsWrittenBy(diff).toSet(), asked(diff)) + } + + @Test + fun relayListLabelsAreAllProvided() { + val diff = AdvertisedRelayListDiff(empty()) + assertEquals(labelsWrittenBy(diff).toSet(), asked(diff)) + } +} diff --git a/commons/plans/2026-08-30-commons-migration-sweep.md b/commons/plans/2026-08-30-commons-migration-sweep.md index d1a325d81e..46de62e8af 100644 --- a/commons/plans/2026-08-30-commons-migration-sweep.md +++ b/commons/plans/2026-08-30-commons-migration-sweep.md @@ -1399,3 +1399,36 @@ real blocker: differ in primary, secondary, surfaces and outlines, and every shared token (`placeholderText`, `chatBubbleThem`, …) is frozen from the app palette. This is a design decision, not a move. + +### Audit of the 2026-09-26 round + +The four commits were reviewed file by file against the originals. They had no +functional bugs. The follow-ups: + +- **Compose resources keep XML whitespace; Android didn't.** This is older than this + round (it dates back to the first string moves), and it is the one real + regression. aapt collapses every whitespace run in an unquoted value and trims + the ends; Compose draws the XML text verbatim. So the values wrapped over + indented lines (`account_backup_tips2_md`, `push_server_install_app_description`, + `couldnt_find_nwc_wallets_description`, the chat explainers, and 349 translated + values) rendered with a leading line break and eight spaces. In the `_md` strings + CommonMark turns that into a code block. Crowdin also exports a translator's stray + edge space, which aapt used to drop, for example ` miejsca zniknęły` and Hindi's + ` दि॰` time suffix. `fix_escapes.py` now applies aapt's rule to runs holding a line + break or tab. In a translation it also trims an edge space its source string lacks, + which keeps deliberate ones like `" and "`. The Crowdin workflow already runs it + after every sync, and `compose_escaping_check.py` now fails on raw line breaks, so + a sync can't bring it back. 598 values across 54 files were repaired. +- `rememberPresentation` read all 16 backup labels on every recomposition, even for + diff types that write none. It now reads only the ones its diff type uses, and + `BackupPresentationLabelsTest` fails if `presentationOf` asks for a label that list + lacks. +- `ScheduledPostStore`: a failed stat now counts as "no file" rather than throwing + (okio's posix metadata throws on EACCES where `File.exists()` returned false). + Symlinked store files still count as present. The chmod is skipped for an injected + non-system `FileSystem`, and the cleanup log keeps its throwable. +- `ClickableEmail` percent-encodes `%` in the `mailto:` URI (RFC 6068). +- Left as-is, by design: Kotlin's Base64 rejects non-zero pad bits (`"SGl="`) that + Java ignored, which no browser encoder produces. `%1$d` arguments now render ASCII + digits in every locale, the same as every other migrated string. + `ChannelFeedContentState` reaches `LocalCache.appHost` for its main-thread check. diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletProtocolJson.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletProtocolJson.kt index af171f6188..c72f1bf825 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletProtocolJson.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletProtocolJson.kt @@ -60,6 +60,8 @@ object NappletProtocolJson { private val json = Json { ignoreUnknownKeys = true } // Accepts unpadded input, as java.util.Base64's decoder did before this moved to commonMain. + // One difference remains: non-zero pad bits ("SGl=") are rejected where Java ignored them. + // Browsers' btoa/readAsDataURL never produce those, so only a hand-crafted payload hits it. private val lenientBase64 = Base64.withPadding(Base64.PaddingOption.PRESENT_OPTIONAL) /** The `type` discriminant of a request envelope, used to build the matching `.result` type. */ diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostStore.kt index ec34e46725..c3eb84304f 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostStore.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostStore.kt @@ -377,7 +377,7 @@ class ScheduledPostStore( // Secure a pre-existing file up front (an older build may have left it at the // 0644 umask default) so it's owner-only even if nothing mutates the store // this session. - if (fileSystem.exists(storageFile)) restrictFileToOwner(storageFile, TAG) + if (storeFileExists()) restrictToOwner(storageFile) var dirty = purgeStale(nowSec()) // Recover claims stranded by a crash-mid-publish so they aren't lost forever. if (recoverStuckClaimsLocked(nowSec())) dirty = true @@ -426,10 +426,12 @@ class ScheduledPostStore( try { storageFile.parent?.let { fileSystem.createDirectories(it) } fileSystem.write(tmp) { writeUtf8(json.encodeToString(ScheduledPostFile(version = 1, posts = snapshot))) } - // Restrict to owner-only BEFORE the rename so the store is never briefly + // Restrict to owner-only BEFORE the rename, so the store file itself is never // world-readable. It holds pre-signed events + the account's pubkey, which - // must not leak to other local users on a shared machine. - restrictFileToOwner(tmp, TAG) + // must not leak to other local users on a shared machine. (The tmp file is + // created at the umask default and narrowed right after its write; the + // directory around it is owner-only on every host that uses this store.) + restrictToOwner(tmp) // Replaces an existing target, including on Windows, where a plain rename // onto an existing file fails. fileSystem.atomicMove(tmp, storageFile) @@ -437,13 +439,38 @@ class ScheduledPostStore( Log.e(TAG, "Failed to persist scheduled posts to $storageFile", e) try { fileSystem.delete(tmp) - } catch (e: Exception) { - Log.w(TAG) { "Failed to clean up temp file $tmp after persist exception: ${e.message}" } + } catch (cleanupError: Exception) { + Log.w(TAG, "Failed to clean up temp file $tmp after persist exception", cleanupError) } } } - private fun hasContent(): Boolean = (fileSystem.metadataOrNull(storageFile)?.size ?: 0L) > 0L + /** + * Permissions are a property of the real disk, so a caller that injects another + * [FileSystem] (an in-memory fake) gets no chmod aimed at a same-named real path. + */ + private fun restrictToOwner(path: Path) { + if (fileSystem === platformFileSystem) restrictFileToOwner(path, TAG) + } + + /** + * The store file's metadata, or null when there is none. A directory is not a store + * file. A symlink counts: okio's metadata does not follow links, so its size is the + * link's, and reading it (which does follow) decides whether there is anything in it. + * A stat that fails (EACCES on iOS, where File.exists() used to answer false) is "no + * file" rather than an exception out of the store. + */ + private fun storeFileMetadata() = + try { + fileSystem.metadataOrNull(storageFile)?.takeIf { it.isRegularFile || it.symlinkTarget != null } + } catch (e: Exception) { + Log.w(TAG, "Could not stat $storageFile", e) + null + } + + private fun storeFileExists(): Boolean = storeFileMetadata() != null + + private fun hasContent(): Boolean = (storeFileMetadata()?.size ?: 0L) > 0L private fun readText(): String = fileSystem.read(storageFile) { readUtf8() } diff --git a/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/util/OwnerOnlyFile.ios.kt b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/util/OwnerOnlyFile.ios.kt index 42b3c0c516..ebd18c664a 100644 --- a/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/util/OwnerOnlyFile.ios.kt +++ b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/util/OwnerOnlyFile.ios.kt @@ -35,6 +35,7 @@ actual fun restrictFileToOwner( tag: String, ) { if (chmod(path.toString(), (S_IRUSR or S_IWUSR).convert()) != 0) { - Log.w(tag) { "Could not restrict permissions on $path: errno $errno" } + val error = errno + Log.w(tag) { "Could not restrict permissions on $path: errno $error" } } } diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/OwnerOnlyFile.jvmAndroid.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/OwnerOnlyFile.jvmAndroid.kt index 5d4d16c3b7..cbcfe50038 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/OwnerOnlyFile.jvmAndroid.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/OwnerOnlyFile.jvmAndroid.kt @@ -37,6 +37,6 @@ actual fun restrictFileToOwner( } catch (_: UnsupportedOperationException) { // Windows: no POSIX permissions; the user profile's NTFS ACLs apply instead. } catch (e: Exception) { - Log.w(tag) { "Could not restrict permissions on $path: ${e.message}" } + Log.w(tag, "Could not restrict permissions on $path", e) } } diff --git a/commonsUI/src/commonMain/composeResources/values-ar-rSA/strings.xml b/commonsUI/src/commonMain/composeResources/values-ar-rSA/strings.xml index c93820fa84..c9b3131eff 100644 --- a/commonsUI/src/commonMain/composeResources/values-ar-rSA/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-ar-rSA/strings.xml @@ -234,7 +234,7 @@ <string name="copy_user_id">نسخ معرف المستخدم</string> <string name="unblock_user">إلغاء حظر عن مستخدم</string> <string name="user_is_blocked_hidden">هذا المستخدم محظور/مكتوم. منشوراته مخفية.</string> - <string name="npub_hex_username">npub, hex, username </string> + <string name="npub_hex_username">npub, hex, username</string> <string name="clear">نظف</string> <string name="app_logo">شعار التطبيق</string> <string name="nsec_npub_hex_private_key">nsec / npub / hex private key</string> @@ -282,15 +282,8 @@ <string name="mark_all_known_as_read">ضع علامة على جميع الرسائل/الاشعارات المعروفة كمقروء</string> <string name="mark_all_new_as_read">تعيين الجديد كمقروء</string> <string name="mark_all_as_read">تعيين الكل كمقروء</string> - <string name="account_backup_tips2_md"> ## النصائح الرئيسية للنسخ الاحتياطي والسلامة - \n\n- يتم تأمين حسابك بمفتاح سري. المفتاح السري هو سلسلة عشوائية طويلة تبدأ بـ **nsec1**. أي شخص لديه مفتاحك السري يمكنه نشر المحتوى باستخدام هويتك. - \n\n- لا تقم بوضع مفتاحك السري في أي موقع أو برنامج لا تثق به. - \n- مطوري Amethyst لن يطلبوا منك **أبدا** المفتاح السري الخاص بك. - \n- حافظ على نسخة احتياطية آمنة لمفتاحك السري لاسترداد الحساب. نوصي باستخدام برنامج لادارة كلمات المرور. - </string> - <string name="account_backup_tips3_md"> للحصول على أمان إضافي، يمكنك تشفير مفتاحك الخاص بكلمة مرور. يبدأ هذا المفتاح المشفر بـ **ncryptsec1** ولا يمكن استخدامه دون كلمة المرور الخاصة بك. - \n\nإذا فقدت كلمة المرور، لن تتمكن من استرداد مفتاحك الخاص. - </string> + <string name="account_backup_tips2_md">## النصائح الرئيسية للنسخ الاحتياطي والسلامة \n\n- يتم تأمين حسابك بمفتاح سري. المفتاح السري هو سلسلة عشوائية طويلة تبدأ بـ **nsec1**. أي شخص لديه مفتاحك السري يمكنه نشر المحتوى باستخدام هويتك. \n\n- لا تقم بوضع مفتاحك السري في أي موقع أو برنامج لا تثق به. \n- مطوري Amethyst لن يطلبوا منك **أبدا** المفتاح السري الخاص بك. \n- حافظ على نسخة احتياطية آمنة لمفتاحك السري لاسترداد الحساب. نوصي باستخدام برنامج لادارة كلمات المرور.</string> + <string name="account_backup_tips3_md">للحصول على أمان إضافي، يمكنك تشفير مفتاحك الخاص بكلمة مرور. يبدأ هذا المفتاح المشفر بـ **ncryptsec1** ولا يمكن استخدامه دون كلمة المرور الخاصة بك. \n\nإذا فقدت كلمة المرور، لن تتمكن من استرداد مفتاحك الخاص.</string> <string name="encrypt_and_copy_my_secret_key">تشفير و نسخ المفتاح الخاص</string> <string name="badge_award_image_for">صورة شارة مكافئة ل %1$s</string> <string name="badge_award_image">"صورة منح الشارة</string> @@ -311,7 +304,7 @@ <string name="quick_action_request_deletion_gallery_alert_body_v2">إزالة هذه الوسائط من معرضك.</string> <string name="quick_action_request_deletion_alert_title">طلب حذفها</string> <string name="quick_action_request_deletion_alert_body">سيطلب Amethyst حذف ملاحظتك من المرحلات المتصل بها حاليا، لا يوجد ضمان لحذفها من هذه المرحلات أو من المرحلات الأخرى التي ربما حفظت فيها.</string> - <string name="quick_action_block_dialog_btn">حظر </string> + <string name="quick_action_block_dialog_btn">حظر</string> <string name="quick_action_delete_dialog_btn">حذف</string> <string name="quick_action_block">حظر</string> <string name="quick_action_report">التبليغ</string> @@ -693,8 +686,7 @@ <string name="add_caption">إضافة شرح توضيحي</string> <string name="add_caption_example">صديقي المميز</string> <string name="content_description">وصف المحتوى/المحتويات</string> - <string name="content_description_example">A blue boat in a white sandy beach at sunset -(قارب أزرق في شاطئ رملي أبيض عند غروب الشمس)</string> + <string name="content_description_example">A blue boat in a white sandy beach at sunset (قارب أزرق في شاطئ رملي أبيض عند غروب الشمس)</string> <string name="ai_suggested_alt_text_hint">اقتراح من الذكاء الاصطناعي، يمكنك تعديله</string> <string name="ai_suggested_alt_text_dismiss">رفض اقتراح الذكاء الاصطناعي</string> <string name="zap_type">نوع الزاب</string> @@ -847,7 +839,7 @@ <string name="relay_filter_since">منذ %1$s</string> <string name="relay_filter_until">حتى %1$s</string> <string name="relay_filter_limit">الحد %1$d</string> - <string name="minimum_prefix">الحد الأدنى لل prefix </string> + <string name="minimum_prefix">الحد الأدنى لل prefix</string> <string name="maximum_event_tags">الحد الأقصى لوسوم الحدث</string> <string name="max_content_length">الحد الأقصى لطول المحتوى</string> <string name="discards_older_than">تُهمَل أقدم من</string> @@ -886,10 +878,8 @@ <string name="followed_tags">العلامات المُتابعة</string> <string name="discover_follows_explainer">هذه قوائم بالمستخدمين التي توصي بها للآخرين. يُسمح فقط بالمستخدمين العامين.</string> <string name="community_approved_posts">المنشورات الموافق عليها</string> - <string name="groups_no_descriptor">هذه المجموعة ليس لديها وصف أو قواعد. - تحدث إلى المالك لإضافة مستخدم جديد</string> - <string name="community_no_descriptor">هذا المجتمع ليس لديه وصف أو قواعد. - تحدث إلى المالك لإضافة مستخدم جديد</string> + <string name="groups_no_descriptor">هذه المجموعة ليس لديها وصف أو قواعد. تحدث إلى المالك لإضافة مستخدم جديد</string> + <string name="community_no_descriptor">هذا المجتمع ليس لديه وصف أو قواعد. تحدث إلى المالك لإضافة مستخدم جديد</string> <string name="user_preferences">تفضيلات المستخدم</string> <string name="settings_search_placeholder">البحث في الإعدادات</string> <string name="settings_search_no_results">لا توجد إعدادات لـ "%1$s"</string> @@ -982,9 +972,7 @@ <string name="push_server_none_explainer">تعطيل الإشعارات</string> <string name="push_server_uses_app_explainer">يتم استخدام هذا التطبيق: %1$s</string> <string name="push_server_install_app">إعدادات الإشعارات</string> - <string name="push_server_install_app_description">لاستخدام الإشعارات حمل أي تطبيق يدعم ال [Unified Push](https://unifiedpush.org/) -مثل [Ntfy](https://ntfy.sh/). -بعد تثبيتك التطبيق قم باختياره من الإعدادات</string> + <string name="push_server_install_app_description">لاستخدام الإشعارات حمل أي تطبيق يدعم ال [Unified Push](https://unifiedpush.org/) مثل [Ntfy](https://ntfy.sh/). بعد تثبيتك التطبيق قم باختياره من الإعدادات</string> <string name="thread_title">الخيط</string> <string name="send_the_seller_a_message">أرسل رسالة إلى البائع</string> <string name="hi_there_is_this_still_available">مرحبًا، هل هذا لا يزال متاحًا؟</string> @@ -1876,7 +1864,7 @@ <string name="biometric_authentication_failed">فشلت المصادقة</string> <string name="biometric_authentication_failed_explainer">فشل التحقق البيومتري في مصادقة مالك هذا الهاتف</string> <string name="biometric_authentication_failed_explainer_with_error">فشل التحقق البيومتري في مصادقة مالك هذا الهاتف. الخطأ: %1$s</string> - <string name="block_only">حظر </string> + <string name="block_only">حظر</string> <string name="blocked_section_explainer">لن يتصل Amethyst أبدًا بهذه الـ relays</string> <string name="blocked_users">المستخدمين المحظورين</string> <string name="bookmark_list_creation_screen_title">قائمة إشارات مرجعية جديدة</string> @@ -1986,8 +1974,7 @@ <string name="cashu_settings_delete_confirm_body">نشر طلب حذف لتوصية kind:38000 الخاصة بك لـ %1$s. ستحذفها الـ relays التي تحترم NIP-09.</string> <string name="cashu_settings_restore_result">تم استرداد %1$s sats عبر %2$s أدلة.</string> <string name="cashu_successful_redemption">تم استلام Cashu</string> - <string name="cashu_successful_redemption_explainer">%1$s sats أرسلت الى محفظتك -(الرسوم: sats %2$s ) </string> + <string name="cashu_successful_redemption_explainer">%1$s sats أرسلت الى محفظتك (الرسوم: sats %2$s )</string> <plurals name="cashu_untrusted_mint_title"> <item quantity="zero">لا أموال محتجزة في mint غير مستخدم</item> <item quantity="one">أموال محتجزة في %1$d mint لا تستخدمه</item> @@ -2060,15 +2047,7 @@ <string name="could_not_fetch_invoice_from_details">تعذّر جلب الفاتورة من %1$s: %2$s</string> <string name="could_not_open_the_compressed_file">تعذّر فتح الملف المضغوط</string> <string name="couldnt_find_nwc_wallets">لم يتم العثور على المحفظة</string> - <string name="couldnt_find_nwc_wallets_description">لم يتمكن Amethyst من العثور على أي تطبيقات محافظ تدعم Nostr Wallet Connect (NWC). -\n\nما يمكنك فعله: -\n\t1. التحقق من دعم NWC: تأكد من أن تطبيق محفظتك المفضل متوافق مع Nostr Wallet Connect. -\n\t2. إنشاء اتصال: انتقل إلى تطبيق محفظتك وأنشئ اتصال NWC جديدًا. -\n\t3. الربط بـ Amethyst: عادةً ما تتيح محفظتك ثلاثة خيارات: -\n\t\t3.1 ربط تلقائي: زر لفتح Amethyst والاتصال به تلقائيًا. إذا كان الأمر كذلك، اضغط على الزر، وافتح Amethyst واضغط على حفظ في الشاشة الجديدة. -\n\t\t3.2 ربط يدوي: عنوان URI للاتصال يمكنك نسخه والضغط على أيقونة اللصق في هذه الشاشة لإدخاله مباشرةً. -\n\t\t3.3 رمز QR: تنشئ المحفظة رمز QR يمكنك قراءته من زر رمز QR في هذه الشاشة. - </string> + <string name="couldnt_find_nwc_wallets_description">لم يتمكن Amethyst من العثور على أي تطبيقات محافظ تدعم Nostr Wallet Connect (NWC). \n\nما يمكنك فعله: \n\t1. التحقق من دعم NWC: تأكد من أن تطبيق محفظتك المفضل متوافق مع Nostr Wallet Connect. \n\t2. إنشاء اتصال: انتقل إلى تطبيق محفظتك وأنشئ اتصال NWC جديدًا. \n\t3. الربط بـ Amethyst: عادةً ما تتيح محفظتك ثلاثة خيارات: \n\t\t3.1 ربط تلقائي: زر لفتح Amethyst والاتصال به تلقائيًا. إذا كان الأمر كذلك، اضغط على الزر، وافتح Amethyst واضغط على حفظ في الشاشة الجديدة. \n\t\t3.2 ربط يدوي: عنوان URI للاتصال يمكنك نسخه والضغط على أيقونة اللصق في هذه الشاشة لإدخاله مباشرةً. \n\t\t3.3 رمز QR: تنشئ المحفظة رمز QR يمكنك قراءته من زر رمز QR في هذه الشاشة.</string> <string name="create">إنشاء</string> <string name="d">يوم</string> <string name="danger_zone">منطقة الخطر</string> @@ -2451,7 +2430,7 @@ <string name="login_with_a_private_key_to_be_able_to_send_zaps">قم بتسجيل الدخول باستخدام مفتاح خاص لتتمكن من إرسال Zaps</string> <string name="login_with_a_private_key_to_be_able_to_show_word">قم بتسجيل الدخول باستخدام مفتاحك السري لتتمكن من إظهار الكلمة أو الجملة</string> <string name="login_with_a_private_key_to_be_able_to_sign_events">أنت تستخدم مفتاحًا عامًا وهو للقراءة فقط. سجّل الدخول بمفتاح خاص لتتمكن من توقيع الأحداث</string> - <string name="login_with_a_private_key_to_be_able_to_unfollow">قم بتسجيل الدخول بمفتاحك الخاص للتتمكن من إلغاء متابعة المستخدمين </string> + <string name="login_with_a_private_key_to_be_able_to_unfollow">قم بتسجيل الدخول بمفتاحك الخاص للتتمكن من إلغاء متابعة المستخدمين</string> <string name="login_with_a_private_key_to_be_able_to_upload">أنت تستخدم مفتاحًا عامًا وهو للقراءة فقط. سجّل الدخول بمفتاح خاص لتتمكن من الرفع</string> <string name="login_with_a_private_key_to_like_posts">تسجيل الدخول باستخدام مفتاح خاص لإبداء الإعجاب بالمنشورات</string> <string name="longs">مقاطع فيديو</string> @@ -2786,8 +2765,7 @@ <string name="preview_card_image_for">معاينة صورة البطاقة لـ %1$s</string> <string name="privacy_options">خيارات الخصوصية</string> <string name="privacy_policy">سياسة الخصوصية</string> - <string name="private_conversation_notification"><استحالة فك تشفير الرسالة الخاصة> -\n\n أنت الآن في محادثة خاصة/مشفرة بين %1$s و %2$s .</string> + <string name="private_conversation_notification"><استحالة فك تشفير الرسالة الخاصة> \n\n أنت الآن في محادثة خاصة/مشفرة بين %1$s و %2$s .</string> <string name="private_inbox_section_explainer">أدخل ما بين 1 و3 relays لتكون صندوقًا واردًا خاصًا. سيستخدم الآخرون هذه الـ relays لإرسال رسائل مباشرة إليك. يجب أن تقبل DM Inbox relays أي رسالة من أي شخص، لكن تسمح لك وحدك بتنزيلها. خيارات جيدة:\n - inbox.nostr.wine (مدفوع)\n - auth.nostr1.com (مجاني)\n - you.nostr1.com (relays شخصية - مدفوعة)</string> <string name="private_inbox_section_explainer_profile">المستخدم يستقبل الرسائل المباشرة على هذه الـ relays</string> <string name="private_message">رسالة خاصة</string> @@ -2807,11 +2785,8 @@ <string name="propose_an_edit">اقتراح تعديل</string> <string name="proxy_section_explainer">الـ relays التجميعية التي يجب أن يستخدمها التطبيق لتنزيل تغذياتك منها، مثل filter.nostr.wine. هذا يستبدل نموذج outbox وسيجعل التطبيق يتصل فقط بالـ relays الموجودة في قوائمك.</string> <string name="public_chat">الدردشة العامة</string> - <string name="public_chat_explainer">المحادثات العامة مرئية للجميع على Nostr ويمكن لأي شخص - المشاركة فيها. إنها مناسبة للمجتمعات المفتوحة حول مواضيع محددة. - يمكن التحكم في الإشراف عبر حذف المنشورات على relay المجموعة</string> - <string name="public_chat_relays_explainer">أدخل من 1 إلى 3 relay تستضيف هذه المجموعة. - تستخدم تطبيقات Nostr هذا الإعداد لمعرفة مكان تنزيل الرسائل وإرسالها.</string> + <string name="public_chat_explainer">المحادثات العامة مرئية للجميع على Nostr ويمكن لأي شخص المشاركة فيها. إنها مناسبة للمجتمعات المفتوحة حول مواضيع محددة. يمكن التحكم في الإشراف عبر حذف المنشورات على relay المجموعة</string> + <string name="public_chat_relays_explainer">أدخل من 1 إلى 3 relay تستضيف هذه المجموعة. تستخدم تطبيقات Nostr هذا الإعداد لمعرفة مكان تنزيل الرسائل وإرسالها.</string> <string name="public_chat_title">بيانات المحادثة العامة</string> <string name="public_chats">المحادثات العامة</string> <string name="public_home_section_explainer">يخزّن هذا النوع من الـ relay جميع محتواك. سيرسل Amethyst منشوراتك إليها وسيستخدم الآخرون هذه الـ relays للعثور على محتواك. أدخل ما بين 1 و3 relays. يمكن أن تكون relays شخصية أو مدفوعة أو عامة.</string> @@ -2833,10 +2808,7 @@ <string name="read_only_user">مستخدم في وضع القراءة فقط</string> <string name="record_a_message_description">اضغط مطوّلًا لتسجيل رسالة</string> <string name="relay_chat">محادثة relay</string> - <string name="relay_chat_explainer">محادثات relay هي مجموعات دردشة يتحكم فيها الـ relay المضيف. - إنها مرئية للجميع على Nostr ويمكن لأي شخص المشاركة فيها. - إنها مناسبة للمجتمعات المفتوحة حول مواضيع محددة. بعض هذه المجموعات مؤقتة - وبالتالي تختفي رسائل الدردشة بمرور الوقت</string> + <string name="relay_chat_explainer">محادثات relay هي مجموعات دردشة يتحكم فيها الـ relay المضيف. إنها مرئية للجميع على Nostr ويمكن لأي شخص المشاركة فيها. إنها مناسبة للمجتمعات المفتوحة حول مواضيع محددة. بعض هذه المجموعات مؤقتة وبالتالي تختفي رسائل الدردشة بمرور الوقت</string> <string name="relay_chat_title">محادثات relay</string> <string name="relay_management_tab_events">الأحداث</string> <string name="relay_management_tab_ips">عناوين IP</string> diff --git a/commonsUI/src/commonMain/composeResources/values-bn-rBD/strings.xml b/commonsUI/src/commonMain/composeResources/values-bn-rBD/strings.xml index e575eee7eb..75664b26ee 100644 --- a/commonsUI/src/commonMain/composeResources/values-bn-rBD/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-bn-rBD/strings.xml @@ -271,15 +271,8 @@ <string name="mark_all_known_as_read">পরিচিত সবগুলোকে পঠিত হিসেবে চিহ্নিত করুন</string> <string name="mark_all_new_as_read">নতুন সবগুলোকে পঠিত হিসেবে চিহ্নিত করুন</string> <string name="mark_all_as_read">সবগুলোকে পঠিত হিসেবে চিহ্নিত করুন</string> - <string name="account_backup_tips2_md"> ## মূল ব্যাকআপ এবং নিরাপত্তা টিপস - \n\n আপনার অ্যাকাউন্ট একটি গোপন কী দ্বারা সুরক্ষিত। মূলটি হল অক্ষরগুলির একটি দীর্ঘ ক্রম যা শুরু হয় **nsec1**. যে কেউ এই গোপন কী অ্যাক্সেস করতে পারে পোস্ট এবং আপনার পরিচয় পরিবর্তন করতে পারেন. - \n\n- আপনি বিশ্বাস করেন না এমন কোনো ওয়েবসাইট বা সফ্টওয়্যারে আপনার গোপন কী **করবেন না**. - \n- অ্যামেথিস্ট ডেভেলপাররা **কখনই** আপনার গোপন কী চাইবে না. - \n- **করুন** অ্যাকাউন্ট পুনরুদ্ধারের জন্য আপনার গোপন কীটির একটি নিরাপদ ব্যাকআপ রাখুন৷. আমরা একটি পাসওয়ার্ড ম্যানেজার ব্যবহার করার পরামর্শ দিই।. - </string> - <string name="account_backup_tips3_md"> অতিরিক্ত নিরাপত্তার জন্য, আপনি একটি পাসওয়ার্ড দিয়ে আপনার কী এনক্রিপ্ট করতে পারেন. এই কী দিয়ে শুরু হয় **ncryptsec1** এবং আপনার পাসওয়ার্ড ছাড়া ব্যবহার করা যাবে না।. - \n\n আপনি যদি আপনার পাসওয়ার্ড হারিয়ে ফেলেন, আপনি আপনার কী পুনরুদ্ধার করতে পারবেন না।. - </string> + <string name="account_backup_tips2_md">## মূল ব্যাকআপ এবং নিরাপত্তা টিপস \n\n আপনার অ্যাকাউন্ট একটি গোপন কী দ্বারা সুরক্ষিত। মূলটি হল অক্ষরগুলির একটি দীর্ঘ ক্রম যা শুরু হয় **nsec1**. যে কেউ এই গোপন কী অ্যাক্সেস করতে পারে পোস্ট এবং আপনার পরিচয় পরিবর্তন করতে পারেন. \n\n- আপনি বিশ্বাস করেন না এমন কোনো ওয়েবসাইট বা সফ্টওয়্যারে আপনার গোপন কী **করবেন না**. \n- অ্যামেথিস্ট ডেভেলপাররা **কখনই** আপনার গোপন কী চাইবে না. \n- **করুন** অ্যাকাউন্ট পুনরুদ্ধারের জন্য আপনার গোপন কীটির একটি নিরাপদ ব্যাকআপ রাখুন৷. আমরা একটি পাসওয়ার্ড ম্যানেজার ব্যবহার করার পরামর্শ দিই।.</string> + <string name="account_backup_tips3_md">অতিরিক্ত নিরাপত্তার জন্য, আপনি একটি পাসওয়ার্ড দিয়ে আপনার কী এনক্রিপ্ট করতে পারেন. এই কী দিয়ে শুরু হয় **ncryptsec1** এবং আপনার পাসওয়ার্ড ছাড়া ব্যবহার করা যাবে না।. \n\n আপনি যদি আপনার পাসওয়ার্ড হারিয়ে ফেলেন, আপনি আপনার কী পুনরুদ্ধার করতে পারবেন না।.</string> <string name="encrypt_and_copy_my_secret_key">এনক্রিপ্ট এবং আমার গোপন কী অনুলিপি</string> <string name="badge_award_image_for">%1$s -এর জন্য ব্যাজ পুরষ্কারের ছবি</string> <string name="badge_award_image">"ব্যাজ পুরস্কার চিত্র</string> @@ -963,9 +956,7 @@ <string name="push_server_none_explainer">Push বিজ্ঞপ্তি নিষ্ক্রিয় করে</string> <string name="push_server_uses_app_explainer">অ্যাপ %1$s ব্যবহার করে</string> <string name="push_server_install_app">Push বিজ্ঞপ্তি সেটআপ</string> - <string name="push_server_install_app_description"> পুশ নোটিফিকেশন পেতে, [Unified Push](https://unifiedpush.org/) সমর্থন করে এমন যেকোনো অ্যাপ ইনস্টল করুন, যেমন [Ntfy](https://ntfy.sh/)। - ইনস্টল করার পরে, সেটিংসে আপনি যে অ্যাপটি ব্যবহার করতে চান তা নির্বাচন করুন। - </string> + <string name="push_server_install_app_description">পুশ নোটিফিকেশন পেতে, [Unified Push](https://unifiedpush.org/) সমর্থন করে এমন যেকোনো অ্যাপ ইনস্টল করুন, যেমন [Ntfy](https://ntfy.sh/)। ইনস্টল করার পরে, সেটিংসে আপনি যে অ্যাপটি ব্যবহার করতে চান তা নির্বাচন করুন।</string> <string name="thread_title">থ্রেড</string> <string name="send_the_seller_a_message">বিক্রেতাকে একটি বার্তা পাঠান</string> <string name="hi_there_is_this_still_available">হাই, এটা কি এখনও পাওয়া যায়?</string> @@ -2022,15 +2013,7 @@ <string name="could_not_fetch_invoice_from_details">%1$s থেকে ইনভয়েস আনতে পারেনি: %2$s</string> <string name="could_not_open_the_compressed_file">সংকুচিত ফাইলটি খোলা যায়নি</string> <string name="couldnt_find_nwc_wallets">ওয়ালেট পাওয়া যায়নি</string> - <string name="couldnt_find_nwc_wallets_description">Amethyst কোনো ওয়ালেট অ্যাপ খুঁজে পায়নি যা Nostr Wallet Connect (NWC) সমর্থন করে। -\n\nআপনি যা করতে পারেন: -\n\t১. NWC সমর্থন যাচাই করুন: নিশ্চিত করুন যে আপনার পছন্দের ওয়ালেট অ্যাপ Nostr Wallet Connect-এর সাথে সামঞ্জস্যপূর্ণ। -\n\t২. সংযোগ তৈরি করুন: আপনার ওয়ালেট অ্যাপে যান এবং একটি নতুন NWC সংযোগ তৈরি করুন। -\n\t৩. Amethyst-এ লিঙ্ক করুন: আপনার ওয়ালেট সাধারণত তিনটি বিকল্প দেবে: -\n\t\t৩.১ স্বয়ংক্রিয় লিঙ্ক: একটি বোতাম যা স্বয়ংক্রিয়ভাবে Amethyst খুলে সংযুক্ত করবে। যদি তাই হয়, সেই বোতামে ক্লিক করুন, Amethyst খুলুন এবং নতুন স্ক্রিনে সেভ চাপুন। -\n\t\t৩.২ ম্যানুয়াল লিঙ্ক: একটি Connection URI যা কপি করে এই স্ক্রিনের পেস্ট আইকনে ক্লিক করে সরাসরি ইনপুট করতে পারবেন। -\n\t\t৩.৩ QR কোড: ওয়ালেট একটি QR কোড তৈরি করে যা আপনি এই স্ক্রিনের QR কোড বোতাম থেকে পড়তে পারবেন। - </string> + <string name="couldnt_find_nwc_wallets_description">Amethyst কোনো ওয়ালেট অ্যাপ খুঁজে পায়নি যা Nostr Wallet Connect (NWC) সমর্থন করে। \n\nআপনি যা করতে পারেন: \n\t১. NWC সমর্থন যাচাই করুন: নিশ্চিত করুন যে আপনার পছন্দের ওয়ালেট অ্যাপ Nostr Wallet Connect-এর সাথে সামঞ্জস্যপূর্ণ। \n\t২. সংযোগ তৈরি করুন: আপনার ওয়ালেট অ্যাপে যান এবং একটি নতুন NWC সংযোগ তৈরি করুন। \n\t৩. Amethyst-এ লিঙ্ক করুন: আপনার ওয়ালেট সাধারণত তিনটি বিকল্প দেবে: \n\t\t৩.১ স্বয়ংক্রিয় লিঙ্ক: একটি বোতাম যা স্বয়ংক্রিয়ভাবে Amethyst খুলে সংযুক্ত করবে। যদি তাই হয়, সেই বোতামে ক্লিক করুন, Amethyst খুলুন এবং নতুন স্ক্রিনে সেভ চাপুন। \n\t\t৩.২ ম্যানুয়াল লিঙ্ক: একটি Connection URI যা কপি করে এই স্ক্রিনের পেস্ট আইকনে ক্লিক করে সরাসরি ইনপুট করতে পারবেন। \n\t\t৩.৩ QR কোড: ওয়ালেট একটি QR কোড তৈরি করে যা আপনি এই স্ক্রিনের QR কোড বোতাম থেকে পড়তে পারবেন।</string> <string name="create">তৈরি করুন</string> <string name="d">d</string> <string name="danger_zone">বিপদজনক এলাকা</string> @@ -2699,11 +2682,8 @@ <string name="proxy_section">প্রক্সি Relay</string> <string name="proxy_section_explainer">অ্যাগ্রিগেটর relay যা অ্যাপটিকে আপনার ফিড ডাউনলোড করতে অবশ্যই ব্যবহার করতে হবে, যেমন filter.nostr.wine। এটি আউটবক্স মডেলকে প্রতিস্থাপন করে এবং অ্যাপটিকে শুধুমাত্র আপনার তালিকার relay গুলোতে সংযুক্ত করবে।</string> <string name="public_chat">সার্বজনীন আলাপ</string> - <string name="public_chat_explainer">পাবলিক চ্যাটগুলো Nostr-এর সবার কাছে দৃশ্যমান এবং যে কেউ - এতে অংশগ্রহণ করতে পারে। নির্দিষ্ট বিষয়ের চারপাশে উন্মুক্ত সম্প্রদায়ের জন্য এগুলো দুর্দান্ত। - এর relay-তে পোস্ট মুছে পরিচালনা নিয়ন্ত্রণ করা যায়</string> - <string name="public_chat_relays_explainer">এই গ্রুপটি হোস্ট করে এমন ১-৩ টি relay যোগ করুন। - Nostr ক্লায়েন্টগুলো এই সেটিং ব্যবহার করে জানে যে কোথা থেকে বার্তা ডাউনলোড করতে হবে এবং আপনার বার্তা কোথায় পাঠাতে হবে।</string> + <string name="public_chat_explainer">পাবলিক চ্যাটগুলো Nostr-এর সবার কাছে দৃশ্যমান এবং যে কেউ এতে অংশগ্রহণ করতে পারে। নির্দিষ্ট বিষয়ের চারপাশে উন্মুক্ত সম্প্রদায়ের জন্য এগুলো দুর্দান্ত। এর relay-তে পোস্ট মুছে পরিচালনা নিয়ন্ত্রণ করা যায়</string> + <string name="public_chat_relays_explainer">এই গ্রুপটি হোস্ট করে এমন ১-৩ টি relay যোগ করুন। Nostr ক্লায়েন্টগুলো এই সেটিং ব্যবহার করে জানে যে কোথা থেকে বার্তা ডাউনলোড করতে হবে এবং আপনার বার্তা কোথায় পাঠাতে হবে।</string> <string name="public_chat_relays_title">Relay-গুলি</string> <string name="public_chat_title">পাবলিক চ্যাট মেটাডেটা</string> <string name="public_chats">পাবলিক চ্যাট</string> @@ -2728,10 +2708,7 @@ <string name="read_only_user">শুধু-পড়ার ব্যবহারকারী</string> <string name="record_a_message_description">একটি বার্তা রেকর্ড করতে ক্লিক করুন এবং ধরে রাখুন</string> <string name="relay_chat">Relay চ্যাট</string> - <string name="relay_chat_explainer">Relay চ্যাটগুলো হলো চ্যাট গ্রুপ যা তাদের হোম relay দ্বারা নিয়ন্ত্রিত হয়। - এগুলো Nostr-এর সবার কাছে দৃশ্যমান এবং যে কেউ এতে অংশগ্রহণ করতে পারে। - নির্দিষ্ট বিষয়ের চারপাশে উন্মুক্ত সম্প্রদায়ের জন্য এগুলো দুর্দান্ত। এই গ্রুপগুলোর কিছু ক্ষণস্থায়ী - এবং সেজন্য চ্যাট বার্তাগুলো সময়ের সাথে মুছে যায়</string> + <string name="relay_chat_explainer">Relay চ্যাটগুলো হলো চ্যাট গ্রুপ যা তাদের হোম relay দ্বারা নিয়ন্ত্রিত হয়। এগুলো Nostr-এর সবার কাছে দৃশ্যমান এবং যে কেউ এতে অংশগ্রহণ করতে পারে। নির্দিষ্ট বিষয়ের চারপাশে উন্মুক্ত সম্প্রদায়ের জন্য এগুলো দুর্দান্ত। এই গ্রুপগুলোর কিছু ক্ষণস্থায়ী এবং সেজন্য চ্যাট বার্তাগুলো সময়ের সাথে মুছে যায়</string> <string name="relay_chat_title">Relay চ্যাট</string> <string name="relay_management_tab_events">ইভেন্ট</string> <string name="relay_management_tab_ips">আইপি</string> diff --git a/commonsUI/src/commonMain/composeResources/values-cs/strings.xml b/commonsUI/src/commonMain/composeResources/values-cs/strings.xml index 4611482789..b21d6ee05a 100644 --- a/commonsUI/src/commonMain/composeResources/values-cs/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-cs/strings.xml @@ -473,13 +473,8 @@ <string name="mark_all_known_as_read">Označit všechny známé jako přečtené</string> <string name="mark_all_new_as_read">Označit všechny nové jako přečtené</string> <string name="mark_all_as_read">Označit všechny jako přečtené</string> - <string name="account_backup_tips2_md"> ## Tipy pro zálohování klíčů a bezpečnost - \n\nVaše konto je zabezpečeno tajným klíčem. Klíč je dlouhá posloupnost znaků začínající s **nsec1**. Kdo má přístup k tomuto tajnému klíči, může přispívat a měnit vaši identitu. - \n\n- **Nedávejte** svůj tajný klíč na žádnou webovou stránku nebo do žádného softwaru, kterému nedůvěřujete. - \n- Vývojáři Amethystu nikdy **nebudou** žádat o váš tajný klíč. - \n- **Udržujte** bezpečnou zálohu vašeho tajného klíče pro obnovení účtu. Doporučujeme použití správce hesel.</string> - <string name="account_backup_tips3_md"> Pro další zabezpečení můžete svůj klíč zašifrovat heslem. Tento klíč začíná s **ncryptsec1** a nelze ho použít bez vašeho hesla. - \n\nPokud zapomenete heslo, nebudete moci obnovit svůj klíč.</string> + <string name="account_backup_tips2_md">## Tipy pro zálohování klíčů a bezpečnost \n\nVaše konto je zabezpečeno tajným klíčem. Klíč je dlouhá posloupnost znaků začínající s **nsec1**. Kdo má přístup k tomuto tajnému klíči, může přispívat a měnit vaši identitu. \n\n- **Nedávejte** svůj tajný klíč na žádnou webovou stránku nebo do žádného softwaru, kterému nedůvěřujete. \n- Vývojáři Amethystu nikdy **nebudou** žádat o váš tajný klíč. \n- **Udržujte** bezpečnou zálohu vašeho tajného klíče pro obnovení účtu. Doporučujeme použití správce hesel.</string> + <string name="account_backup_tips3_md">Pro další zabezpečení můžete svůj klíč zašifrovat heslem. Tento klíč začíná s **ncryptsec1** a nelze ho použít bez vašeho hesla. \n\nPokud zapomenete heslo, nebudete moci obnovit svůj klíč.</string> <string name="backup_keys_nudge_title">Zálohujte si klíče</string> <string name="backup_keys_nudge_body">Váš tajný klíč je jediný způsob, jak se k tomuto účtu dostat. Pokud ho ztratíte, nelze ho už nikdy obnovit. Uložte si ho někam do bezpečí.</string> <string name="backup_keys_nudge_backup_now">Zálohovat nyní</string> @@ -1598,9 +1593,7 @@ <string name="push_server_none_explainer">Zakázat Push oznámení</string> <string name="push_server_uses_app_explainer">Použít aplikaci %1$s</string> <string name="push_server_install_app">Nastavení Push-oznámení</string> - <string name="push_server_install_app_description"> Pro příjem Push-oznámení nainstalujte libovolnou aplikaci, která podporuje [Unified Push](https://unifiedpush.org/), jako je [Ntfy](https://ntfy.sh/). - Po instalaci vyberte aplikaci kterou chcete používat v Nastavení. - </string> + <string name="push_server_install_app_description">Pro příjem Push-oznámení nainstalujte libovolnou aplikaci, která podporuje [Unified Push](https://unifiedpush.org/), jako je [Ntfy](https://ntfy.sh/). Po instalaci vyberte aplikaci kterou chcete používat v Nastavení.</string> <string name="thread_title">Vlákno</string> <string name="send_the_seller_a_message">Poslat prodejci zprávu</string> <string name="hi_there_is_this_still_available">Ahoj, je to stále k dispozici?</string> @@ -3393,14 +3386,7 @@ <string name="could_not_fetch_invoice_from_details">Nelze získat fakturu z %1$s: %2$s</string> <string name="could_not_open_the_compressed_file">Nepodařilo se otevřít komprimovaný soubor</string> <string name="couldnt_find_nwc_wallets">Peněženka nebyla nalezená</string> - <string name="couldnt_find_nwc_wallets_description">Amethyst nenašel žádné aplikace peněženek, které podporují Nostr Wallet Connect (NWC). -\n\nCo můžete udělat: -\n\t1. Ověřte podporu NWC: Zkontrolujte, že vaše oblíbená peněženka podporuje Nostr Wallet Connect. -\n\t2. Vytvořte připojení: Otevřete svou peněženku a vytvořte nové NWC připojení. -\n\t3. Propojte s Amethyst: Vaše peněženka obvykle nabídne tři možnosti: -\n\t\t3.1 Automatické propojení: Tlačítko pro automatické otevření a připojení k Amethyst. Klikněte na něj, otevřete Amethyst a uložte na nové obrazovce. -\n\t\t3.2 Ruční propojení: URI připojení, které můžete zkopírovat a vložit přes ikonu vložení na této obrazovce. -\n\t\t3.3 QR kód: Peněženka vytvoří QR kód, který můžete naskenovat pomocí tlačítka QR kódu na této obrazovce.</string> + <string name="couldnt_find_nwc_wallets_description">Amethyst nenašel žádné aplikace peněženek, které podporují Nostr Wallet Connect (NWC). \n\nCo můžete udělat: \n\t1. Ověřte podporu NWC: Zkontrolujte, že vaše oblíbená peněženka podporuje Nostr Wallet Connect. \n\t2. Vytvořte připojení: Otevřete svou peněženku a vytvořte nové NWC připojení. \n\t3. Propojte s Amethyst: Vaše peněženka obvykle nabídne tři možnosti: \n\t\t3.1 Automatické propojení: Tlačítko pro automatické otevření a připojení k Amethyst. Klikněte na něj, otevřete Amethyst a uložte na nové obrazovce. \n\t\t3.2 Ruční propojení: URI připojení, které můžete zkopírovat a vložit přes ikonu vložení na této obrazovce. \n\t\t3.3 QR kód: Peněženka vytvoří QR kód, který můžete naskenovat pomocí tlačítka QR kódu na této obrazovce.</string> <string name="create">Vytvořit</string> <string name="d">d</string> <string name="danger_zone">Nebezpečná zóna</string> @@ -4611,11 +4597,8 @@ <string name="proxy_section">Proxy přenašeče</string> <string name="proxy_section_explainer">Agregační přenašeče, které aplikace používá ke stahování vašich zdrojů, jako např. filter.nostr.wine</string> <string name="public_chat">Veřejný chat</string> - <string name="public_chat_explainer">Veřejné konverzace jsou viditelné pro všechny na Nostru a kdokoli - se na nich může podílet. Jsou skvělé pro otevřené komunity kolem konkrétních témat. - Moderaci lze ovládat smazáním příspěvků na relé</string> - <string name="public_chat_relays_explainer">Vložte mezi 1–3 relé, které hostí tuto skupinu. - Klienti Nostr používají toto nastavení, aby věděli, kam stahovat zprávy a odesílat zprávy.</string> + <string name="public_chat_explainer">Veřejné konverzace jsou viditelné pro všechny na Nostru a kdokoli se na nich může podílet. Jsou skvělé pro otevřené komunity kolem konkrétních témat. Moderaci lze ovládat smazáním příspěvků na relé</string> + <string name="public_chat_relays_explainer">Vložte mezi 1–3 relé, které hostí tuto skupinu. Klienti Nostr používají toto nastavení, aby věděli, kam stahovat zprávy a odesílat zprávy.</string> <string name="public_chat_relays_title">Rele</string> <string name="public_chat_title">Metadata veřejného chatu</string> <string name="public_chats">Veřejné chaty</string> @@ -4684,10 +4667,7 @@ <string name="relay_auth_search_keywords">auth autentizace relay podepsat ověřit nip-42 identita</string> <string name="relay_auth_settings_title">Autentizace relay</string> <string name="relay_chat">Relé chat</string> - <string name="relay_chat_explainer">Relay chaty jsou chatové skupiny ovládané jejich domácí relé. - Jsou viditelné pro všechny na Nostru a každý se jich může zúčastnit. - Jsou skvělé pro otevřené komunity kolem konkrétních témat. Některé z těchto skupin jsou efemérní - a proto zprávy časem mizí</string> + <string name="relay_chat_explainer">Relay chaty jsou chatové skupiny ovládané jejich domácí relé. Jsou viditelné pro všechny na Nostru a každý se jich může zúčastnit. Jsou skvělé pro otevřené komunity kolem konkrétních témat. Některé z těchto skupin jsou efemérní a proto zprávy časem mizí</string> <string name="relay_chat_title">Relé chaty</string> <string name="relay_explain_account_data">Váš vlastní profil, nastavení a koncepty na vašich domovských relayích.</string> <string name="relay_explain_community_chats">Relaye, na které každá komunita publikuje své roviny.</string> diff --git a/commonsUI/src/commonMain/composeResources/values-de-rDE/strings.xml b/commonsUI/src/commonMain/composeResources/values-de-rDE/strings.xml index 076f687129..17617a8ae7 100644 --- a/commonsUI/src/commonMain/composeResources/values-de-rDE/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-de-rDE/strings.xml @@ -361,7 +361,7 @@ <string name="copy_user_id">Benutzer-ID kopieren</string> <string name="unblock_user">Benutzer freigeben</string> <string name="user_is_blocked_hidden">Dieser Nutzer ist blockiert/stummgeschaltet. Seine Beiträge sind ausgeblendet.</string> - <string name="npub_hex_username">npub, Benutzername, Text </string> + <string name="npub_hex_username">npub, Benutzername, Text</string> <string name="clear">Löschen</string> <string name="app_logo">App-Logo</string> <string name="nsec_npub_hex_private_key">nsec.. oder npub..</string> @@ -463,13 +463,8 @@ <string name="mark_all_known_as_read">Alle Kontakte als gelesen markieren</string> <string name="mark_all_new_as_read">Alle Unbekannten als gelesen markieren</string> <string name="mark_all_as_read">Alle als gelesen markieren</string> - <string name="account_backup_tips2_md"> ## Tipps zur Schlüsselsicherheit - \n\nDein Konto ist durch einen privaten Schlüssel gesichert. Der Schlüssel ist eine lange Zeichenfolge, die mit **nsec1** beginnt. Jeder, der Zugriff auf diesen privaten Schlüssel hat, kann in deinem Namen posten und dein Profil ändern. - \n\n- Lege deinen privaten Schlüssel **nicht** auf einer Website oder in einer Software ab, der du nicht vertraust. - \n- Die Entwickler von Amethyst werden dich **niemals** nach deinem privaten Schlüssel fragen. - \n- **Bewahre** eine sichere Kopie deines privaten Schlüssels zur Kontowiederherstellung auf. Wir empfehlen die Verwendung eines Passwort-Managers.</string> - <string name="account_backup_tips3_md"> Für zusätzliche Sicherheit kannst du deinen Schlüssel mit einem Passwort verschlüsseln. Dieser Schlüssel beginnt mit **ncryptsec1** und kann ohne dein Passwort nicht verwendet werden. - \n\nWenn du dein Passwort verlierst, kannst du deinen Schlüssel nicht wiederherstellen.</string> + <string name="account_backup_tips2_md">## Tipps zur Schlüsselsicherheit \n\nDein Konto ist durch einen privaten Schlüssel gesichert. Der Schlüssel ist eine lange Zeichenfolge, die mit **nsec1** beginnt. Jeder, der Zugriff auf diesen privaten Schlüssel hat, kann in deinem Namen posten und dein Profil ändern. \n\n- Lege deinen privaten Schlüssel **nicht** auf einer Website oder in einer Software ab, der du nicht vertraust. \n- Die Entwickler von Amethyst werden dich **niemals** nach deinem privaten Schlüssel fragen. \n- **Bewahre** eine sichere Kopie deines privaten Schlüssels zur Kontowiederherstellung auf. Wir empfehlen die Verwendung eines Passwort-Managers.</string> + <string name="account_backup_tips3_md">Für zusätzliche Sicherheit kannst du deinen Schlüssel mit einem Passwort verschlüsseln. Dieser Schlüssel beginnt mit **ncryptsec1** und kann ohne dein Passwort nicht verwendet werden. \n\nWenn du dein Passwort verlierst, kannst du deinen Schlüssel nicht wiederherstellen.</string> <string name="backup_keys_nudge_title">Sichere deine Schlüssel</string> <string name="backup_keys_nudge_body">Dein geheimer Schlüssel ist der einzige Weg zu diesem Konto. Wenn du ihn verlierst, kann er nie wiederhergestellt werden. Speichere ihn jetzt an einem sicheren Ort.</string> <string name="backup_keys_nudge_backup_now">Jetzt sichern</string> @@ -1584,9 +1579,7 @@ <string name="push_server_none_explainer">Push-Benachrichtigungen deaktivieren</string> <string name="push_server_uses_app_explainer">Verwendet die App %1$s</string> <string name="push_server_install_app">Push-Benachrichtigungen einrichten</string> - <string name="push_server_install_app_description"> Um Push-Benachrichtigungen zu erhalten, installiere eine App, die [Unified Push](https://unifiedpush.org/) unterstützt, wie zum Beispiel [Ntfy](https://ntfy.sh/). - Nach der Installation wähle in den Einstellungen die App aus, die du verwenden möchtest. - </string> + <string name="push_server_install_app_description">Um Push-Benachrichtigungen zu erhalten, installiere eine App, die [Unified Push](https://unifiedpush.org/) unterstützt, wie zum Beispiel [Ntfy](https://ntfy.sh/). Nach der Installation wähle in den Einstellungen die App aus, die du verwenden möchtest.</string> <string name="thread_title">Faden</string> <string name="send_the_seller_a_message">Dem Verkäufer eine Nachricht senden</string> <string name="hi_there_is_this_still_available">Hallo, ist das noch verfügbar?</string> @@ -3323,14 +3316,7 @@ <string name="could_not_fetch_invoice_from_details">Rechnung konnte nicht von %1$s abgerufen werden: %2$s</string> <string name="could_not_open_the_compressed_file">Konnte die komprimierte Datei nicht öffnen</string> <string name="couldnt_find_nwc_wallets">Wallet nicht gefunden</string> - <string name="couldnt_find_nwc_wallets_description">Amethyst konnte keine Wallet-Apps finden, die Nostr Wallet Connect (NWC) unterstützen. -\n\nWas du tun kannst: -\n\t1. NWC-Unterstützung überprüfen: Stelle sicher, dass deine bevorzugte Wallet-App mit Nostr Wallet Connect kompatibel ist. -\n\t2. Verbindung erstellen: Öffne deine Wallet-App und erstelle eine neue NWC-Verbindung. -\n\t3. Mit Amethyst verknüpfen: Deine Wallet bietet normalerweise drei Optionen: -\n\t\t3.1 Automatische Verknüpfung: Eine Schaltfläche zum automatischen Öffnen und Verbinden mit Amethyst. Klicke darauf, öffne Amethyst und speichere auf dem neuen Bildschirm. -\n\t\t3.2 Manuelle Verknüpfung: Eine Verbindungs-URI, die du kopieren und über das Einfügesymbol auf diesem Bildschirm einfügen kannst. -\n\t\t3.3 QR-Code: Die Wallet erstellt einen QR-Code, den du über die QR-Schaltfläche auf diesem Bildschirm scannen kannst.</string> + <string name="couldnt_find_nwc_wallets_description">Amethyst konnte keine Wallet-Apps finden, die Nostr Wallet Connect (NWC) unterstützen. \n\nWas du tun kannst: \n\t1. NWC-Unterstützung überprüfen: Stelle sicher, dass deine bevorzugte Wallet-App mit Nostr Wallet Connect kompatibel ist. \n\t2. Verbindung erstellen: Öffne deine Wallet-App und erstelle eine neue NWC-Verbindung. \n\t3. Mit Amethyst verknüpfen: Deine Wallet bietet normalerweise drei Optionen: \n\t\t3.1 Automatische Verknüpfung: Eine Schaltfläche zum automatischen Öffnen und Verbinden mit Amethyst. Klicke darauf, öffne Amethyst und speichere auf dem neuen Bildschirm. \n\t\t3.2 Manuelle Verknüpfung: Eine Verbindungs-URI, die du kopieren und über das Einfügesymbol auf diesem Bildschirm einfügen kannst. \n\t\t3.3 QR-Code: Die Wallet erstellt einen QR-Code, den du über die QR-Schaltfläche auf diesem Bildschirm scannen kannst.</string> <string name="create">Erstellen</string> <string name="d">t</string> <string name="danger_zone">Gefahrenzone</string> @@ -4427,11 +4413,8 @@ <string name="proxy_section">Proxy-Relays</string> <string name="proxy_section_explainer">Aggregator-Relays wie filter.nostr.wine, von denen die App deine Feeds herunterlädt</string> <string name="public_chat">Öffentlicher Chat</string> - <string name="public_chat_explainer">Öffentliche Chats sind für jeden auf Nostr sichtbar und jeder - kann daran teilnehmen. Sie eignen sich hervorragend für offene Gemeinschaften rund um bestimmte Themen. - Moderation kann durch Löschen von Beiträgen auf den Relays gesteuert werden</string> - <string name="public_chat_relays_explainer">Füge zwischen 1–3 Relays ein, die diese Gruppe beherbergen. - Nostr Clients verwenden diese Einstellung, um zu wissen, woher sie Nachrichten herunterladen und an welche gesendet werden sollen.</string> + <string name="public_chat_explainer">Öffentliche Chats sind für jeden auf Nostr sichtbar und jeder kann daran teilnehmen. Sie eignen sich hervorragend für offene Gemeinschaften rund um bestimmte Themen. Moderation kann durch Löschen von Beiträgen auf den Relays gesteuert werden</string> + <string name="public_chat_relays_explainer">Füge zwischen 1–3 Relays ein, die diese Gruppe beherbergen. Nostr Clients verwenden diese Einstellung, um zu wissen, woher sie Nachrichten herunterladen und an welche gesendet werden sollen.</string> <string name="public_chat_relays_title">Relays</string> <string name="public_chat_title">Öffentliche Chat Metadaten</string> <string name="public_chats">Öffentliche Chats</string> @@ -4496,10 +4479,7 @@ <string name="relay_auth_search_keywords">auth authentifizierung relay signieren verifizieren nip-42 identität</string> <string name="relay_auth_settings_title">Relay-Authentifizierung</string> <string name="relay_chat">Relay-Chat</string> - <string name="relay_chat_explainer">Relay-Chats sind Chatgruppen, die von ihrem Home-Relay gesteuert werden. - Sie sind für jeden auf Nostr sichtbar und jeder kann an ihnen teilnehmen. - Sie eignen sich hervorragend für offene Communities rund um bestimmte Themen. Einige dieser Gruppen sind kurzsichtig - und daher verschwinden Chat-Nachrichten im Laufe der Zeit</string> + <string name="relay_chat_explainer">Relay-Chats sind Chatgruppen, die von ihrem Home-Relay gesteuert werden. Sie sind für jeden auf Nostr sichtbar und jeder kann an ihnen teilnehmen. Sie eignen sich hervorragend für offene Communities rund um bestimmte Themen. Einige dieser Gruppen sind kurzsichtig und daher verschwinden Chat-Nachrichten im Laufe der Zeit</string> <string name="relay_chat_title">Relay-Chats</string> <string name="relay_explain_account_data">Dein eigenes Profil, deine Einstellungen und Entwürfe auf deinen Heim-Relays.</string> <string name="relay_explain_community_chats">Die Relays, auf denen jede Community ihre Planes veröffentlicht.</string> diff --git a/commonsUI/src/commonMain/composeResources/values-de/strings.xml b/commonsUI/src/commonMain/composeResources/values-de/strings.xml index fe28311d40..a1bc04624c 100644 --- a/commonsUI/src/commonMain/composeResources/values-de/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-de/strings.xml @@ -211,7 +211,7 @@ <string name="copy_user_id">Benutzer-ID kopieren</string> <string name="unblock_user">Benutzer freigeben</string> <string name="user_is_blocked_hidden">Dieser Nutzer ist blockiert/stummgeschaltet. Seine Beiträge sind ausgeblendet.</string> - <string name="npub_hex_username">Npub, Hex, Benutzername </string> + <string name="npub_hex_username">Npub, Hex, Benutzername</string> <string name="clear">Löschen</string> <string name="app_logo">App-Logo</string> <string name="nsec_npub_hex_private_key">Nsec / Npub / Hex-Privatschlüssel</string> @@ -219,9 +219,7 @@ <string name="show_password">Passwort anzeigen</string> <string name="i_accept_the">Ich akzeptiere die </string> <string name="terms_of_use">Nutzungsbedingungen</string> - <string name="acceptance_of_terms_is_required">Die Akzept - -anz der Bedingungen ist erforderlich</string> + <string name="acceptance_of_terms_is_required">Die Akzept anz der Bedingungen ist erforderlich</string> <string name="login">Anmeldung</string> <string name="sign_up">Anmelden</string> <string name="create_account">Konto erstellen</string> @@ -261,13 +259,8 @@ anz der Bedingungen ist erforderlich</string> <string name="mark_all_known_as_read">Alle als bekannt markieren</string> <string name="mark_all_new_as_read">Alle als neu markieren</string> <string name="mark_all_as_read">Alle als gelesen markieren</string> - <string name="account_backup_tips2_md"> ## Schlüsselsicherheits-Tipps - \n\nIhr Konto ist durch einen geheimen Schlüssel gesichert. Der Schlüssel ist eine lange Zeichenfolge, die mit **nsec1** beginnt. Jeder, der Zugriff auf diesen geheimen Schlüssel hat, kann Ihre Identität posten und ändern. - \n\n- Legen Sie Ihren geheimen Schlüssel **nicht** auf eine Website oder in eine Software, der Sie nicht vertrauen. - \n- Die Entwickler von Amethyst werden Sie **niemals** nach Ihrem geheimen Schlüssel fragen. - \n- **Bewahren Sie** eine sichere Sicherung Ihres geheimen Schlüssels zur Kontowiederherstellung auf. Wir empfehlen die Verwendung eines Passwort-Managers.</string> - <string name="account_backup_tips3_md"> Für zusätzliche Sicherheit können Sie Ihren Schlüssel mit einem Passwort verschlüsseln. Dieser Schlüssel beginnt mit **ncryptsec1** und kann ohne Ihr Passwort nicht verwendet werden. - \n\nWenn Sie Ihr Passwort verlieren, können Sie Ihren Schlüssel nicht wiederherstellen.</string> + <string name="account_backup_tips2_md">## Schlüsselsicherheits-Tipps \n\nIhr Konto ist durch einen geheimen Schlüssel gesichert. Der Schlüssel ist eine lange Zeichenfolge, die mit **nsec1** beginnt. Jeder, der Zugriff auf diesen geheimen Schlüssel hat, kann Ihre Identität posten und ändern. \n\n- Legen Sie Ihren geheimen Schlüssel **nicht** auf eine Website oder in eine Software, der Sie nicht vertrauen. \n- Die Entwickler von Amethyst werden Sie **niemals** nach Ihrem geheimen Schlüssel fragen. \n- **Bewahren Sie** eine sichere Sicherung Ihres geheimen Schlüssels zur Kontowiederherstellung auf. Wir empfehlen die Verwendung eines Passwort-Managers.</string> + <string name="account_backup_tips3_md">Für zusätzliche Sicherheit können Sie Ihren Schlüssel mit einem Passwort verschlüsseln. Dieser Schlüssel beginnt mit **ncryptsec1** und kann ohne Ihr Passwort nicht verwendet werden. \n\nWenn Sie Ihr Passwort verlieren, können Sie Ihren Schlüssel nicht wiederherstellen.</string> <string name="encrypt_and_copy_my_secret_key">Verschlüsseln und kopieren Sie meinen geheimen Schlüssel</string> <string name="badge_award_image_for">Auszeichnungsbild für %1$s</string> <string name="badge_award_image">Auszeichnungsbild</string> @@ -960,9 +953,7 @@ anz der Bedingungen ist erforderlich</string> <string name="push_server_none_explainer">Push-Benachrichtigungen deaktivieren</string> <string name="push_server_uses_app_explainer">Verwendet die App %1$s</string> <string name="push_server_install_app">Push-Benachrichtigungen einrichten</string> - <string name="push_server_install_app_description"> Um Push-Benachrichtigungen zu erhalten, installieren Sie eine App, die [Unified Push](https://unifiedpush.org/) unterstützt, wie zum Beispiel [Ntfy](https://ntfy.sh/). - Nach der Installation wählen Sie die App aus, die Sie in den Einstellungen verwenden möchten. - </string> + <string name="push_server_install_app_description">Um Push-Benachrichtigungen zu erhalten, installieren Sie eine App, die [Unified Push](https://unifiedpush.org/) unterstützt, wie zum Beispiel [Ntfy](https://ntfy.sh/). Nach der Installation wählen Sie die App aus, die Sie in den Einstellungen verwenden möchten.</string> <string name="thread_title">Faden</string> <string name="send_the_seller_a_message">Dem Verkäufer eine Nachricht senden</string> <string name="hi_there_is_this_still_available">Hallo, ist das noch verfügbar?</string> @@ -2064,14 +2055,7 @@ anz der Bedingungen ist erforderlich</string> <string name="could_not_fetch_invoice_from_details">Rechnung konnte nicht von %1$s abgerufen werden: %2$s</string> <string name="could_not_open_the_compressed_file">Konnte die komprimierte Datei nicht öffnen</string> <string name="couldnt_find_nwc_wallets">Wallet nicht gefunden</string> - <string name="couldnt_find_nwc_wallets_description">Amethyst konnte keine Wallet-Apps finden, die Nostr Wallet Connect (NWC) unterstützen. -\n\nWas du tun kannst: -\n\t1. NWC-Unterstützung überprüfen: Stelle sicher, dass deine bevorzugte Wallet-App mit Nostr Wallet Connect kompatibel ist. -\n\t2. Verbindung erstellen: Öffne deine Wallet-App und erstelle eine neue NWC-Verbindung. -\n\t3. Mit Amethyst verknüpfen: Deine Wallet bietet normalerweise drei Optionen: -\n\t\t3.1 Automatische Verknüpfung: Eine Schaltfläche zum automatischen Öffnen und Verbinden mit Amethyst. Klicke darauf, öffne Amethyst und speichere auf dem neuen Bildschirm. -\n\t\t3.2 Manuelle Verknüpfung: Eine Verbindungs-URI, die du kopieren und über das Einfügesymbol auf diesem Bildschirm einfügen kannst. -\n\t\t3.3 QR-Code: Die Wallet erstellt einen QR-Code, den du über die QR-Schaltfläche auf diesem Bildschirm scannen kannst.</string> + <string name="couldnt_find_nwc_wallets_description">Amethyst konnte keine Wallet-Apps finden, die Nostr Wallet Connect (NWC) unterstützen. \n\nWas du tun kannst: \n\t1. NWC-Unterstützung überprüfen: Stelle sicher, dass deine bevorzugte Wallet-App mit Nostr Wallet Connect kompatibel ist. \n\t2. Verbindung erstellen: Öffne deine Wallet-App und erstelle eine neue NWC-Verbindung. \n\t3. Mit Amethyst verknüpfen: Deine Wallet bietet normalerweise drei Optionen: \n\t\t3.1 Automatische Verknüpfung: Eine Schaltfläche zum automatischen Öffnen und Verbinden mit Amethyst. Klicke darauf, öffne Amethyst und speichere auf dem neuen Bildschirm. \n\t\t3.2 Manuelle Verknüpfung: Eine Verbindungs-URI, die du kopieren und über das Einfügesymbol auf diesem Bildschirm einfügen kannst. \n\t\t3.3 QR-Code: Die Wallet erstellt einen QR-Code, den du über die QR-Schaltfläche auf diesem Bildschirm scannen kannst.</string> <string name="create">Erstellen</string> <string name="d">t</string> <string name="danger_zone">Gefahrenzone</string> @@ -2274,9 +2258,7 @@ anz der Bedingungen ist erforderlich</string> <string name="http_status_508">Schleife erkannt - Der Server erkennt eine Endlosschleife, während er die Anfrage bearbeitet</string> <string name="http_status_511">Netzwerk-Authentifizierung erforderlich - Der Client muss authentifiziert werden, um auf das Netzwerk zuzugreifen</string> <string name="illegal_behavior">Illegales Verhalten</string> - <string name="image_saved_to_the_gallery">Bild in der Gal - -erie gespeichert</string> + <string name="image_saved_to_the_gallery">Bild in der Gal erie gespeichert</string> <string name="impersonation">Vortäuschung</string> <string name="import_follows_search_keywords">Kontakte, folgen, Folgeliste, importieren</string> <string name="inbox_relays_title">Eingehende Relays</string> @@ -2777,11 +2759,8 @@ erie gespeichert</string> <string name="proxy_section">Proxy-Relays</string> <string name="proxy_section_explainer">Aggregator-Relays wie filter.nostr.wine, von denen die App deine Feeds herunterlädt</string> <string name="public_chat">Öffentlicher Chat</string> - <string name="public_chat_explainer">Öffentliche Chats sind für jeden auf Nostr sichtbar und jeder - kann daran teilnehmen. Sie eignen sich hervorragend für offene Gemeinschaften rund um bestimmte Themen. - Moderation kann durch Löschen von Beiträgen auf den Relais gesteuert werden</string> - <string name="public_chat_relays_explainer">Fügen Sie zwischen 1–3 Relais ein, die diese Gruppe beherbergen. - Nostr Clients verwenden diese Einstellung, um zu wissen, woher sie Nachrichten herunterladen und an welche gesendet werden sollen.</string> + <string name="public_chat_explainer">Öffentliche Chats sind für jeden auf Nostr sichtbar und jeder kann daran teilnehmen. Sie eignen sich hervorragend für offene Gemeinschaften rund um bestimmte Themen. Moderation kann durch Löschen von Beiträgen auf den Relais gesteuert werden</string> + <string name="public_chat_relays_explainer">Fügen Sie zwischen 1–3 Relais ein, die diese Gruppe beherbergen. Nostr Clients verwenden diese Einstellung, um zu wissen, woher sie Nachrichten herunterladen und an welche gesendet werden sollen.</string> <string name="public_chat_relays_title">Relais</string> <string name="public_chat_title">Öffentliche Chat Metadaten</string> <string name="public_chats">Öffentliche Chats</string> @@ -2808,10 +2787,7 @@ erie gespeichert</string> <string name="read_only_user">Nur-Lese-Benutzer</string> <string name="record_a_message_description">Zum Aufnehmen einer Nachricht gedrückt halten</string> <string name="relay_chat">Relay-Chat</string> - <string name="relay_chat_explainer">Relaischats sind Chatgruppen, die von ihrem Home-Relais gesteuert werden. - Sie sind für jeden auf Nostr sichtbar und jeder kann an ihnen teilnehmen. - Sie eignen sich hervorragend für offene Communities rund um bestimmte Themen. Einige dieser Gruppen sind kurzsichtig - und daher verschwinden Chat-Nachrichten im Laufe der Zeit</string> + <string name="relay_chat_explainer">Relaischats sind Chatgruppen, die von ihrem Home-Relais gesteuert werden. Sie sind für jeden auf Nostr sichtbar und jeder kann an ihnen teilnehmen. Sie eignen sich hervorragend für offene Communities rund um bestimmte Themen. Einige dieser Gruppen sind kurzsichtig und daher verschwinden Chat-Nachrichten im Laufe der Zeit</string> <string name="relay_chat_title">Relay-Chats</string> <string name="relay_management_tab_events">Ereignisse</string> <string name="relay_management_tab_ips">IPs</string> diff --git a/commonsUI/src/commonMain/composeResources/values-el-rGR/strings.xml b/commonsUI/src/commonMain/composeResources/values-el-rGR/strings.xml index 6569dd09c3..ffe6a08835 100644 --- a/commonsUI/src/commonMain/composeResources/values-el-rGR/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-el-rGR/strings.xml @@ -271,15 +271,8 @@ <string name="mark_all_known_as_read">Σήμανση όλων των γνωστοποιήσεων ως αναγνωσμένων</string> <string name="mark_all_new_as_read">Σήμανση όλων ως αναγνωσμένων</string> <string name="mark_all_as_read">Σήμανση όλων ως αναγνωσμένα</string> - <string name="account_backup_tips2_md"> ## Συμβουλές ασφαλείας και δημιουργίας αντιγράφων ασφαλείας κλειδιού - \n\nΟ λογαριασμός σου ασφαλίζεται με ένα μυστικό κλειδί. Το κλειδί είναι μια μακρά ακολουθία χαρακτήρων που ξεκινά με **nsec1**. Όποιος έχει πρόσβαση σε αυτό το μυστικό κλειδί μπορεί να δημοσιεύει και να αλλάζει την ταυτότητά σου. - \n\n- **Μην** βάζεις το μυστικό σου κλειδί σε κανέναν ιστότοπο ή λογισμικό που δεν εμπιστεύεσαι. - \n- Οι προγραμματιστές του Amethyst **δεν** θα ζητήσουν ποτέ το μυστικό σου κλειδί. - \n- **Διατήρησε** ένα ασφαλές αντίγραφο ασφαλείας του μυστικού σου κλειδιού για ανάκτηση λογαριασμού. Συνιστούμε τη χρήση διαχειριστή κωδικών πρόσβασης. - </string> - <string name="account_backup_tips3_md"> Για πρόσθετη ασφάλεια, μπορείς να κρυπτογραφήσεις το κλειδί σου με κωδικό πρόσβασης. Αυτό το κλειδί ξεκινά με **ncryptsec1** και δεν μπορεί να χρησιμοποιηθεί χωρίς τον κωδικό σου. - \n\nΑν χάσεις τον κωδικό σου, δεν θα μπορείς να ανακτήσεις το κλειδί σου. - </string> + <string name="account_backup_tips2_md">## Συμβουλές ασφαλείας και δημιουργίας αντιγράφων ασφαλείας κλειδιού \n\nΟ λογαριασμός σου ασφαλίζεται με ένα μυστικό κλειδί. Το κλειδί είναι μια μακρά ακολουθία χαρακτήρων που ξεκινά με **nsec1**. Όποιος έχει πρόσβαση σε αυτό το μυστικό κλειδί μπορεί να δημοσιεύει και να αλλάζει την ταυτότητά σου. \n\n- **Μην** βάζεις το μυστικό σου κλειδί σε κανέναν ιστότοπο ή λογισμικό που δεν εμπιστεύεσαι. \n- Οι προγραμματιστές του Amethyst **δεν** θα ζητήσουν ποτέ το μυστικό σου κλειδί. \n- **Διατήρησε** ένα ασφαλές αντίγραφο ασφαλείας του μυστικού σου κλειδιού για ανάκτηση λογαριασμού. Συνιστούμε τη χρήση διαχειριστή κωδικών πρόσβασης.</string> + <string name="account_backup_tips3_md">Για πρόσθετη ασφάλεια, μπορείς να κρυπτογραφήσεις το κλειδί σου με κωδικό πρόσβασης. Αυτό το κλειδί ξεκινά με **ncryptsec1** και δεν μπορεί να χρησιμοποιηθεί χωρίς τον κωδικό σου. \n\nΑν χάσεις τον κωδικό σου, δεν θα μπορείς να ανακτήσεις το κλειδί σου.</string> <string name="encrypt_and_copy_my_secret_key">Κρυπτογράφηση και αντιγραφή του μυστικού μου κλειδιού</string> <string name="badge_award_image_for">Σήμα βράβευσης για %1$s</string> <string name="badge_award_image">"Εικόνα βραβείου παρασήμου</string> @@ -934,8 +927,7 @@ <string name="moderators">Συντονιστές</string> <string name="login_with_external_signer">Σύνδεση με Amber</string> <string name="status_update">Ενημέρωση κατάστασης</string> - <string name="poll_zap_value_min_max_explainer">Η ψηφοφορία σε μια δημοσίευση Δημοσκόπησης γίνεται με zaps. Τα zaps είναι μικροπληρωμές σε satoshi (sats). Τα sats είναι υποδιαίρεση του Bitcoin. Κάθε Bitcoin έχει 100.000.000 sats. -Η επιλογή με τα περισσότερα zaps (δηλαδή τα περισσότερα sats) είναι η επικρατέστερη. Μπορείτε να ορίσετε ένα ελάχιστο ποσό zap για να αποφύγετε την ψήφο spammers αλλά και ένα μέγιστο ποσό ώστε να αποφύγετε μεγάλους zappers (χρήστες που στέλνουν zaps) που επιθυμούν να χειραγωγήσουν την ψηφοφορία. Χρησιμοποιήστε το ίδιο ποσό και στα δύο πεδία για να βεβαιωθείτε ότι κάθε ψήφος αποτιμάται το ίδιο. Ή αφήστε τα πεδία κενά για να αποδεχτείτε οποιοδήποτε ποσό.</string> + <string name="poll_zap_value_min_max_explainer">Η ψηφοφορία σε μια δημοσίευση Δημοσκόπησης γίνεται με zaps. Τα zaps είναι μικροπληρωμές σε satoshi (sats). Τα sats είναι υποδιαίρεση του Bitcoin. Κάθε Bitcoin έχει 100.000.000 sats. Η επιλογή με τα περισσότερα zaps (δηλαδή τα περισσότερα sats) είναι η επικρατέστερη. Μπορείτε να ορίσετε ένα ελάχιστο ποσό zap για να αποφύγετε την ψήφο spammers αλλά και ένα μέγιστο ποσό ώστε να αποφύγετε μεγάλους zappers (χρήστες που στέλνουν zaps) που επιθυμούν να χειραγωγήσουν την ψηφοφορία. Χρησιμοποιήστε το ίδιο ποσό και στα δύο πεδία για να βεβαιωθείτε ότι κάθε ψήφος αποτιμάται το ίδιο. Ή αφήστε τα πεδία κενά για να αποδεχτείτε οποιοδήποτε ποσό.</string> <string name="error_dialog_talk_to_user">Μήνυμα στον Χρήστη</string> <string name="error_dialog_talk_to_user_name">Μήνυμα σε %1$s</string> <string name="error_dialog_button_ok">ΟΚ</string> @@ -963,9 +955,7 @@ <string name="push_server_none_explainer">Απενεργοποίηση Ειδοποιήσεων Push</string> <string name="push_server_uses_app_explainer">Χρήση εφαρμογής %1$s</string> <string name="push_server_install_app">Ρυθμίσεις Ειδοποιήσεων Push</string> - <string name="push_server_install_app_description"> Για να λαμβάνετε ειδοποιήσεις Push, εγκαταστήστε οποιαδήποτε εφαρμογή τις υποστηρίζει [Unified Push](https://unifiedpush.org/), όπως πχ το [Ntfy](https://ntfy.sh/). - Μετά την εγκατάσταση, επιλέξτε την εφαρμογή που θέλετε να χρησιμοποιήσετε από τις Ρυθμίσεις. - </string> + <string name="push_server_install_app_description">Για να λαμβάνετε ειδοποιήσεις Push, εγκαταστήστε οποιαδήποτε εφαρμογή τις υποστηρίζει [Unified Push](https://unifiedpush.org/), όπως πχ το [Ntfy](https://ntfy.sh/). Μετά την εγκατάσταση, επιλέξτε την εφαρμογή που θέλετε να χρησιμοποιήσετε από τις Ρυθμίσεις.</string> <string name="thread_title">Νήμα</string> <string name="send_the_seller_a_message">Στείλτε μήνυμα στον πωλητή</string> <string name="hi_there_is_this_still_available">Γεια σου, είναι ακόμα διαθέσιμο;</string> @@ -2008,15 +1998,7 @@ <string name="could_not_fetch_invoice_from_details">Δεν ήταν δυνατή η λήψη τιμολογίου από %1$s: %2$s</string> <string name="could_not_open_the_compressed_file">Δεν ήταν δυνατό το άνοιγμα του συμπιεσμένου αρχείου</string> <string name="couldnt_find_nwc_wallets">Το πορτοφόλι δεν βρέθηκε</string> - <string name="couldnt_find_nwc_wallets_description">Το Amethyst δεν βρήκε εφαρμογές πορτοφολιού που υποστηρίζουν Nostr Wallet Connect (NWC). -\n\nΤι μπορείτε να κάνετε: -\n\t1. Επαλήθευση υποστήριξης NWC: Βεβαιωθείτε ότι η προτιμώμενη εφαρμογή πορτοφολιού σας είναι συμβατή με Nostr Wallet Connect. -\n\t2. Δημιουργία σύνδεσης: Μεταβείτε στην εφαρμογή πορτοφολιού σας και δημιουργήστε μια νέα σύνδεση NWC. -\n\t3. Σύνδεση με το Amethyst: Το πορτοφόλι σας συνήθως θα σας δώσει τρεις επιλογές: -\n\t\t3.1 Αυτόματη σύνδεση: Ένα κουμπί για αυτόματο άνοιγμα και σύνδεση με το Amethyst. Αν ναι, κάντε κλικ σε αυτό το κουμπί, ανοίξτε το Amethyst και πατήστε αποθήκευση στη νέα οθόνη. -\n\t\t3.2 Χειροκίνητη σύνδεση: Ένα URI σύνδεσης που μπορείτε να αντιγράψετε και να κάνετε κλικ στο εικονίδιο επικόλλησης σε αυτή την οθόνη για απευθείας εισαγωγή. -\n\t\t3.3 Κωδικός QR: Το πορτοφόλι δημιουργεί κωδικό QR τον οποίο μπορείτε να σαρώσετε από το κουμπί κωδικού QR σε αυτή την οθόνη. - </string> + <string name="couldnt_find_nwc_wallets_description">Το Amethyst δεν βρήκε εφαρμογές πορτοφολιού που υποστηρίζουν Nostr Wallet Connect (NWC). \n\nΤι μπορείτε να κάνετε: \n\t1. Επαλήθευση υποστήριξης NWC: Βεβαιωθείτε ότι η προτιμώμενη εφαρμογή πορτοφολιού σας είναι συμβατή με Nostr Wallet Connect. \n\t2. Δημιουργία σύνδεσης: Μεταβείτε στην εφαρμογή πορτοφολιού σας και δημιουργήστε μια νέα σύνδεση NWC. \n\t3. Σύνδεση με το Amethyst: Το πορτοφόλι σας συνήθως θα σας δώσει τρεις επιλογές: \n\t\t3.1 Αυτόματη σύνδεση: Ένα κουμπί για αυτόματο άνοιγμα και σύνδεση με το Amethyst. Αν ναι, κάντε κλικ σε αυτό το κουμπί, ανοίξτε το Amethyst και πατήστε αποθήκευση στη νέα οθόνη. \n\t\t3.2 Χειροκίνητη σύνδεση: Ένα URI σύνδεσης που μπορείτε να αντιγράψετε και να κάνετε κλικ στο εικονίδιο επικόλλησης σε αυτή την οθόνη για απευθείας εισαγωγή. \n\t\t3.3 Κωδικός QR: Το πορτοφόλι δημιουργεί κωδικό QR τον οποίο μπορείτε να σαρώσετε από το κουμπί κωδικού QR σε αυτή την οθόνη.</string> <string name="create">Δημιουργία</string> <string name="d">η</string> <string name="danger_zone">Επικίνδυνη ζώνη</string> @@ -2653,11 +2635,8 @@ <string name="proxy_section">Relay Διαμεσολαβητή</string> <string name="proxy_section_explainer">Relay συγκεντρωτές που η εφαρμογή πρέπει να χρησιμοποιεί για να κατεβάσει τις τροφοδοσίες σας, όπως filter.nostr.wine. Αυτό αντικαθιστά το μοντέλο outbox και θα κάνει την εφαρμογή να συνδέεται μόνο στα relay στις λίστες σας.</string> <string name="public_chat">Δημόσια Συζήτηση</string> - <string name="public_chat_explainer">Οι δημόσιες συνομιλίες είναι ορατές σε όλους στο Nostr και οποιοσδήποτε - μπορεί να συμμετάσχει σε αυτές. Είναι ιδανικές για ανοιχτές κοινότητες γύρω από συγκεκριμένα θέματα. - Η εποπτεία μπορεί να ελεγχθεί με διαγραφή αναρτήσεων στα relay της</string> - <string name="public_chat_relays_explainer">Εισάγετε 1–3 relay που φιλοξενούν αυτή την ομάδα. - Οι πελάτες Nostr χρησιμοποιούν αυτή τη ρύθμιση για να γνωρίζουν πού να κατεβάσουν μηνύματα και πού να στέλνουν τα μηνύματά σου.</string> + <string name="public_chat_explainer">Οι δημόσιες συνομιλίες είναι ορατές σε όλους στο Nostr και οποιοσδήποτε μπορεί να συμμετάσχει σε αυτές. Είναι ιδανικές για ανοιχτές κοινότητες γύρω από συγκεκριμένα θέματα. Η εποπτεία μπορεί να ελεγχθεί με διαγραφή αναρτήσεων στα relay της</string> + <string name="public_chat_relays_explainer">Εισάγετε 1–3 relay που φιλοξενούν αυτή την ομάδα. Οι πελάτες Nostr χρησιμοποιούν αυτή τη ρύθμιση για να γνωρίζουν πού να κατεβάσουν μηνύματα και πού να στέλνουν τα μηνύματά σου.</string> <string name="public_chat_title">Μεταδεδομένα δημόσιας συνομιλίας</string> <string name="public_chats">Δημόσιες Συνομιλίες</string> <string name="public_home_section">Δημόσια Relay Εξερχόμενων/Αρχικής</string> @@ -2681,10 +2660,7 @@ <string name="read_only_user">Χρήστης μόνο για ανάγνωση</string> <string name="record_a_message_description">Πάτα παρατεταμένα για να καταγράψεις ένα μήνυμα</string> <string name="relay_chat">Συνομιλία relay</string> - <string name="relay_chat_explainer">Οι συνομιλίες relay είναι ομάδες συνομιλίας που ελέγχονται από το αρχικό τους relay. - Είναι ορατές σε όλους στο Nostr και οποιοσδήποτε μπορεί να συμμετάσχει σε αυτές. - Είναι ιδανικές για ανοιχτές κοινότητες γύρω από συγκεκριμένα θέματα. Ορισμένες από αυτές τις ομάδες είναι εφήμερες - και έτσι τα μηνύματα συνομιλίας εξαφανίζονται με την πάροδο του χρόνου</string> + <string name="relay_chat_explainer">Οι συνομιλίες relay είναι ομάδες συνομιλίας που ελέγχονται από το αρχικό τους relay. Είναι ορατές σε όλους στο Nostr και οποιοσδήποτε μπορεί να συμμετάσχει σε αυτές. Είναι ιδανικές για ανοιχτές κοινότητες γύρω από συγκεκριμένα θέματα. Ορισμένες από αυτές τις ομάδες είναι εφήμερες και έτσι τα μηνύματα συνομιλίας εξαφανίζονται με την πάροδο του χρόνου</string> <string name="relay_chat_title">Συνομιλίες relay</string> <string name="relay_management_tab_events">Συμβάντα</string> <string name="relay_management_tab_pubkeys">Χρήστες</string> diff --git a/commonsUI/src/commonMain/composeResources/values-eo-rUY/strings.xml b/commonsUI/src/commonMain/composeResources/values-eo-rUY/strings.xml index e55ddcf03c..35481e4172 100644 --- a/commonsUI/src/commonMain/composeResources/values-eo-rUY/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-eo-rUY/strings.xml @@ -225,7 +225,7 @@ <string name="copy_user_id">Kopii ID de uzanto</string> <string name="unblock_user">Malbloki Uzanton</string> <string name="user_is_blocked_hidden">Tiu ĉi uzanto estas blokita/silentita. Iliaj afiŝoj estas kaŝitaj.</string> - <string name="npub_hex_username">npub, hekso, uzantnomo </string> + <string name="npub_hex_username">npub, hekso, uzantnomo</string> <string name="clear">Klarigi</string> <string name="app_logo">Logo de Apo</string> <string name="nsec_npub_hex_private_key">nsec / npub / heksa privata ŝlosilo</string> @@ -273,15 +273,8 @@ <string name="mark_all_known_as_read">Marki ĉiujn Konatajn legitaj</string> <string name="mark_all_new_as_read">Marki ĉiujn Novajn legitaj</string> <string name="mark_all_as_read">Marki ĉiujn legitajn</string> - <string name="account_backup_tips2_md"> ## Konsiletoj pri Ŝlosila Sekurkopiao kaj Sekureco - \n\nVia konto estas sekurigita per sekreta ŝlosilo. La ŝlosilo estas longa sinsekvo de signoj komencanta per **nsec1**. Ĉiu kiu havas aliron al ĉi tiu sekreta ŝlosilo povas afiŝi kaj ŝanĝi vian identecon. - \n\n- **Ne** metu vian sekretan ŝlosilon en ajnan retejon aŭ programaron, kiun vi ne fidas. - \n- Amethyst-programistoj **neniam** petos vian sekretan ŝlosilon. - \n- **Faru** sekuran sekurkopion de via sekreta ŝlosilo por reakiro de konto. Ni rekomendas uzi pasvortan administrilon. - </string> - <string name="account_backup_tips3_md"> Por plia sekureco, vi povas ĉifri vian ŝlosilon per pasvorto. Ĉi tiu ŝlosilo komenciĝas per **ncryptsec1** kaj ne povas esti uzata sen via pasvorto. - \n\nSe vi perdas vian pasvorton, vi ne povos reakiri vian ŝlosilon. - </string> + <string name="account_backup_tips2_md">## Konsiletoj pri Ŝlosila Sekurkopiao kaj Sekureco \n\nVia konto estas sekurigita per sekreta ŝlosilo. La ŝlosilo estas longa sinsekvo de signoj komencanta per **nsec1**. Ĉiu kiu havas aliron al ĉi tiu sekreta ŝlosilo povas afiŝi kaj ŝanĝi vian identecon. \n\n- **Ne** metu vian sekretan ŝlosilon en ajnan retejon aŭ programaron, kiun vi ne fidas. \n- Amethyst-programistoj **neniam** petos vian sekretan ŝlosilon. \n- **Faru** sekuran sekurkopion de via sekreta ŝlosilo por reakiro de konto. Ni rekomendas uzi pasvortan administrilon.</string> + <string name="account_backup_tips3_md">Por plia sekureco, vi povas ĉifri vian ŝlosilon per pasvorto. Ĉi tiu ŝlosilo komenciĝas per **ncryptsec1** kaj ne povas esti uzata sen via pasvorto. \n\nSe vi perdas vian pasvorton, vi ne povos reakiri vian ŝlosilon.</string> <string name="encrypt_and_copy_my_secret_key">Ĉifri kaj kopii mian seketan ŝlosilon</string> <string name="badge_award_image_for">Ŝildeto-premia bildo por %1$s</string> <string name="badge_award_image">"Insigna premibildeto</string> @@ -971,9 +964,7 @@ <string name="push_server_none_explainer">Malaktivigi Puŝajn Sciigojn</string> <string name="push_server_uses_app_explainer">Uzas aplikaĵon %1$s</string> <string name="push_server_install_app">Puŝa Sciig-Agordo</string> - <string name="push_server_install_app_description"> Por ricevi puŝajn sciigojn, instalu ajnan aplikaĵon kiu subtenas [Unified Push](https://unifiedpush.org/), kiel ekzemple [Ntfy](https://ntfy.sh/). - Post instalado, elektu la aplikaĵon, kiun vi volas uzi en la Agordoj. - </string> + <string name="push_server_install_app_description">Por ricevi puŝajn sciigojn, instalu ajnan aplikaĵon kiu subtenas [Unified Push](https://unifiedpush.org/), kiel ekzemple [Ntfy](https://ntfy.sh/). Post instalado, elektu la aplikaĵon, kiun vi volas uzi en la Agordoj.</string> <string name="thread_title">Fadeno</string> <string name="send_the_seller_a_message">Sendi mesaĝon al la vendisto</string> <string name="hi_there_is_this_still_available">Saluton, ĉu ĉi tio ankoraŭ disponeblas?</string> @@ -2031,15 +2022,7 @@ <string name="could_not_fetch_invoice_from_details">Ne eblis preni fakturon de %1$s: %2$s</string> <string name="could_not_open_the_compressed_file">Ne eblis malfermi la kunpremitan dosieron</string> <string name="couldnt_find_nwc_wallets">Monujo ne trovita</string> - <string name="couldnt_find_nwc_wallets_description">Amethyst ne povis trovi ajnajn monujo-aplikojn, kiuj subtenas Nostr Wallet Connect (NWC). -\n\nKion vi povas fari: -\n\t1. Kontrolu NWC-subtenon: Duoble kontrolu, ĉu via preferata monujo-aplikaĵo estas kongrua kun Nostr Wallet Connect. -\n\t2. Krei Konekton: Iru al via monujo-aplikaĵo kaj kreu novan NWC-konekton. -\n\t3. Ligi al Amethyst: Via monujo kutime donos al vi tri opciojn: -\n\t\t3.1 Aŭtomata Ligo: Butono por aŭtomate malfermi kaj konekti al Amethyst. Se jes, alklaku tiun butonon, malfermu Amethyst kaj alklaku konservi sur la nova ekrano. -\n\t\t3.2 Mana Ligo: Konekta URI, kiun vi povas kopii kaj alklaki la algluu-ikonon sur ĉi tiu ekrano por rekte enigi ĝin. -\n\t\t3.3 QR-Kodo: La monujo kreas QR-kodon, kiun vi povas legi per la QR-koda butono sur ĉi tiu ekrano. - </string> + <string name="couldnt_find_nwc_wallets_description">Amethyst ne povis trovi ajnajn monujo-aplikojn, kiuj subtenas Nostr Wallet Connect (NWC). \n\nKion vi povas fari: \n\t1. Kontrolu NWC-subtenon: Duoble kontrolu, ĉu via preferata monujo-aplikaĵo estas kongrua kun Nostr Wallet Connect. \n\t2. Krei Konekton: Iru al via monujo-aplikaĵo kaj kreu novan NWC-konekton. \n\t3. Ligi al Amethyst: Via monujo kutime donos al vi tri opciojn: \n\t\t3.1 Aŭtomata Ligo: Butono por aŭtomate malfermi kaj konekti al Amethyst. Se jes, alklaku tiun butonon, malfermu Amethyst kaj alklaku konservi sur la nova ekrano. \n\t\t3.2 Mana Ligo: Konekta URI, kiun vi povas kopii kaj alklaki la algluu-ikonon sur ĉi tiu ekrano por rekte enigi ĝin. \n\t\t3.3 QR-Kodo: La monujo kreas QR-kodon, kiun vi povas legi per la QR-koda butono sur ĉi tiu ekrano.</string> <string name="create">Krei</string> <string name="d">t</string> <string name="danger_zone">Danĝera Zono</string> @@ -2701,11 +2684,8 @@ <string name="proxy_section">Prokuro-Relay-oj</string> <string name="proxy_section_explainer">Agregataj relay-oj, kiujn la aplikaĵo devas uzi por elŝuti viajn fluojn, ekzemple filter.nostr.wine. Ĉi tio anstataŭas la sendkesta modelon kaj igos la aplikaĵon konektiĝi nur al la relay-oj en viaj listoj.</string> <string name="public_chat">Publika Babilado</string> - <string name="public_chat_explainer">Publikaj babilejoj estas videblaj al ĉiuj en Nostr kaj ĉiu ajn - povas partopreni en ili. Ili estas bonegaj por malfermitaj komunumoj pri specifaj temoj. - Moderado povas esti kontrolata per forigo de afiŝoj sur siaj relays</string> - <string name="public_chat_relays_explainer">Enigu inter 1–3 relays, kiuj gastigas ĉi tiun grupon. - Nostr-klientoj uzas ĉi tiun agordon por scii, kie elŝuti mesaĝojn kaj kien sendi viajn mesaĝojn.</string> + <string name="public_chat_explainer">Publikaj babilejoj estas videblaj al ĉiuj en Nostr kaj ĉiu ajn povas partopreni en ili. Ili estas bonegaj por malfermitaj komunumoj pri specifaj temoj. Moderado povas esti kontrolata per forigo de afiŝoj sur siaj relays</string> + <string name="public_chat_relays_explainer">Enigu inter 1–3 relays, kiuj gastigas ĉi tiun grupon. Nostr-klientoj uzas ĉi tiun agordon por scii, kie elŝuti mesaĝojn kaj kien sendi viajn mesaĝojn.</string> <string name="public_chat_relays_title">Relay-oj</string> <string name="public_chat_title">Publikaj Babilejaj Metadatenoj</string> <string name="public_chats">Publikaj Babilejoj</string> @@ -2730,10 +2710,7 @@ <string name="read_only_user">Nur-lega uzanto</string> <string name="record_a_message_description">Alklaku kaj tenu por registri mesaĝon</string> <string name="relay_chat">Relay-Babilejo</string> - <string name="relay_chat_explainer">Relay-babilejoj estas babilaj grupoj kontrolataj de sia hejma relay. - Ili estas videblaj al ĉiuj en Nostr kaj ĉiu ajn povas partopreni en ili. - Ili estas bonegaj por malfermitaj komunumoj pri specifaj temoj. Kelkaj el ĉi tiuj grupoj estas efemeraj - kaj tial babilmesaĝoj malaperas kun la tempo</string> + <string name="relay_chat_explainer">Relay-babilejoj estas babilaj grupoj kontrolataj de sia hejma relay. Ili estas videblaj al ĉiuj en Nostr kaj ĉiu ajn povas partopreni en ili. Ili estas bonegaj por malfermitaj komunumoj pri specifaj temoj. Kelkaj el ĉi tiuj grupoj estas efemeraj kaj tial babilmesaĝoj malaperas kun la tempo</string> <string name="relay_chat_title">Relay-Babilejaroj</string> <string name="relay_management_tab_events">Eventoj</string> <string name="relay_management_tab_ips">IP-oj</string> diff --git a/commonsUI/src/commonMain/composeResources/values-eo/strings.xml b/commonsUI/src/commonMain/composeResources/values-eo/strings.xml index f9cf0c8844..ef18a4b455 100644 --- a/commonsUI/src/commonMain/composeResources/values-eo/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-eo/strings.xml @@ -211,7 +211,7 @@ <string name="copy_user_id">Kopii ID de uzanto</string> <string name="unblock_user">Malbloki Uzanton</string> <string name="user_is_blocked_hidden">Tiu ĉi uzanto estas blokita/silentita. Iliaj afiŝoj estas kaŝitaj.</string> - <string name="npub_hex_username">npub, hekso, uzantnomo </string> + <string name="npub_hex_username">npub, hekso, uzantnomo</string> <string name="clear">Klarigi</string> <string name="app_logo">Logo de Apo</string> <string name="nsec_npub_hex_private_key">nsec / npub / heksa privata ŝlosilo</string> @@ -259,17 +259,8 @@ <string name="mark_all_known_as_read">Marki ĉiujn Konatajn legitaj</string> <string name="mark_all_new_as_read">Marki ĉiujn Novajn legitaj</string> <string name="mark_all_as_read">Marki ĉiujn legitajn</string> - <string name="account_backup_tips2_md"> - ## Konsiletoj pri Ŝlosila Sekurkopiao kaj Sekureco - \n\nVia konto estas sekurigita per sekreta ŝlosilo. La ŝlosilo estas longa sinsekvo de signoj komencanta per **nsec1**. Ĉiu kiu havas aliron al ĉi tiu sekreta ŝlosilo povas afiŝi kaj ŝanĝi vian identecon. - \n\n- **Ne** metu vian sekretan ŝlosilon en ajnan retejon aŭ programaron, kiun vi ne fidas. - \n- Amethyst-programistoj **neniam** petos vian sekretan ŝlosilon. - \n- **Faru** sekuran sekurkopion de via sekreta ŝlosilo por reakiro de konto. Ni rekomendas uzi pasvortan administrilon. - </string> - <string name="account_backup_tips3_md"> - Por plia sekureco, vi povas ĉifri vian ŝlosilon per pasvorto. Ĉi tiu ŝlosilo komenciĝas per **ncryptsec1** kaj ne povas esti uzata sen via pasvorto. - \n\nSe vi perdas vian pasvorton, vi ne povos reakiri vian ŝlosilon. - </string> + <string name="account_backup_tips2_md">## Konsiletoj pri Ŝlosila Sekurkopiao kaj Sekureco \n\nVia konto estas sekurigita per sekreta ŝlosilo. La ŝlosilo estas longa sinsekvo de signoj komencanta per **nsec1**. Ĉiu kiu havas aliron al ĉi tiu sekreta ŝlosilo povas afiŝi kaj ŝanĝi vian identecon. \n\n- **Ne** metu vian sekretan ŝlosilon en ajnan retejon aŭ programaron, kiun vi ne fidas. \n- Amethyst-programistoj **neniam** petos vian sekretan ŝlosilon. \n- **Faru** sekuran sekurkopion de via sekreta ŝlosilo por reakiro de konto. Ni rekomendas uzi pasvortan administrilon.</string> + <string name="account_backup_tips3_md">Por plia sekureco, vi povas ĉifri vian ŝlosilon per pasvorto. Ĉi tiu ŝlosilo komenciĝas per **ncryptsec1** kaj ne povas esti uzata sen via pasvorto. \n\nSe vi perdas vian pasvorton, vi ne povos reakiri vian ŝlosilon.</string> <string name="encrypt_and_copy_my_secret_key">Ĉifri kaj kopii mian seketan ŝlosilon</string> <string name="badge_award_image_for">Ŝildeto-premia bildo por %1$s</string> <string name="badge_award_image">"Insigna premibildeto</string> @@ -962,10 +953,7 @@ <string name="push_server_none_explainer">Malaktivigi Puŝajn Sciigojn</string> <string name="push_server_uses_app_explainer">Uzas aplikaĵon %1$s</string> <string name="push_server_install_app">Puŝa Sciig-Agordo</string> - <string name="push_server_install_app_description"> - Por ricevi puŝajn sciigojn, instalu ajnan aplikaĵon kiu subtenas [Unified Push](https://unifiedpush.org/), kiel ekzemple [Ntfy](https://ntfy.sh/). - Post instalado, elektu la aplikaĵon, kiun vi volas uzi en la Agordoj. - </string> + <string name="push_server_install_app_description">Por ricevi puŝajn sciigojn, instalu ajnan aplikaĵon kiu subtenas [Unified Push](https://unifiedpush.org/), kiel ekzemple [Ntfy](https://ntfy.sh/). Post instalado, elektu la aplikaĵon, kiun vi volas uzi en la Agordoj.</string> <string name="thread_title">Fadeno</string> <string name="send_the_seller_a_message">Sendi mesaĝon al la vendisto</string> <string name="hi_there_is_this_still_available">Saluton, ĉu ĉi tio ankoraŭ disponeblas?</string> @@ -2058,15 +2046,7 @@ <string name="could_not_fetch_invoice_from_details">Ne eblis preni fakturon de %1$s: %2$s</string> <string name="could_not_open_the_compressed_file">Ne eblis malfermi la kunpremitan dosieron</string> <string name="couldnt_find_nwc_wallets">Monujo ne trovita</string> - <string name="couldnt_find_nwc_wallets_description">Amethyst ne povis trovi ajnajn monujo-aplikojn, kiuj subtenas Nostr Wallet Connect (NWC). -\n\nKion vi povas fari: -\n\t1. Kontrolu NWC-subtenon: Duoble kontrolu, ĉu via preferata monujo-aplikaĵo estas kongrua kun Nostr Wallet Connect. -\n\t2. Krei Konekton: Iru al via monujo-aplikaĵo kaj kreu novan NWC-konekton. -\n\t3. Ligi al Amethyst: Via monujo kutime donos al vi tri opciojn: -\n\t\t3.1 Aŭtomata Ligo: Butono por aŭtomate malfermi kaj konekti al Amethyst. Se jes, alklaku tiun butonon, malfermu Amethyst kaj alklaku konservi sur la nova ekrano. -\n\t\t3.2 Mana Ligo: Konekta URI, kiun vi povas kopii kaj alklaki la algluu-ikonon sur ĉi tiu ekrano por rekte enigi ĝin. -\n\t\t3.3 QR-Kodo: La monujo kreas QR-kodon, kiun vi povas legi per la QR-koda butono sur ĉi tiu ekrano. - </string> + <string name="couldnt_find_nwc_wallets_description">Amethyst ne povis trovi ajnajn monujo-aplikojn, kiuj subtenas Nostr Wallet Connect (NWC). \n\nKion vi povas fari: \n\t1. Kontrolu NWC-subtenon: Duoble kontrolu, ĉu via preferata monujo-aplikaĵo estas kongrua kun Nostr Wallet Connect. \n\t2. Krei Konekton: Iru al via monujo-aplikaĵo kaj kreu novan NWC-konekton. \n\t3. Ligi al Amethyst: Via monujo kutime donos al vi tri opciojn: \n\t\t3.1 Aŭtomata Ligo: Butono por aŭtomate malfermi kaj konekti al Amethyst. Se jes, alklaku tiun butonon, malfermu Amethyst kaj alklaku konservi sur la nova ekrano. \n\t\t3.2 Mana Ligo: Konekta URI, kiun vi povas kopii kaj alklaki la algluu-ikonon sur ĉi tiu ekrano por rekte enigi ĝin. \n\t\t3.3 QR-Kodo: La monujo kreas QR-kodon, kiun vi povas legi per la QR-koda butono sur ĉi tiu ekrano.</string> <string name="create">Krei</string> <string name="d">t</string> <string name="danger_zone">Danĝera Zono</string> @@ -2754,11 +2734,8 @@ <string name="proxy_section">Prokuro-Relay-oj</string> <string name="proxy_section_explainer">Agregataj relay-oj, kiujn la aplikaĵo devas uzi por elŝuti viajn fluojn, ekzemple filter.nostr.wine. Ĉi tio anstataŭas la sendkesta modelon kaj igos la aplikaĵon konektiĝi nur al la relay-oj en viaj listoj.</string> <string name="public_chat">Publika Babilado</string> - <string name="public_chat_explainer">Publikaj babilejoj estas videblaj al ĉiuj en Nostr kaj ĉiu ajn - povas partopreni en ili. Ili estas bonegaj por malfermitaj komunumoj pri specifaj temoj. - Moderado povas esti kontrolata per forigo de afiŝoj sur siaj relays</string> - <string name="public_chat_relays_explainer">Enigu inter 1–3 relays, kiuj gastigas ĉi tiun grupon. - Nostr-klientoj uzas ĉi tiun agordon por scii, kie elŝuti mesaĝojn kaj kien sendi viajn mesaĝojn.</string> + <string name="public_chat_explainer">Publikaj babilejoj estas videblaj al ĉiuj en Nostr kaj ĉiu ajn povas partopreni en ili. Ili estas bonegaj por malfermitaj komunumoj pri specifaj temoj. Moderado povas esti kontrolata per forigo de afiŝoj sur siaj relays</string> + <string name="public_chat_relays_explainer">Enigu inter 1–3 relays, kiuj gastigas ĉi tiun grupon. Nostr-klientoj uzas ĉi tiun agordon por scii, kie elŝuti mesaĝojn kaj kien sendi viajn mesaĝojn.</string> <string name="public_chat_relays_title">Relay-oj</string> <string name="public_chat_title">Publikaj Babilejaj Metadatenoj</string> <string name="public_chats">Publikaj Babilejoj</string> @@ -2783,10 +2760,7 @@ <string name="read_only_user">Nur-lega uzanto</string> <string name="record_a_message_description">Alklaku kaj tenu por registri mesaĝon</string> <string name="relay_chat">Relay-Babilejo</string> - <string name="relay_chat_explainer">Relay-babilejoj estas babilaj grupoj kontrolataj de sia hejma relay. - Ili estas videblaj al ĉiuj en Nostr kaj ĉiu ajn povas partopreni en ili. - Ili estas bonegaj por malfermitaj komunumoj pri specifaj temoj. Kelkaj el ĉi tiuj grupoj estas efemeraj - kaj tial babilmesaĝoj malaperas kun la tempo</string> + <string name="relay_chat_explainer">Relay-babilejoj estas babilaj grupoj kontrolataj de sia hejma relay. Ili estas videblaj al ĉiuj en Nostr kaj ĉiu ajn povas partopreni en ili. Ili estas bonegaj por malfermitaj komunumoj pri specifaj temoj. Kelkaj el ĉi tiuj grupoj estas efemeraj kaj tial babilmesaĝoj malaperas kun la tempo</string> <string name="relay_chat_title">Relay-Babilejaroj</string> <string name="relay_management_tab_events">Eventoj</string> <string name="relay_management_tab_ips">IP-oj</string> diff --git a/commonsUI/src/commonMain/composeResources/values-es-rES/strings.xml b/commonsUI/src/commonMain/composeResources/values-es-rES/strings.xml index ee9c2f0a5b..45b069c10c 100644 --- a/commonsUI/src/commonMain/composeResources/values-es-rES/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-es-rES/strings.xml @@ -224,7 +224,7 @@ <string name="copy_user_id">Copiar ID de usuario</string> <string name="unblock_user">Desbloquear usuario</string> <string name="user_is_blocked_hidden">Este usuario está bloqueado/silenciado. Sus publicaciones están ocultas.</string> - <string name="npub_hex_username">npub, hex, username </string> + <string name="npub_hex_username">npub, hex, username</string> <string name="clear">Limpiar</string> <string name="app_logo">Logo de la app</string> <string name="nsec_npub_hex_private_key">nsec o npub</string> @@ -272,15 +272,8 @@ <string name="mark_all_known_as_read">Marcar los conocidos como leídos</string> <string name="mark_all_new_as_read">Marcar los nuevos como leídos</string> <string name="mark_all_as_read">Marcar todos como leídos</string> - <string name="account_backup_tips2_md"> ## Consejos sobre la protección y la copia de seguridad de las claves - \n\nTu cuenta está protegida por una clave secreta. La clave es una larga secuencia de caracteres que empieza por **nsec1**. Cualquiera que tenga acceso a esta clave secreta puede publicar y cambiar tu identidad. - \n\n- **No** guardes la clave secreta en ningún sitio web o software en los que no confíes. - \n- Los desarrolladores de Amethyst **nunca** te pedirán la clave secreta. - \n- **Sí** puedes realizar una copia de seguridad de la clave secreta para recuperar la cuenta. Para ello, te recomendamos usar un gestor de contraseñas. - </string> - <string name="account_backup_tips3_md"> Para mayor seguridad, puedes cifrar la clave con una contraseña. Esta clave empieza por **ncryptsec1** y no puede utilizarse sin tu contraseña. - \n\nSi pierdes la contraseña, no podrás recuperar tu clave. - </string> + <string name="account_backup_tips2_md">## Consejos sobre la protección y la copia de seguridad de las claves \n\nTu cuenta está protegida por una clave secreta. La clave es una larga secuencia de caracteres que empieza por **nsec1**. Cualquiera que tenga acceso a esta clave secreta puede publicar y cambiar tu identidad. \n\n- **No** guardes la clave secreta en ningún sitio web o software en los que no confíes. \n- Los desarrolladores de Amethyst **nunca** te pedirán la clave secreta. \n- **Sí** puedes realizar una copia de seguridad de la clave secreta para recuperar la cuenta. Para ello, te recomendamos usar un gestor de contraseñas.</string> + <string name="account_backup_tips3_md">Para mayor seguridad, puedes cifrar la clave con una contraseña. Esta clave empieza por **ncryptsec1** y no puede utilizarse sin tu contraseña. \n\nSi pierdes la contraseña, no podrás recuperar tu clave.</string> <string name="encrypt_and_copy_my_secret_key">Cifrar y copiar mi clave secreta</string> <string name="badge_award_image_for">Imagen del Badge para %1$s</string> <string name="badge_award_image">"Imagen de premio de insignia</string> @@ -962,9 +955,7 @@ <string name="push_server_none_explainer">Desactiva las notificaciones push</string> <string name="push_server_uses_app_explainer">Usa la app %1$s</string> <string name="push_server_install_app">Configuración de notificaciones push</string> - <string name="push_server_install_app_description"> Para recibir notificaciones push, instala cualquier aplicación compatible con [Unified Push](https://unifiedpush.org/), como [Ntfy](https://ntfy.sh/). - Tras la instalación, selecciona la aplicación que deseas usar en la configuración. - </string> + <string name="push_server_install_app_description">Para recibir notificaciones push, instala cualquier aplicación compatible con [Unified Push](https://unifiedpush.org/), como [Ntfy](https://ntfy.sh/). Tras la instalación, selecciona la aplicación que deseas usar en la configuración.</string> <string name="thread_title">Conversación</string> <string name="send_the_seller_a_message">Envía un mensaje al vendedor</string> <string name="hi_there_is_this_still_available">Hola, ¿esto todavía está disponible?</string> @@ -2028,15 +2019,7 @@ <string name="could_not_fetch_invoice_from_details">No se ha podido obtener la factura de %1$s: %2$s</string> <string name="could_not_open_the_compressed_file">No se pudo abrir el archivo comprimido</string> <string name="couldnt_find_nwc_wallets">Cartera no encontrada</string> - <string name="couldnt_find_nwc_wallets_description">Amethyst no encontró ninguna aplicación de cartera compatible con Nostr Wallet Connect (NWC). -\n\nQué puedes hacer: -\n\t1. Verificar la compatibilidad con NWC: Comprueba que tu aplicación de cartera preferida es compatible con Nostr Wallet Connect. -\n\t2. Crear conexión: Ve a tu aplicación de cartera y crea una nueva conexión NWC. -\n\t3. Vincular con Amethyst: Tu cartera normalmente te ofrecerá tres opciones: -\n\t\t3.1 Enlace automático: Un botón para abrir y conectar automáticamente a Amethyst. Si es así, pulsa ese botón, abre Amethyst y guarda en la nueva pantalla. -\n\t\t3.2 Enlace manual: Un URI de conexión que puedes copiar y pegar usando el icono de pegado en esta pantalla para introducirlo directamente. -\n\t\t3.3 Código QR: La cartera genera un código QR que puedes leer con el botón de código QR en esta pantalla. - </string> + <string name="couldnt_find_nwc_wallets_description">Amethyst no encontró ninguna aplicación de cartera compatible con Nostr Wallet Connect (NWC). \n\nQué puedes hacer: \n\t1. Verificar la compatibilidad con NWC: Comprueba que tu aplicación de cartera preferida es compatible con Nostr Wallet Connect. \n\t2. Crear conexión: Ve a tu aplicación de cartera y crea una nueva conexión NWC. \n\t3. Vincular con Amethyst: Tu cartera normalmente te ofrecerá tres opciones: \n\t\t3.1 Enlace automático: Un botón para abrir y conectar automáticamente a Amethyst. Si es así, pulsa ese botón, abre Amethyst y guarda en la nueva pantalla. \n\t\t3.2 Enlace manual: Un URI de conexión que puedes copiar y pegar usando el icono de pegado en esta pantalla para introducirlo directamente. \n\t\t3.3 Código QR: La cartera genera un código QR que puedes leer con el botón de código QR en esta pantalla.</string> <string name="create">Crear</string> <string name="d">d</string> <string name="danger_zone">Zona peligrosa</string> @@ -2715,11 +2698,8 @@ <string name="proxy_section">Relés de proxy</string> <string name="proxy_section_explainer">Relés recopiladores que la app debe usar para descargar tus fuentes, como filter.nostr.wine. Esto reemplaza el modelo de bandeja de salida y hará que la app se conecte solo a los relés de tus listas.</string> <string name="public_chat">Chat público</string> - <string name="public_chat_explainer">Los chats públicos son visibles para todos en Nostr y cualquiera - puede participar en ellos. Son ideales para comunidades abiertas alrededor de temas específicos. - La moderación puede controlarse eliminando publicaciones en sus relés.</string> - <string name="public_chat_relays_explainer">Inserta entre 1 y 3 relés que alojan este grupo. - Los clientes de Nostr utilizan esta configuración para saber de dónde descargar y adónde enviar los mensajes.</string> + <string name="public_chat_explainer">Los chats públicos son visibles para todos en Nostr y cualquiera puede participar en ellos. Son ideales para comunidades abiertas alrededor de temas específicos. La moderación puede controlarse eliminando publicaciones en sus relés.</string> + <string name="public_chat_relays_explainer">Inserta entre 1 y 3 relés que alojan este grupo. Los clientes de Nostr utilizan esta configuración para saber de dónde descargar y adónde enviar los mensajes.</string> <string name="public_chat_relays_title">Relés</string> <string name="public_chat_title">Metadatos públicos del chat</string> <string name="public_chats">Chats públicos</string> @@ -2744,10 +2724,7 @@ <string name="read_only_user">Usuario de solo lectura</string> <string name="record_a_message_description">Haz clic y mantén pulsado para grabar un mensaje</string> <string name="relay_chat">Chat en relé</string> - <string name="relay_chat_explainer">Los chats en relé son grupos de chat controlados por su relé de origen. - Son visibles para todos los usuarios de Nostr y cualquiera puede participar en ellos. - Son ideales para comunidades abiertas en torno a temas específicos. Algunos de estos grupos son efímeros - y, por lo tanto, los mensajes del chat desaparecen con el tiempo</string> + <string name="relay_chat_explainer">Los chats en relé son grupos de chat controlados por su relé de origen. Son visibles para todos los usuarios de Nostr y cualquiera puede participar en ellos. Son ideales para comunidades abiertas en torno a temas específicos. Algunos de estos grupos son efímeros y, por lo tanto, los mensajes del chat desaparecen con el tiempo</string> <string name="relay_chat_title">Chats en relé</string> <string name="relay_management_tab_events">Eventos</string> <string name="relay_management_tab_kinds">Tipos</string> diff --git a/commonsUI/src/commonMain/composeResources/values-es-rMX/strings.xml b/commonsUI/src/commonMain/composeResources/values-es-rMX/strings.xml index f3e6fa1ca8..d941f9ea99 100644 --- a/commonsUI/src/commonMain/composeResources/values-es-rMX/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-es-rMX/strings.xml @@ -272,15 +272,8 @@ <string name="mark_all_known_as_read">Marcar todos los conocidos como leídos</string> <string name="mark_all_new_as_read">Marcar todos los nuevos como leídos</string> <string name="mark_all_as_read">Marcar todos como leídos</string> - <string name="account_backup_tips2_md"> ## Consejos sobre la protección y la copia de seguridad de las claves - \n\nTu cuenta está protegida por una clave secreta. La clave es una larga secuencia de caracteres que empieza por **nsec1**. Cualquiera que tenga acceso a esta clave secreta puede publicar y cambiar tu identidad. - \n\n- **No** guardes la clave secreta en ningún sitio web o software en los que no confíes. - \n- Los desarrolladores de Amethyst **nunca** te pedirán la clave secreta. - \n- **Sí** puedes realizar una copia de seguridad de la clave secreta para recuperar la cuenta. Para ello, te recomendamos usar un gestor de contraseñas. - </string> - <string name="account_backup_tips3_md"> Para mayor seguridad, puedes cifrar la clave con una contraseña. Esta clave empieza por **ncryptsec1** y no puede utilizarse sin tu contraseña. - \n\nSi pierdes la contraseña, no podrás recuperar tu clave. - </string> + <string name="account_backup_tips2_md">## Consejos sobre la protección y la copia de seguridad de las claves \n\nTu cuenta está protegida por una clave secreta. La clave es una larga secuencia de caracteres que empieza por **nsec1**. Cualquiera que tenga acceso a esta clave secreta puede publicar y cambiar tu identidad. \n\n- **No** guardes la clave secreta en ningún sitio web o software en los que no confíes. \n- Los desarrolladores de Amethyst **nunca** te pedirán la clave secreta. \n- **Sí** puedes realizar una copia de seguridad de la clave secreta para recuperar la cuenta. Para ello, te recomendamos usar un gestor de contraseñas.</string> + <string name="account_backup_tips3_md">Para mayor seguridad, puedes cifrar la clave con una contraseña. Esta clave empieza por **ncryptsec1** y no puede utilizarse sin tu contraseña. \n\nSi pierdes la contraseña, no podrás recuperar tu clave.</string> <string name="encrypt_and_copy_my_secret_key">Cifrar y copiar mi clave secreta</string> <string name="badge_award_image_for">Imagen de premio de insignia por %1$s</string> <string name="badge_award_image">"Imagen de premio de insignia</string> @@ -961,9 +954,7 @@ <string name="push_server_none_explainer">Desactiva las notificaciones push</string> <string name="push_server_uses_app_explainer">Usa la app %1$s</string> <string name="push_server_install_app">Configuración de notificaciones push</string> - <string name="push_server_install_app_description"> Para recibir notificaciones push, instala cualquier aplicación compatible con [Unified Push](https://unifiedpush.org/), como [Ntfy](https://ntfy.sh/). - Tras la instalación, selecciona la aplicación que deseas usar en la configuración. - </string> + <string name="push_server_install_app_description">Para recibir notificaciones push, instala cualquier aplicación compatible con [Unified Push](https://unifiedpush.org/), como [Ntfy](https://ntfy.sh/). Tras la instalación, selecciona la aplicación que deseas usar en la configuración.</string> <string name="thread_title">Hilo</string> <string name="send_the_seller_a_message">Envía un mensaje al vendedor</string> <string name="hi_there_is_this_still_available">Hola, ¿esto todavía está disponible?</string> @@ -2027,15 +2018,7 @@ <string name="could_not_fetch_invoice_from_details">No se pudo obtener la factura de %1$s: %2$s</string> <string name="could_not_open_the_compressed_file">No se pudo abrir el archivo comprimido</string> <string name="couldnt_find_nwc_wallets">Billetera no encontrada</string> - <string name="couldnt_find_nwc_wallets_description">Amethyst no encontró ninguna aplicación de cartera compatible con Nostr Wallet Connect (NWC). -\n\nQué puedes hacer: -\n\t1. Verificar la compatibilidad con NWC: Comprueba que tu aplicación de cartera preferida es compatible con Nostr Wallet Connect. -\n\t2. Crear conexión: Ve a tu aplicación de cartera y crea una nueva conexión NWC. -\n\t3. Vincular con Amethyst: Tu cartera normalmente te ofrecerá tres opciones: -\n\t\t3.1 Enlace automático: Un botón para abrir y conectar automáticamente a Amethyst. Si es así, pulsa ese botón, abre Amethyst y guarda en la nueva pantalla. -\n\t\t3.2 Enlace manual: Un URI de conexión que puedes copiar y pegar usando el icono de pegado en esta pantalla para introducirlo directamente. -\n\t\t3.3 Código QR: La cartera genera un código QR que puedes leer con el botón de código QR en esta pantalla. - </string> + <string name="couldnt_find_nwc_wallets_description">Amethyst no encontró ninguna aplicación de cartera compatible con Nostr Wallet Connect (NWC). \n\nQué puedes hacer: \n\t1. Verificar la compatibilidad con NWC: Comprueba que tu aplicación de cartera preferida es compatible con Nostr Wallet Connect. \n\t2. Crear conexión: Ve a tu aplicación de cartera y crea una nueva conexión NWC. \n\t3. Vincular con Amethyst: Tu cartera normalmente te ofrecerá tres opciones: \n\t\t3.1 Enlace automático: Un botón para abrir y conectar automáticamente a Amethyst. Si es así, pulsa ese botón, abre Amethyst y guarda en la nueva pantalla. \n\t\t3.2 Enlace manual: Un URI de conexión que puedes copiar y pegar usando el icono de pegado en esta pantalla para introducirlo directamente. \n\t\t3.3 Código QR: La cartera genera un código QR que puedes leer con el botón de código QR en esta pantalla.</string> <string name="create">Crear</string> <string name="d">d</string> <string name="danger_zone">Zona peligrosa</string> @@ -2708,11 +2691,8 @@ <string name="proxy_section">Relés de proxy</string> <string name="proxy_section_explainer">Relés recopiladores que la app debe usar para descargar tus feeds, como filter.nostr.wine. Esto reemplaza el modelo de bandeja de salida y hará que la app se conecte solo a los relés de tus listas.</string> <string name="public_chat">Chat público</string> - <string name="public_chat_explainer">Los chats públicos son visibles para todos en Nostr y cualquiera - puede participar en ellos. Son ideales para comunidades abiertas alrededor de temas específicos. - La moderación puede controlarse eliminando publicaciones en sus relés.</string> - <string name="public_chat_relays_explainer">Inserta entre 1 y 3 relés que alojan este grupo. - Los clientes de Nostr utilizan esta configuración para saber de dónde descargar y adónde enviar los mensajes.</string> + <string name="public_chat_explainer">Los chats públicos son visibles para todos en Nostr y cualquiera puede participar en ellos. Son ideales para comunidades abiertas alrededor de temas específicos. La moderación puede controlarse eliminando publicaciones en sus relés.</string> + <string name="public_chat_relays_explainer">Inserta entre 1 y 3 relés que alojan este grupo. Los clientes de Nostr utilizan esta configuración para saber de dónde descargar y adónde enviar los mensajes.</string> <string name="public_chat_relays_title">Relés</string> <string name="public_chat_title">Metadatos públicos del chat</string> <string name="public_chats">Chats públicos</string> @@ -2737,10 +2717,7 @@ <string name="read_only_user">Usuario de solo lectura</string> <string name="record_a_message_description">Haz clic y mantén presionado para grabar un mensaje</string> <string name="relay_chat">Chat en relé</string> - <string name="relay_chat_explainer">Los chats en relé son grupos de chat controlados por su relé de origen. - Son visibles para todos los usuarios de Nostr y cualquiera puede participar en ellos. - Son ideales para comunidades abiertas en torno a temas específicos. Algunos de estos grupos son efímeros - y, por lo tanto, los mensajes del chat desaparecen con el tiempo</string> + <string name="relay_chat_explainer">Los chats en relé son grupos de chat controlados por su relé de origen. Son visibles para todos los usuarios de Nostr y cualquiera puede participar en ellos. Son ideales para comunidades abiertas en torno a temas específicos. Algunos de estos grupos son efímeros y, por lo tanto, los mensajes del chat desaparecen con el tiempo</string> <string name="relay_chat_title">Chats en relé</string> <string name="relay_management_tab_events">Eventos</string> <string name="relay_management_tab_kinds">Tipos</string> diff --git a/commonsUI/src/commonMain/composeResources/values-es-rUS/strings.xml b/commonsUI/src/commonMain/composeResources/values-es-rUS/strings.xml index a86f5c2aaf..3e6060ab9c 100644 --- a/commonsUI/src/commonMain/composeResources/values-es-rUS/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-es-rUS/strings.xml @@ -272,15 +272,8 @@ <string name="mark_all_known_as_read">Marcar todos los conocidos como leídos</string> <string name="mark_all_new_as_read">Marcar todos los nuevos como leídos</string> <string name="mark_all_as_read">Marcar todos como leídos</string> - <string name="account_backup_tips2_md"> ## Consejos sobre la protección y la copia de seguridad de las claves - \n\nTu cuenta está protegida por una clave secreta. La clave es una larga secuencia de caracteres que empieza por **nsec1**. Cualquiera que tenga acceso a esta clave secreta puede publicar y cambiar tu identidad. - \n\n- **No** guardes la clave secreta en ningún sitio web o software en los que no confíes. - \n- Los desarrolladores de Amethyst **nunca** te pedirán la clave secreta. - \n- **Sí** puedes realizar una copia de seguridad de la clave secreta para recuperar la cuenta. Para ello, te recomendamos usar un gestor de contraseñas. - </string> - <string name="account_backup_tips3_md"> Para mayor seguridad, puedes cifrar la clave con una contraseña. Esta clave empieza por **ncryptsec1** y no puede utilizarse sin tu contraseña. - \n\nSi pierdes la contraseña, no podrás recuperar tu clave. - </string> + <string name="account_backup_tips2_md">## Consejos sobre la protección y la copia de seguridad de las claves \n\nTu cuenta está protegida por una clave secreta. La clave es una larga secuencia de caracteres que empieza por **nsec1**. Cualquiera que tenga acceso a esta clave secreta puede publicar y cambiar tu identidad. \n\n- **No** guardes la clave secreta en ningún sitio web o software en los que no confíes. \n- Los desarrolladores de Amethyst **nunca** te pedirán la clave secreta. \n- **Sí** puedes realizar una copia de seguridad de la clave secreta para recuperar la cuenta. Para ello, te recomendamos usar un gestor de contraseñas.</string> + <string name="account_backup_tips3_md">Para mayor seguridad, puedes cifrar la clave con una contraseña. Esta clave empieza por **ncryptsec1** y no puede utilizarse sin tu contraseña. \n\nSi pierdes la contraseña, no podrás recuperar tu clave.</string> <string name="encrypt_and_copy_my_secret_key">Cifrar y copiar mi clave secreta</string> <string name="badge_award_image_for">Imagen de premio de insignia por %1$s</string> <string name="badge_award_image">"Imagen de premio de insignia</string> @@ -961,9 +954,7 @@ <string name="push_server_none_explainer">Desactiva las notificaciones push</string> <string name="push_server_uses_app_explainer">Usa la app %1$s</string> <string name="push_server_install_app">Configuración de notificaciones push</string> - <string name="push_server_install_app_description"> Para recibir notificaciones push, instala cualquier aplicación compatible con [Unified Push](https://unifiedpush.org/), como [Ntfy](https://ntfy.sh/). - Tras la instalación, selecciona la aplicación que deseas usar en la configuración. - </string> + <string name="push_server_install_app_description">Para recibir notificaciones push, instala cualquier aplicación compatible con [Unified Push](https://unifiedpush.org/), como [Ntfy](https://ntfy.sh/). Tras la instalación, selecciona la aplicación que deseas usar en la configuración.</string> <string name="thread_title">Hilo</string> <string name="send_the_seller_a_message">Envía un mensaje al vendedor</string> <string name="hi_there_is_this_still_available">Hola, ¿esto todavía está disponible?</string> @@ -2027,15 +2018,7 @@ <string name="could_not_fetch_invoice_from_details">No se pudo obtener la factura de %1$s: %2$s</string> <string name="could_not_open_the_compressed_file">No se pudo abrir el archivo comprimido</string> <string name="couldnt_find_nwc_wallets">Billetera no encontrada</string> - <string name="couldnt_find_nwc_wallets_description">Amethyst no encontró ninguna aplicación de cartera compatible con Nostr Wallet Connect (NWC). -\n\nQué puedes hacer: -\n\t1. Verificar la compatibilidad con NWC: Comprueba que tu aplicación de cartera preferida es compatible con Nostr Wallet Connect. -\n\t2. Crear conexión: Ve a tu aplicación de cartera y crea una nueva conexión NWC. -\n\t3. Vincular con Amethyst: Tu cartera normalmente te ofrecerá tres opciones: -\n\t\t3.1 Enlace automático: Un botón para abrir y conectar automáticamente a Amethyst. Si es así, pulsa ese botón, abre Amethyst y guarda en la nueva pantalla. -\n\t\t3.2 Enlace manual: Un URI de conexión que puedes copiar y pegar usando el icono de pegado en esta pantalla para introducirlo directamente. -\n\t\t3.3 Código QR: La cartera genera un código QR que puedes leer con el botón de código QR en esta pantalla. - </string> + <string name="couldnt_find_nwc_wallets_description">Amethyst no encontró ninguna aplicación de cartera compatible con Nostr Wallet Connect (NWC). \n\nQué puedes hacer: \n\t1. Verificar la compatibilidad con NWC: Comprueba que tu aplicación de cartera preferida es compatible con Nostr Wallet Connect. \n\t2. Crear conexión: Ve a tu aplicación de cartera y crea una nueva conexión NWC. \n\t3. Vincular con Amethyst: Tu cartera normalmente te ofrecerá tres opciones: \n\t\t3.1 Enlace automático: Un botón para abrir y conectar automáticamente a Amethyst. Si es así, pulsa ese botón, abre Amethyst y guarda en la nueva pantalla. \n\t\t3.2 Enlace manual: Un URI de conexión que puedes copiar y pegar usando el icono de pegado en esta pantalla para introducirlo directamente. \n\t\t3.3 Código QR: La cartera genera un código QR que puedes leer con el botón de código QR en esta pantalla.</string> <string name="create">Crear</string> <string name="d">d</string> <string name="danger_zone">Zona peligrosa</string> @@ -2708,11 +2691,8 @@ <string name="proxy_section">Relés de proxy</string> <string name="proxy_section_explainer">Relés recopiladores que la app debe usar para descargar tus feeds, como filter.nostr.wine. Esto reemplaza el modelo de bandeja de salida y hará que la app se conecte solo a los relés de tus listas.</string> <string name="public_chat">Chat público</string> - <string name="public_chat_explainer">Los chats públicos son visibles para todos en Nostr y cualquiera - puede participar en ellos. Son ideales para comunidades abiertas alrededor de temas específicos. - La moderación puede controlarse eliminando mensajes en sus relés.</string> - <string name="public_chat_relays_explainer">Inserta entre 1 y 3 relés que alojan este grupo. - Los clientes de Nostr utilizan esta configuración para saber de dónde descargar y adónde enviar los mensajes.</string> + <string name="public_chat_explainer">Los chats públicos son visibles para todos en Nostr y cualquiera puede participar en ellos. Son ideales para comunidades abiertas alrededor de temas específicos. La moderación puede controlarse eliminando mensajes en sus relés.</string> + <string name="public_chat_relays_explainer">Inserta entre 1 y 3 relés que alojan este grupo. Los clientes de Nostr utilizan esta configuración para saber de dónde descargar y adónde enviar los mensajes.</string> <string name="public_chat_relays_title">Relés</string> <string name="public_chat_title">Metadatos públicos del chat</string> <string name="public_chats">Chats públicos</string> @@ -2737,10 +2717,7 @@ <string name="read_only_user">Usuario de solo lectura</string> <string name="record_a_message_description">Haz clic y mantén presionado para grabar un mensaje</string> <string name="relay_chat">Chat en relé</string> - <string name="relay_chat_explainer">Los chats en relé son grupos de chat controlados por su relé de origen. - Son visibles para todos los usuarios de Nostr y cualquiera puede participar en ellos. - Son ideales para comunidades abiertas en torno a temas específicos. Algunos de estos grupos son efímeros - y, por lo tanto, los mensajes del chat desaparecen con el tiempo</string> + <string name="relay_chat_explainer">Los chats en relé son grupos de chat controlados por su relé de origen. Son visibles para todos los usuarios de Nostr y cualquiera puede participar en ellos. Son ideales para comunidades abiertas en torno a temas específicos. Algunos de estos grupos son efímeros y, por lo tanto, los mensajes del chat desaparecen con el tiempo</string> <string name="relay_chat_title">Chats en relé</string> <string name="relay_management_tab_events">Eventos</string> <string name="relay_management_tab_kinds">Tipos</string> diff --git a/commonsUI/src/commonMain/composeResources/values-es/strings.xml b/commonsUI/src/commonMain/composeResources/values-es/strings.xml index d9570812e6..0f52873364 100644 --- a/commonsUI/src/commonMain/composeResources/values-es/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-es/strings.xml @@ -211,7 +211,7 @@ <string name="copy_user_id">Copiar ID de usuario</string> <string name="unblock_user">Desbloquear usuario</string> <string name="user_is_blocked_hidden">Este usuario está bloqueado/silenciado. Sus publicaciones están ocultas.</string> - <string name="npub_hex_username">npub, hex, username </string> + <string name="npub_hex_username">npub, hex, username</string> <string name="clear">Limpiar</string> <string name="app_logo">Logo de la app</string> <string name="nsec_npub_hex_private_key">nsec / npub / hex private key</string> @@ -951,9 +951,7 @@ <string name="push_server_none_explainer">Desactiva las notificaciones push</string> <string name="push_server_uses_app_explainer">Usa la app %1$s</string> <string name="push_server_install_app">Configuración de notificaciones push</string> - <string name="push_server_install_app_description"> Para recibir notificaciones push, instala cualquier aplicación compatible con [Unified Push](https://unifiedpush.org/), como [Ntfy](https://ntfy.sh/). - Tras la instalación, selecciona la aplicación que deseas usar en la configuración. - </string> + <string name="push_server_install_app_description">Para recibir notificaciones push, instala cualquier aplicación compatible con [Unified Push](https://unifiedpush.org/), como [Ntfy](https://ntfy.sh/). Tras la instalación, selecciona la aplicación que deseas usar en la configuración.</string> <string name="thread_title">Conversación</string> <string name="send_the_seller_a_message">Envía un mensaje al vendedor</string> <string name="hi_there_is_this_still_available">Hola, ¿esto todavía está disponible?</string> @@ -1802,15 +1800,8 @@ <string name="ots_explorer_api_label">URL base de la API del explorador</string> <string name="dvm_pay_invoice_from_dvm">Pagar factura del DVM</string> <string name="dvm_pay_amount_to_dvm">Pagar %1$s sats al DVM</string> - <string xmlns:ns0="http://schemas.android.com/tools" name="account_backup_tips2_md" ns0:ignore="Typos"> ## Consejos sobre la protección y la copia de seguridad de las claves - \n\nTu cuenta está protegida por una clave secreta. La clave es una larga secuencia de caracteres que empieza por **nsec1**. Cualquiera que tenga acceso a esta clave secreta puede publicar y cambiar tu identidad. - \n\n- **No** guardes la clave secreta en ningún sitio web o software en los que no confíes. - \n- Los desarrolladores de Amethyst **nunca** te pedirán la clave secreta. - \n- **Sí** puedes realizar una copia de seguridad de la clave secreta para recuperar la cuenta. Para ello, te recomendamos usar un gestor de contraseñas. - </string> - <string xmlns:ns0="http://schemas.android.com/tools" name="account_backup_tips3_md" ns0:ignore="Typos"> Para mayor seguridad, puedes cifrar la clave con una contraseña. Esta clave empieza por **ncryptsec1** y no puede utilizarse sin tu contraseña. - \n\nSi pierdes la contraseña, no podrás recuperar tu clave. - </string> + <string xmlns:ns0="http://schemas.android.com/tools" name="account_backup_tips2_md" ns0:ignore="Typos">## Consejos sobre la protección y la copia de seguridad de las claves \n\nTu cuenta está protegida por una clave secreta. La clave es una larga secuencia de caracteres que empieza por **nsec1**. Cualquiera que tenga acceso a esta clave secreta puede publicar y cambiar tu identidad. \n\n- **No** guardes la clave secreta en ningún sitio web o software en los que no confíes. \n- Los desarrolladores de Amethyst **nunca** te pedirán la clave secreta. \n- **Sí** puedes realizar una copia de seguridad de la clave secreta para recuperar la cuenta. Para ello, te recomendamos usar un gestor de contraseñas.</string> + <string xmlns:ns0="http://schemas.android.com/tools" name="account_backup_tips3_md" ns0:ignore="Typos">Para mayor seguridad, puedes cifrar la clave con una contraseña. Esta clave empieza por **ncryptsec1** y no puede utilizarse sin tu contraseña. \n\nSi pierdes la contraseña, no podrás recuperar tu clave.</string> <string name="use_direct_url">Usar URL directa</string> <string name="nsite_title">nSite: %1$s</string> <string name="napplet_card_title">nApplet: %1$s</string> @@ -2055,15 +2046,7 @@ <string name="could_not_fetch_invoice_from_details">No se ha podido obtener la factura de %1$s: %2$s</string> <string name="could_not_open_the_compressed_file">No se pudo abrir el archivo comprimido</string> <string name="couldnt_find_nwc_wallets">Cartera no encontrada</string> - <string name="couldnt_find_nwc_wallets_description">Amethyst no encontró ninguna aplicación de cartera compatible con Nostr Wallet Connect (NWC). -\n\nQué puedes hacer: -\n\t1. Verificar la compatibilidad con NWC: Comprueba que tu aplicación de cartera preferida es compatible con Nostr Wallet Connect. -\n\t2. Crear conexión: Ve a tu aplicación de cartera y crea una nueva conexión NWC. -\n\t3. Vincular con Amethyst: Tu cartera normalmente te ofrecerá tres opciones: -\n\t\t3.1 Enlace automático: Un botón para abrir y conectar automáticamente a Amethyst. Si es así, pulsa ese botón, abre Amethyst y guarda en la nueva pantalla. -\n\t\t3.2 Enlace manual: Un URI de conexión que puedes copiar y pegar usando el icono de pegado en esta pantalla para introducirlo directamente. -\n\t\t3.3 Código QR: La cartera genera un código QR que puedes leer con el botón de código QR en esta pantalla. - </string> + <string name="couldnt_find_nwc_wallets_description">Amethyst no encontró ninguna aplicación de cartera compatible con Nostr Wallet Connect (NWC). \n\nQué puedes hacer: \n\t1. Verificar la compatibilidad con NWC: Comprueba que tu aplicación de cartera preferida es compatible con Nostr Wallet Connect. \n\t2. Crear conexión: Ve a tu aplicación de cartera y crea una nueva conexión NWC. \n\t3. Vincular con Amethyst: Tu cartera normalmente te ofrecerá tres opciones: \n\t\t3.1 Enlace automático: Un botón para abrir y conectar automáticamente a Amethyst. Si es así, pulsa ese botón, abre Amethyst y guarda en la nueva pantalla. \n\t\t3.2 Enlace manual: Un URI de conexión que puedes copiar y pegar usando el icono de pegado en esta pantalla para introducirlo directamente. \n\t\t3.3 Código QR: La cartera genera un código QR que puedes leer con el botón de código QR en esta pantalla.</string> <string name="create">Crear</string> <string name="d">d</string> <string name="danger_zone">Zona peligrosa</string> @@ -2751,11 +2734,8 @@ <string name="proxy_section">Relés de proxy</string> <string name="proxy_section_explainer">Relés recopiladores que la app debe usar para descargar tus fuentes, como filter.nostr.wine. Esto reemplaza el modelo de bandeja de salida y hará que la app se conecte solo a los relés de tus listas.</string> <string name="public_chat">Chat público</string> - <string name="public_chat_explainer">Los chats públicos son visibles para todos en Nostr y cualquiera - puede participar en ellos. Son ideales para comunidades abiertas alrededor de temas específicos. - La moderación puede controlarse eliminando publicaciones en sus relés.</string> - <string name="public_chat_relays_explainer">Inserta entre 1 y 3 relés que alojan este grupo. - Los clientes de Nostr utilizan esta configuración para saber de dónde descargar y adónde enviar los mensajes.</string> + <string name="public_chat_explainer">Los chats públicos son visibles para todos en Nostr y cualquiera puede participar en ellos. Son ideales para comunidades abiertas alrededor de temas específicos. La moderación puede controlarse eliminando publicaciones en sus relés.</string> + <string name="public_chat_relays_explainer">Inserta entre 1 y 3 relés que alojan este grupo. Los clientes de Nostr utilizan esta configuración para saber de dónde descargar y adónde enviar los mensajes.</string> <string name="public_chat_relays_title">Relés</string> <string name="public_chat_title">Metadatos públicos del chat</string> <string name="public_chats">Chats públicos</string> @@ -2780,10 +2760,7 @@ <string name="read_only_user">Usuario de solo lectura</string> <string name="record_a_message_description">Haz clic y mantén pulsado para grabar un mensaje</string> <string name="relay_chat">Chat en relé</string> - <string name="relay_chat_explainer">Los chats en relé son grupos de chat controlados por su relé de origen. - Son visibles para todos los usuarios de Nostr y cualquiera puede participar en ellos. - Son ideales para comunidades abiertas en torno a temas específicos. Algunos de estos grupos son efímeros - y, por lo tanto, los mensajes del chat desaparecen con el tiempo</string> + <string name="relay_chat_explainer">Los chats en relé son grupos de chat controlados por su relé de origen. Son visibles para todos los usuarios de Nostr y cualquiera puede participar en ellos. Son ideales para comunidades abiertas en torno a temas específicos. Algunos de estos grupos son efímeros y, por lo tanto, los mensajes del chat desaparecen con el tiempo</string> <string name="relay_chat_title">Chats en relé</string> <string name="relay_management_tab_events">Eventos</string> <string name="relay_management_tab_ips">IPs</string> diff --git a/commonsUI/src/commonMain/composeResources/values-fa-rIR/strings.xml b/commonsUI/src/commonMain/composeResources/values-fa-rIR/strings.xml index a92f92d82a..df5f3b4363 100644 --- a/commonsUI/src/commonMain/composeResources/values-fa-rIR/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-fa-rIR/strings.xml @@ -10,7 +10,7 @@ <!-- Accessibility --> <!-- Relay history paging (shared feed markers + status card) --> <!-- Notes & Replies --> - <string name="replying_to"> پاسخ به</string> + <string name="replying_to">پاسخ به</string> <!-- Static sites (NIP-5A) & napplets (NIP-5D) feed card --> <string name="napplet_card_permissions">مجوزها:</string> <string name="nsite_root_site">سایت اصلی</string> @@ -226,7 +226,7 @@ <string name="copy_user_id">کپی شناسه کاربر</string> <string name="unblock_user">از بلاک درآوردن کاربر</string> <string name="user_is_blocked_hidden">این کاربر مسدود/بی‌صدا شده است. پست‌های آن‌ها مخفی است.</string> - <string name="npub_hex_username">npub, hex, نام کاربری </string> + <string name="npub_hex_username">npub, hex, نام کاربری</string> <string name="clear">پاک کردن</string> <string name="app_logo">لوگوی اپ</string> <string name="nsec_npub_hex_private_key">nsec / npub / hex کلید خصوصی</string> @@ -248,7 +248,7 @@ <string name="chats_reply_searching_history">در حال جستجوی پیام اصلی…</string> <string name="chats_reply_not_found">این پیام پیدا نشد</string> <string name="chats_reply_searched">همه relay ها جستجو شد · برای مشاهده ضربه بزنید</string> - <string name="error_loading_replies"> :خطا در بارگیری پاسخ ها</string> + <string name="error_loading_replies">:خطا در بارگیری پاسخ ها</string> <string name="try_again">دوباره تلاش کنید</string> <string name="notification_feed_is_empty">هنوز هیچ اعلانی نیست.</string> <string name="open_poll">نظرسنجی باز</string> @@ -274,14 +274,8 @@ <string name="mark_all_known_as_read">همه شناس ها خوانده شده کن</string> <string name="mark_all_new_as_read">همه جدیدها را خوانده شده کن</string> <string name="mark_all_as_read">همه را خوانده شده کن</string> - <string name="account_backup_tips2_md">نکات ایمنی و ذخیره کلید - -حساب کاربری شما با یک کلید خصوصی محافظت می شود. این کلید دنباله ای بلند از کاراکترها است که با **nsec1** آغاز می گردد. هر کس به این کلید خصوصی دسترسی داشته باشد می تواند بجای شما یادداشت پست کرده یا هویت شما را تغییر دهد. -هرگز کلید خصوصی خود را در هیچ وبسایت با نرم افزاری که به آن اطمینان ندارید **وارد نکنید**. -سازندگان امتیست **هرگز** کلیدتان را از شما نمی خواهند. -یک پشتیبان یدکی مخفی از کلید خود به منظور بازیابی **نگه دارید**. ما استفاده از نرم افزار مدیریت پسورد را توصیه می کنیم.</string> - <string name="account_backup_tips3_md">برای ایمنی بیشتر، می توانید کلید خود را با یک رمزعبور رمزنگاری کنید. این نوع کلید با **ncryptsec1** آغاز می شود و نمی تواند بدون رمز عبور شما استفاده شود. -اگر پسوردتان را گم کنید، نخواهید توانست کلیدتان را بازیابی کنید.</string> + <string name="account_backup_tips2_md">نکات ایمنی و ذخیره کلید حساب کاربری شما با یک کلید خصوصی محافظت می شود. این کلید دنباله ای بلند از کاراکترها است که با **nsec1** آغاز می گردد. هر کس به این کلید خصوصی دسترسی داشته باشد می تواند بجای شما یادداشت پست کرده یا هویت شما را تغییر دهد. هرگز کلید خصوصی خود را در هیچ وبسایت با نرم افزاری که به آن اطمینان ندارید **وارد نکنید**. سازندگان امتیست **هرگز** کلیدتان را از شما نمی خواهند. یک پشتیبان یدکی مخفی از کلید خود به منظور بازیابی **نگه دارید**. ما استفاده از نرم افزار مدیریت پسورد را توصیه می کنیم.</string> + <string name="account_backup_tips3_md">برای ایمنی بیشتر، می توانید کلید خود را با یک رمزعبور رمزنگاری کنید. این نوع کلید با **ncryptsec1** آغاز می شود و نمی تواند بدون رمز عبور شما استفاده شود. اگر پسوردتان را گم کنید، نخواهید توانست کلیدتان را بازیابی کنید.</string> <string name="encrypt_and_copy_my_secret_key">رمزنگاری و کپی کلید من</string> <string name="badge_award_image_for">تصویر مدال پاداش برای %1$s</string> <string name="badge_award_image">تصویر جایزه نشان</string> @@ -301,7 +295,7 @@ <string name="quick_action_request_deletion_gallery_title">حذف از گالری</string> <string name="quick_action_request_deletion_gallery_alert_body_v2">این رسانه را از گالری خود حذف کنید.</string> <string name="quick_action_request_deletion_alert_title">درخواست حذف</string> - <string name="quick_action_request_deletion_alert_body">آماتیست درخواست می کند که یادداشت شما از رله هایی که درحال حاضر به آن متصل هستید حذف شود. هیچ تضمینی نیست که یادداشت شما برای همیشه از آن رله ها یا از رله های دیگری که ممکن است در آنها ذخیره شده باشد حذف خواهد شد.. </string> + <string name="quick_action_request_deletion_alert_body">آماتیست درخواست می کند که یادداشت شما از رله هایی که درحال حاضر به آن متصل هستید حذف شود. هیچ تضمینی نیست که یادداشت شما برای همیشه از آن رله ها یا از رله های دیگری که ممکن است در آنها ذخیره شده باشد حذف خواهد شد..</string> <string name="quick_action_block_dialog_btn">بلاک</string> <string name="quick_action_delete_dialog_btn">حذف</string> <string name="quick_action_block">بلاک</string> @@ -970,9 +964,7 @@ <string name="push_server_none_explainer">غیرفعال سازی اعلان ها</string> <string name="push_server_uses_app_explainer">از اپلیکیشن %1$s استفاده می کند</string> <string name="push_server_install_app">تنظیمات اعلان ها</string> - <string name="push_server_install_app_description">برای دریافت اعلان ها، می توانید هر اپلیکیشنی که از [Unified Push](https://unifiedpush.org/) پشتیبانی می کند نصب کنید، مانند[Ntfy](https://ntfy.sh/). - پس از نصب، اپلیکیشنی را که می خواهید استفاده کنید در تنظیمات انتخاب کنید. - </string> + <string name="push_server_install_app_description">برای دریافت اعلان ها، می توانید هر اپلیکیشنی که از [Unified Push](https://unifiedpush.org/) پشتیبانی می کند نصب کنید، مانند[Ntfy](https://ntfy.sh/). پس از نصب، اپلیکیشنی را که می خواهید استفاده کنید در تنظیمات انتخاب کنید.</string> <string name="thread_title">رسته</string> <string name="send_the_seller_a_message">به فروشنده پیغام دهید</string> <string name="hi_there_is_this_still_available">سلام، هنوز موجوده؟</string> @@ -1315,10 +1307,7 @@ <string name="show_nprofile_as_a_qr_code">نمایش nprofile به عنوان کد QR</string> <string name="dm_relays_not_found">تنظیم رله های صندوق دریافت خصوصی</string> <string name="dm_relays_not_found_description">با این تنظیمات همه می توانند بدانند که هنگام ارسال پیام به شما از کدام رله ها استفاده کنند. بدون این تنظیمات ممکن است برخی پیام ها را از دست بدهید.</string> - <string name="dm_relays_not_found_examples2">گزینه های خوب: -- inbox.nostr.wine (پولی) -- auth.nostr1.com (رایگان) -- you.nostr1.com (رله شخصی - پولی)</string> + <string name="dm_relays_not_found_examples2">گزینه های خوب: - inbox.nostr.wine (پولی) - auth.nostr1.com (رایگان) - you.nostr1.com (رله شخصی - پولی)</string> <string name="dm_relays_not_found_editing">از ۱ تا ۳ رله به عنوان رله خصوصی وارد کنید. رله های صندوق دربافت پیام های خصوصی می بایست هر پیامی از هرکس را بپذیرند، ولی تنها به شما اجازه بارگیری پیام ها را می دهند.</string> <string name="dm_relays_not_found_create_now">هم اکنون تنظیم شود</string> <string name="recipient_missing_dm_relays">relay های صندوق ورودی DM یافت نشد. پیام‌ها تحویل داده نمی‌شوند تا زمانی که لیست relay خود را پیکربندی کنند.</string> @@ -1345,7 +1334,7 @@ <string name="zap_the_devs_title">به توسعه دهندگان رپ دهید!</string> <string name="zap_the_devs_description">هدیه شما به ما برای تاثیرگذاری کمک می کند. هر ساتوشی به حساب می آید!</string> <string name="donate_now">اکنون کمک کنید</string> - <string name="brought_to_you_by">تقدیم از طرف: </string> + <string name="brought_to_you_by">تقدیم از طرف:</string> <string name="this_version_brought_to_you_by">این نسخه نرم‌افزار پیشکش از:</string> <string name="version_name">نسخه %1$s</string> <string name="thank_you">متشکریم!</string> @@ -2025,7 +2014,7 @@ <string name="copied_note_id_to_clipboard">(@note1)شناسه یادداشت در کلیپبورد کپی شد</string> <string name="copied_note_text_to_clipboard">یادداشت در کلیپبورد کپی شد</string> <string name="copied_to_clipboard">در کلیپ‌بورد کپی شد</string> - <string name="copied_user_id_to_clipboard">@npubنویسنده در کلیپبورد کپی شد </string> + <string name="copied_user_id_to_clipboard">@npubنویسنده در کلیپبورد کپی شد</string> <string name="copy_my_secret_key">کلید خصوصیم را کپی کن</string> <string name="copy_to_clipboard">کپی به کلیپ‌بورد</string> <string name="could_not_assemble_lnurl_from_lightning_address_check_the_user_s_setup">LNUrl از آدرس لایتنینگ "%1$s" بدست نیامد. تنظیمات کاربر را بررسی کنید.</string> @@ -2034,15 +2023,7 @@ <string name="could_not_fetch_invoice_from_details">صورتحساب از %1$s گرفته نشد: %2$s</string> <string name="could_not_open_the_compressed_file">فایل فشرده باز نشد</string> <string name="couldnt_find_nwc_wallets">کیف پول یافت نشد</string> - <string name="couldnt_find_nwc_wallets_description">Amethyst هیچ برنامه کیف پولی که از Nostr Wallet Connect (NWC) پشتیبانی کند پیدا نکرد. -\n\nکارهایی که می‌توانید انجام دهید: -\n\t1. بررسی پشتیبانی NWC: مطمئن شوید که برنامه کیف پول مورد نظر شما با Nostr Wallet Connect سازگار است. -\n\t2. ایجاد اتصال: به برنامه کیف پول خود بروید و یک اتصال NWC جدید ایجاد کنید. -\n\t3. اتصال به Amethyst: کیف پول شما معمولاً سه گزینه ارائه می‌دهد: -\n\t\t3.1 اتصال خودکار: دکمه‌ای برای باز کردن و اتصال خودکار به Amethyst. اگر این گزینه وجود دارد، آن دکمه را بزنید، Amethyst را باز کنید و روی صفحه جدید ذخیره کنید. -\n\t\t3.2 اتصال دستی: یک URI اتصال که می‌توانید کپی کنید و با کلیک روی آیکون paste در این صفحه مستقیماً وارد کنید. -\n\t\t3.3 کد QR: کیف پول یک کد QR ایجاد می‌کند که می‌توانید با دکمه کد QR در این صفحه آن را بخوانید. - </string> + <string name="couldnt_find_nwc_wallets_description">Amethyst هیچ برنامه کیف پولی که از Nostr Wallet Connect (NWC) پشتیبانی کند پیدا نکرد. \n\nکارهایی که می‌توانید انجام دهید: \n\t1. بررسی پشتیبانی NWC: مطمئن شوید که برنامه کیف پول مورد نظر شما با Nostr Wallet Connect سازگار است. \n\t2. ایجاد اتصال: به برنامه کیف پول خود بروید و یک اتصال NWC جدید ایجاد کنید. \n\t3. اتصال به Amethyst: کیف پول شما معمولاً سه گزینه ارائه می‌دهد: \n\t\t3.1 اتصال خودکار: دکمه‌ای برای باز کردن و اتصال خودکار به Amethyst. اگر این گزینه وجود دارد، آن دکمه را بزنید، Amethyst را باز کنید و روی صفحه جدید ذخیره کنید. \n\t\t3.2 اتصال دستی: یک URI اتصال که می‌توانید کپی کنید و با کلیک روی آیکون paste در این صفحه مستقیماً وارد کنید. \n\t\t3.3 کد QR: کیف پول یک کد QR ایجاد می‌کند که می‌توانید با دکمه کد QR در این صفحه آن را بخوانید.</string> <string name="create">ساختن</string> <string name="d">روز</string> <string name="danger_zone">منطقه خطر</string> @@ -2689,10 +2670,7 @@ <string name="privacy_policy">سیاست حریم خصوصی</string> <string name="private_conversation_notification"><پیام خصوصی رمزگشایی نشد>\\n\nدر یک پیام خصوصی/رمزنگاری شده بین %1$s و %2$s نام شما آمده بود.</string> <string name="private_inbox_section">رله های صندوق دریافت پیام خصوصی</string> - <string name="private_inbox_section_explainer">بین ۱-۳ رله به عنوان رله صندوق ورودی خصوصی وارد کنید. دیگران از این رله ها برای ارسال پیام خصوصی به شما استفاده خواهند کرد. رله های صندوق دریافت پیام خصوصی می بایست هر پیامی از هر کسی را بپذیرند، اما فقط بگذارند شما آن ها را بارگیری کنید. انتخاب های خوب شامل: - - inbox.nostr.wine (پولی) -- auth.nostr1.com (رایگان) -- you.nostr1.com (رله پولی یا شخصی)</string> + <string name="private_inbox_section_explainer">بین ۱-۳ رله به عنوان رله صندوق ورودی خصوصی وارد کنید. دیگران از این رله ها برای ارسال پیام خصوصی به شما استفاده خواهند کرد. رله های صندوق دریافت پیام خصوصی می بایست هر پیامی از هر کسی را بپذیرند، اما فقط بگذارند شما آن ها را بارگیری کنید. انتخاب های خوب شامل: - inbox.nostr.wine (پولی) - auth.nostr1.com (رایگان) - you.nostr1.com (رله پولی یا شخصی)</string> <string name="private_inbox_section_explainer_profile">کاربر DM ها را روی این relay ها دریافت می‌کند</string> <string name="private_message">پیام خصوصی</string> <string name="private_note">خصوصی کردن: یادداشت را به صورت gift-wrap فقط برای کاربران مطلع‌شده ارسال کن</string> @@ -2713,11 +2691,8 @@ <string name="proxy_section">relay های پراکسی</string> <string name="proxy_section_explainer">relay های تجمیع‌کننده که برنامه باید برای دانلود فیدهای شما از آن‌ها استفاده کند، مانند filter.nostr.wine. این جایگزین مدل outbox می‌شود و باعث می‌شود برنامه فقط به relay های موجود در لیست‌هایتان متصل شود.</string> <string name="public_chat">گفتگوی عمومی</string> - <string name="public_chat_explainer">گفتگوهای عمومی در ناستر برای همه نمایان هستند - می توانند در آن شرکت کنند. آنها برای انجمن های باز درباره موضوعات به‌خصوص عالی هستند. - نظارت می تواند توسط حذف پست ها از رله ها انجام گیرد</string> - <string name="public_chat_relays_explainer">برای میزبانی این گروه ۱تا ۳ رله وارد کنید. - کلاینت های ناستر از این تنظیمات استفاده می کنند تا بدانند پیام ها را از کجا بارگیری کنند و به کجا بفرستند.</string> + <string name="public_chat_explainer">گفتگوهای عمومی در ناستر برای همه نمایان هستند می توانند در آن شرکت کنند. آنها برای انجمن های باز درباره موضوعات به‌خصوص عالی هستند. نظارت می تواند توسط حذف پست ها از رله ها انجام گیرد</string> + <string name="public_chat_relays_explainer">برای میزبانی این گروه ۱تا ۳ رله وارد کنید. کلاینت های ناستر از این تنظیمات استفاده می کنند تا بدانند پیام ها را از کجا بارگیری کنند و به کجا بفرستند.</string> <string name="public_chat_relays_title">رله ها</string> <string name="public_chat_title">فراداده گفتگوی عمومی</string> <string name="public_chats">گفتگوهای عمومی</string> @@ -2742,10 +2717,7 @@ <string name="read_only_user">کاربر با امکان خواندن فقط</string> <string name="record_a_message_description">برای ضبط پیام کلیک کنید و نگه دارید</string> <string name="relay_chat">گفتگوی relay</string> - <string name="relay_chat_explainer">گفتگوهای relay گروه‌های چت هستند که توسط relay خانگی خود کنترل می‌شوند. - آن‌ها برای همه در Nostr قابل مشاهده هستند و هر کسی می‌تواند در آن‌ها شرکت کند. - آن‌ها برای جوامع باز حول موضوعات خاص عالی هستند. برخی از این گروه‌ها ناپایدار - هستند و بنابراین پیام‌های چت با گذشت زمان ناپدید می‌شوند</string> + <string name="relay_chat_explainer">گفتگوهای relay گروه‌های چت هستند که توسط relay خانگی خود کنترل می‌شوند. آن‌ها برای همه در Nostr قابل مشاهده هستند و هر کسی می‌تواند در آن‌ها شرکت کند. آن‌ها برای جوامع باز حول موضوعات خاص عالی هستند. برخی از این گروه‌ها ناپایدار هستند و بنابراین پیام‌های چت با گذشت زمان ناپدید می‌شوند</string> <string name="relay_chat_title">گفتگوهای relay</string> <string name="relay_management_tab_events">رویدادها</string> <string name="relay_management_tab_ips">آدرس‌های IP</string> @@ -2903,7 +2875,7 @@ <string name="tor_use_new_relays_explainer">تور را برای رله های ارسال/دریافت الزامی کن</string> <string name="tor_use_nip05_verification">تایید آدرس ناستر</string> <string name="tor_use_nip05_verification_explainer">تور را برای تایید آدرس های NIP-05 الزامی کن</string> - <string name="tor_use_onion_address">رله ها / آدرس های Onion </string> + <string name="tor_use_onion_address">رله ها / آدرس های Onion</string> <string name="tor_use_onion_address_explainer">از تور برای هر آدرس اونیون استفاده کن</string> <string name="tor_use_profile_pictures">تصاویر نمایه</string> <string name="tor_use_profile_pictures_explainer">تور را برای بارگیری تصاویر نمایه اجباری کن</string> diff --git a/commonsUI/src/commonMain/composeResources/values-fa/strings.xml b/commonsUI/src/commonMain/composeResources/values-fa/strings.xml index a20d36b070..cf8347e236 100644 --- a/commonsUI/src/commonMain/composeResources/values-fa/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-fa/strings.xml @@ -88,7 +88,7 @@ <string name="quote">نقل قول</string> <string name="fork">فورک</string> <string name="new_amount_in_sats">مبلغ جدید به ساتوشی</string> - <string name="replying_to"> پاسخ به</string> + <string name="replying_to">پاسخ به</string> <string name="and"> و </string> <string name="following">دنبال شونده</string> <string name="followers">دنبال کننده</string> @@ -211,7 +211,7 @@ <string name="copy_user_id">کپی شناسه کاربر</string> <string name="unblock_user">از بلاک درآوردن کاربر</string> <string name="user_is_blocked_hidden">این کاربر مسدود/بی‌صدا شده است. پست‌های آن‌ها مخفی است.</string> - <string name="npub_hex_username">npub, hex, نام کاربری </string> + <string name="npub_hex_username">npub, hex, نام کاربری</string> <string name="clear">پاک کردن</string> <string name="app_logo">لوگوی اپ</string> <string name="nsec_npub_hex_private_key">nsec / npub / hex کلید خصوصی</string> @@ -233,7 +233,7 @@ <string name="chats_reply_searching_history">در حال جستجوی پیام اصلی…</string> <string name="chats_reply_not_found">این پیام پیدا نشد</string> <string name="chats_reply_searched">همه relay ها جستجو شد · برای مشاهده ضربه بزنید</string> - <string name="error_loading_replies"> :خطا در بارگیری پاسخ ها</string> + <string name="error_loading_replies">:خطا در بارگیری پاسخ ها</string> <string name="try_again">دوباره تلاش کنید</string> <string name="notification_feed_is_empty">هنوز هیچ اعلانی نیست.</string> <string name="open_poll">نظرسنجی باز</string> @@ -259,14 +259,8 @@ <string name="mark_all_known_as_read">همه شناس ها خوانده شده کن</string> <string name="mark_all_new_as_read">همه جدیدها را خوانده شده کن</string> <string name="mark_all_as_read">همه را خوانده شده کن</string> - <string name="account_backup_tips2_md">نکات ایمنی و ذخیره کلید - -حساب کاربری شما با یک کلید خصوصی محافظت می شود. این کلید دنباله ای بلند از کاراکترها است که با **nsec1** آغاز می گردد. هر کس به این کلید خصوصی دسترسی داشته باشد می تواند بجای شما یادداشت پست کرده یا هویت شما را تغییر دهد. -هرگز کلید خصوصی خود را در هیچ وبسایت با نرم افزاری که به آن اطمینان ندارید **وارد نکنید**. -سازندگان امتیست **هرگز** کلیدتان را از شما نمی خواهند. -یک پشتیبان یدکی مخفی از کلید خود به منظور بازیابی **نگه دارید**. ما استفاده از نرم افزار مدیریت پسورد را توصیه می کنیم.</string> - <string name="account_backup_tips3_md">برای ایمنی بیشتر، می توانید کلید خود را با یک رمزعبور رمزنگاری کنید. این نوع کلید با **ncryptsec1** آغاز می شود و نمی تواند بدون رمز عبور شما استفاده شود. -اگر پسوردتان را گم کنید، نخواهید توانست کلیدتان را بازیابی کنید.</string> + <string name="account_backup_tips2_md">نکات ایمنی و ذخیره کلید حساب کاربری شما با یک کلید خصوصی محافظت می شود. این کلید دنباله ای بلند از کاراکترها است که با **nsec1** آغاز می گردد. هر کس به این کلید خصوصی دسترسی داشته باشد می تواند بجای شما یادداشت پست کرده یا هویت شما را تغییر دهد. هرگز کلید خصوصی خود را در هیچ وبسایت با نرم افزاری که به آن اطمینان ندارید **وارد نکنید**. سازندگان امتیست **هرگز** کلیدتان را از شما نمی خواهند. یک پشتیبان یدکی مخفی از کلید خود به منظور بازیابی **نگه دارید**. ما استفاده از نرم افزار مدیریت پسورد را توصیه می کنیم.</string> + <string name="account_backup_tips3_md">برای ایمنی بیشتر، می توانید کلید خود را با یک رمزعبور رمزنگاری کنید. این نوع کلید با **ncryptsec1** آغاز می شود و نمی تواند بدون رمز عبور شما استفاده شود. اگر پسوردتان را گم کنید، نخواهید توانست کلیدتان را بازیابی کنید.</string> <string name="encrypt_and_copy_my_secret_key">رمزنگاری و کپی کلید من</string> <string name="badge_award_image_for">تصویر مدال پاداش برای %1$s</string> <string name="badge_award_image">تصویر جایزه نشان</string> @@ -286,7 +280,7 @@ <string name="quick_action_request_deletion_gallery_title">حذف از گالری</string> <string name="quick_action_request_deletion_gallery_alert_body_v2">این رسانه را از گالری خود حذف کنید.</string> <string name="quick_action_request_deletion_alert_title">درخواست حذف</string> - <string name="quick_action_request_deletion_alert_body">آماتیست درخواست می کند که یادداشت شما از رله هایی که درحال حاضر به آن متصل هستید حذف شود. هیچ تضمینی نیست که یادداشت شما برای همیشه از آن رله ها یا از رله های دیگری که ممکن است در آنها ذخیره شده باشد حذف خواهد شد.. </string> + <string name="quick_action_request_deletion_alert_body">آماتیست درخواست می کند که یادداشت شما از رله هایی که درحال حاضر به آن متصل هستید حذف شود. هیچ تضمینی نیست که یادداشت شما برای همیشه از آن رله ها یا از رله های دیگری که ممکن است در آنها ذخیره شده باشد حذف خواهد شد..</string> <string name="quick_action_block_dialog_btn">بلاک</string> <string name="quick_action_delete_dialog_btn">حذف</string> <string name="quick_action_block">بلاک</string> @@ -959,9 +953,7 @@ <string name="push_server_none_explainer">غیرفعال سازی اعلان ها</string> <string name="push_server_uses_app_explainer">از اپلیکیشن %1$s استفاده می کند</string> <string name="push_server_install_app">تنظیمات اعلان ها</string> - <string name="push_server_install_app_description">برای دریافت اعلان ها، می توانید هر اپلیکیشنی که از [Unified Push](https://unifiedpush.org/) پشتیبانی می کند نصب کنید، مانند[Ntfy](https://ntfy.sh/). - پس از نصب، اپلیکیشنی را که می خواهید استفاده کنید در تنظیمات انتخاب کنید. - </string> + <string name="push_server_install_app_description">برای دریافت اعلان ها، می توانید هر اپلیکیشنی که از [Unified Push](https://unifiedpush.org/) پشتیبانی می کند نصب کنید، مانند[Ntfy](https://ntfy.sh/). پس از نصب، اپلیکیشنی را که می خواهید استفاده کنید در تنظیمات انتخاب کنید.</string> <string name="thread_title">رسته</string> <string name="send_the_seller_a_message">به فروشنده پیغام دهید</string> <string name="hi_there_is_this_still_available">سلام، هنوز موجوده؟</string> @@ -1313,10 +1305,7 @@ <string name="show_nprofile_as_a_qr_code">نمایش nprofile به عنوان کد QR</string> <string name="dm_relays_not_found">تنظیم رله های صندوق دریافت خصوصی</string> <string name="dm_relays_not_found_description">با این تنظیمات همه می توانند بدانند که هنگام ارسال پیام به شما از کدام رله ها استفاده کنند. بدون این تنظیمات ممکن است برخی پیام ها را از دست بدهید.</string> - <string name="dm_relays_not_found_examples2">گزینه های خوب: -- inbox.nostr.wine (پولی) -- auth.nostr1.com (رایگان) -- you.nostr1.com (رله شخصی - پولی)</string> + <string name="dm_relays_not_found_examples2">گزینه های خوب: - inbox.nostr.wine (پولی) - auth.nostr1.com (رایگان) - you.nostr1.com (رله شخصی - پولی)</string> <string name="dm_relays_not_found_editing">از ۱ تا ۳ رله به عنوان رله خصوصی وارد کنید. رله های صندوق دربافت پیام های خصوصی می بایست هر پیامی از هرکس را بپذیرند، ولی تنها به شما اجازه بارگیری پیام ها را می دهند.</string> <string name="dm_relays_not_found_create_now">هم اکنون تنظیم شود</string> <string name="recipient_missing_dm_relays">relay های صندوق ورودی DM یافت نشد. پیام‌ها تحویل داده نمی‌شوند تا زمانی که لیست relay خود را پیکربندی کنند.</string> @@ -1343,7 +1332,7 @@ <string name="zap_the_devs_title">به توسعه دهندگان رپ دهید!</string> <string name="zap_the_devs_description">هدیه شما به ما برای تاثیرگذاری کمک می کند. هر ساتوشی به حساب می آید!</string> <string name="donate_now">اکنون کمک کنید</string> - <string name="brought_to_you_by">تقدیم از طرف: </string> + <string name="brought_to_you_by">تقدیم از طرف:</string> <string name="this_version_brought_to_you_by">این نسخه نرم‌افزار پیشکش از:</string> <string name="version_name">نسخه %1$s</string> <string name="thank_you">متشکریم!</string> @@ -2048,7 +2037,7 @@ <string name="copied_note_id_to_clipboard">(@note1)شناسه یادداشت در کلیپبورد کپی شد</string> <string name="copied_note_text_to_clipboard">یادداشت در کلیپبورد کپی شد</string> <string name="copied_to_clipboard">در کلیپ‌بورد کپی شد</string> - <string name="copied_user_id_to_clipboard">@npubنویسنده در کلیپبورد کپی شد </string> + <string name="copied_user_id_to_clipboard">@npubنویسنده در کلیپبورد کپی شد</string> <string name="copy_my_secret_key">کلید خصوصیم را کپی کن</string> <string name="copy_to_clipboard">کپی به کلیپ‌بورد</string> <string name="could_not_assemble_lnurl_from_lightning_address_check_the_user_s_setup">LNUrl از آدرس لایتنینگ "%1$s" بدست نیامد. تنظیمات کاربر را بررسی کنید.</string> @@ -2057,15 +2046,7 @@ <string name="could_not_fetch_invoice_from_details">صورتحساب از %1$s گرفته نشد: %2$s</string> <string name="could_not_open_the_compressed_file">فایل فشرده باز نشد</string> <string name="couldnt_find_nwc_wallets">کیف پول یافت نشد</string> - <string name="couldnt_find_nwc_wallets_description">Amethyst هیچ برنامه کیف پولی که از Nostr Wallet Connect (NWC) پشتیبانی کند پیدا نکرد. -\n\nکارهایی که می‌توانید انجام دهید: -\n\t1. بررسی پشتیبانی NWC: مطمئن شوید که برنامه کیف پول مورد نظر شما با Nostr Wallet Connect سازگار است. -\n\t2. ایجاد اتصال: به برنامه کیف پول خود بروید و یک اتصال NWC جدید ایجاد کنید. -\n\t3. اتصال به Amethyst: کیف پول شما معمولاً سه گزینه ارائه می‌دهد: -\n\t\t3.1 اتصال خودکار: دکمه‌ای برای باز کردن و اتصال خودکار به Amethyst. اگر این گزینه وجود دارد، آن دکمه را بزنید، Amethyst را باز کنید و روی صفحه جدید ذخیره کنید. -\n\t\t3.2 اتصال دستی: یک URI اتصال که می‌توانید کپی کنید و با کلیک روی آیکون paste در این صفحه مستقیماً وارد کنید. -\n\t\t3.3 کد QR: کیف پول یک کد QR ایجاد می‌کند که می‌توانید با دکمه کد QR در این صفحه آن را بخوانید. - </string> + <string name="couldnt_find_nwc_wallets_description">Amethyst هیچ برنامه کیف پولی که از Nostr Wallet Connect (NWC) پشتیبانی کند پیدا نکرد. \n\nکارهایی که می‌توانید انجام دهید: \n\t1. بررسی پشتیبانی NWC: مطمئن شوید که برنامه کیف پول مورد نظر شما با Nostr Wallet Connect سازگار است. \n\t2. ایجاد اتصال: به برنامه کیف پول خود بروید و یک اتصال NWC جدید ایجاد کنید. \n\t3. اتصال به Amethyst: کیف پول شما معمولاً سه گزینه ارائه می‌دهد: \n\t\t3.1 اتصال خودکار: دکمه‌ای برای باز کردن و اتصال خودکار به Amethyst. اگر این گزینه وجود دارد، آن دکمه را بزنید، Amethyst را باز کنید و روی صفحه جدید ذخیره کنید. \n\t\t3.2 اتصال دستی: یک URI اتصال که می‌توانید کپی کنید و با کلیک روی آیکون paste در این صفحه مستقیماً وارد کنید. \n\t\t3.3 کد QR: کیف پول یک کد QR ایجاد می‌کند که می‌توانید با دکمه کد QR در این صفحه آن را بخوانید.</string> <string name="create">ساختن</string> <string name="d">روز</string> <string name="danger_zone">منطقه خطر</string> @@ -2732,10 +2713,7 @@ <string name="privacy_policy">سیاست حریم خصوصی</string> <string name="private_conversation_notification"><پیام خصوصی رمزگشایی نشد>\\n\nدر یک پیام خصوصی/رمزنگاری شده بین %1$s و %2$s نام شما آمده بود.</string> <string name="private_inbox_section">رله های صندوق دریافت پیام خصوصی</string> - <string name="private_inbox_section_explainer">بین ۱-۳ رله به عنوان رله صندوق ورودی خصوصی وارد کنید. دیگران از این رله ها برای ارسال پیام خصوصی به شما استفاده خواهند کرد. رله های صندوق دریافت پیام خصوصی می بایست هر پیامی از هر کسی را بپذیرند، اما فقط بگذارند شما آن ها را بارگیری کنید. انتخاب های خوب شامل: - - inbox.nostr.wine (پولی) -- auth.nostr1.com (رایگان) -- you.nostr1.com (رله پولی یا شخصی)</string> + <string name="private_inbox_section_explainer">بین ۱-۳ رله به عنوان رله صندوق ورودی خصوصی وارد کنید. دیگران از این رله ها برای ارسال پیام خصوصی به شما استفاده خواهند کرد. رله های صندوق دریافت پیام خصوصی می بایست هر پیامی از هر کسی را بپذیرند، اما فقط بگذارند شما آن ها را بارگیری کنید. انتخاب های خوب شامل: - inbox.nostr.wine (پولی) - auth.nostr1.com (رایگان) - you.nostr1.com (رله پولی یا شخصی)</string> <string name="private_inbox_section_explainer_profile">کاربر DM ها را روی این relay ها دریافت می‌کند</string> <string name="private_message">پیام خصوصی</string> <string name="private_note">خصوصی کردن: یادداشت را به صورت gift-wrap فقط برای کاربران مطلع‌شده ارسال کن</string> @@ -2782,10 +2760,7 @@ <string name="read_only_user">کاربر با امکان خواندن فقط</string> <string name="record_a_message_description">برای ضبط پیام کلیک کنید و نگه دارید</string> <string name="relay_chat">گفتگوی relay</string> - <string name="relay_chat_explainer">گفتگوهای relay گروه‌های چت هستند که توسط relay خانگی خود کنترل می‌شوند. - آن‌ها برای همه در Nostr قابل مشاهده هستند و هر کسی می‌تواند در آن‌ها شرکت کند. - آن‌ها برای جوامع باز حول موضوعات خاص عالی هستند. برخی از این گروه‌ها ناپایدار - هستند و بنابراین پیام‌های چت با گذشت زمان ناپدید می‌شوند</string> + <string name="relay_chat_explainer">گفتگوهای relay گروه‌های چت هستند که توسط relay خانگی خود کنترل می‌شوند. آن‌ها برای همه در Nostr قابل مشاهده هستند و هر کسی می‌تواند در آن‌ها شرکت کند. آن‌ها برای جوامع باز حول موضوعات خاص عالی هستند. برخی از این گروه‌ها ناپایدار هستند و بنابراین پیام‌های چت با گذشت زمان ناپدید می‌شوند</string> <string name="relay_chat_title">گفتگوهای relay</string> <string name="relay_management_tab_events">رویدادها</string> <string name="relay_management_tab_ips">آدرس‌های IP</string> @@ -2943,7 +2918,7 @@ <string name="tor_use_new_relays_explainer">تور را برای رله های ارسال/دریافت الزامی کن</string> <string name="tor_use_nip05_verification">تایید آدرس ناستر</string> <string name="tor_use_nip05_verification_explainer">تور را برای تایید آدرس های NIP-05 الزامی کن</string> - <string name="tor_use_onion_address">رله ها / آدرس های Onion </string> + <string name="tor_use_onion_address">رله ها / آدرس های Onion</string> <string name="tor_use_onion_address_explainer">از تور برای هر آدرس اونیون استفاده کن</string> <string name="tor_use_profile_pictures">تصاویر نمایه</string> <string name="tor_use_profile_pictures_explainer">تور را برای بارگیری تصاویر نمایه اجباری کن</string> diff --git a/commonsUI/src/commonMain/composeResources/values-fi-rFI/strings.xml b/commonsUI/src/commonMain/composeResources/values-fi-rFI/strings.xml index ede84f29eb..f2784cced4 100644 --- a/commonsUI/src/commonMain/composeResources/values-fi-rFI/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-fi-rFI/strings.xml @@ -268,15 +268,8 @@ <string name="mark_all_known_as_read">Merkitse kaikki Tunnetut luetuiksi</string> <string name="mark_all_new_as_read">Merkitse kaikki Uudet luetuiksi</string> <string name="mark_all_as_read">Merkitse kaikki luetuiksi</string> - <string name="account_backup_tips2_md"> ## Avainten varmuuskopiointi ja turvallisuusvinkit - \n\nTilisi on suojattu salaisella avaimella. Avain on pitkä merkkijono, joka alkaa **nsec1**:llä. Kuka tahansa, jolla on pääsy tähän salaiseen avaimeen, voi julkaista ja muuttaa henkilöllisyyttäsi. - \n\n- **Älä** anna salaista avaintasi mihinkään verkkosivustoon tai ohjelmistoon, johon et luota. - \n- Amethystin kehittäjät eivät **koskaan** pyydä salaista avaintasi. - \n- **Tee** turvallinen varmuuskopio salaisesta avaimestasi tilin palauttamista varten. Suosittelemme salasananhallintaohjelman käyttöä. - </string> - <string name="account_backup_tips3_md"> Lisäturvallisuuden vuoksi voit salata avaimesi salasanalla. Tämä avain alkaa **ncryptsec1**:llä eikä sitä voi käyttää ilman salasanaasi. - \n\nJos kadotat salasanasi, et pysty palauttamaan avaintasi. - </string> + <string name="account_backup_tips2_md">## Avainten varmuuskopiointi ja turvallisuusvinkit \n\nTilisi on suojattu salaisella avaimella. Avain on pitkä merkkijono, joka alkaa **nsec1**:llä. Kuka tahansa, jolla on pääsy tähän salaiseen avaimeen, voi julkaista ja muuttaa henkilöllisyyttäsi. \n\n- **Älä** anna salaista avaintasi mihinkään verkkosivustoon tai ohjelmistoon, johon et luota. \n- Amethystin kehittäjät eivät **koskaan** pyydä salaista avaintasi. \n- **Tee** turvallinen varmuuskopio salaisesta avaimestasi tilin palauttamista varten. Suosittelemme salasananhallintaohjelman käyttöä.</string> + <string name="account_backup_tips3_md">Lisäturvallisuuden vuoksi voit salata avaimesi salasanalla. Tämä avain alkaa **ncryptsec1**:llä eikä sitä voi käyttää ilman salasanaasi. \n\nJos kadotat salasanasi, et pysty palauttamaan avaintasi.</string> <string name="encrypt_and_copy_my_secret_key">Salaa ja kopioi salainen avaimeni</string> <string name="badge_award_image_for">Merkki kohteelle %1$s</string> <string name="badge_award_image">"Merkkipalkinnon kuva</string> @@ -959,8 +952,7 @@ <string name="push_server_none_explainer">Poistaa Push-ilmoitukset käytöstä</string> <string name="push_server_uses_app_explainer">Käyttää sovellusta %1$s</string> <string name="push_server_install_app">Push-ilmoituksen asennus</string> - <string name="push_server_install_app_description"> Saadaksesi push-ilmoituksia, asenna mikä tahansa sovellus, joka tukee Unified Push:ia, kuten Ntfy. - Asennuksen jälkeen valitse Asetuksista sovellus, jota haluat käyttää.</string> + <string name="push_server_install_app_description">Saadaksesi push-ilmoituksia, asenna mikä tahansa sovellus, joka tukee Unified Push:ia, kuten Ntfy. Asennuksen jälkeen valitse Asetuksista sovellus, jota haluat käyttää.</string> <string name="thread_title">Keskusteluketju</string> <string name="send_the_seller_a_message">Lähetä myyjälle viesti</string> <string name="hi_there_is_this_still_available">Hei, onko tämä vielä saatavilla?</string> @@ -2011,15 +2003,7 @@ <string name="could_not_fetch_invoice_from_details">Laskun hakeminen epäonnistui kohteesta %1$s: %2$s</string> <string name="could_not_open_the_compressed_file">Pakattua tiedostoa ei voitu avata</string> <string name="couldnt_find_nwc_wallets">Lompakkoa ei löydy</string> - <string name="couldnt_find_nwc_wallets_description">Amethyst ei löytänyt yhtään lompakkosovellusta, joka tukee Nostr Wallet Connect (NWC) -toimintoa. -\n\nMitä voit tehdä: -\n\t1. Tarkista NWC-tuki: Varmista, että haluamasi lompakkosovellus on yhteensopiva Nostr Wallet Connectin kanssa. -\n\t2. Luo yhteys: Siirry lompakkosovellukseesi ja luo uusi NWC-yhteys. -\n\t3. Linkitä Amethystiin: Lompakkosi tarjoaa yleensä kolme vaihtoehtoa: -\n\t\t3.1 Automaattinen linkitys: Painike, joka avaa Amethystin ja yhdistää automaattisesti. Jos näet sen, paina painiketta, avaa Amethyst ja tallenna uudella näytöllä. -\n\t\t3.2 Manuaalinen linkitys: YhteysURI, jonka voit kopioida ja liittää tämän näytön liitä-kuvakkeella. -\n\t\t3.3 QR-koodi: Lompakko luo QR-koodin, jonka voit lukea tämän näytön QR-koodipainikkeella. - </string> + <string name="couldnt_find_nwc_wallets_description">Amethyst ei löytänyt yhtään lompakkosovellusta, joka tukee Nostr Wallet Connect (NWC) -toimintoa. \n\nMitä voit tehdä: \n\t1. Tarkista NWC-tuki: Varmista, että haluamasi lompakkosovellus on yhteensopiva Nostr Wallet Connectin kanssa. \n\t2. Luo yhteys: Siirry lompakkosovellukseesi ja luo uusi NWC-yhteys. \n\t3. Linkitä Amethystiin: Lompakkosi tarjoaa yleensä kolme vaihtoehtoa: \n\t\t3.1 Automaattinen linkitys: Painike, joka avaa Amethystin ja yhdistää automaattisesti. Jos näet sen, paina painiketta, avaa Amethyst ja tallenna uudella näytöllä. \n\t\t3.2 Manuaalinen linkitys: YhteysURI, jonka voit kopioida ja liittää tämän näytön liitä-kuvakkeella. \n\t\t3.3 QR-koodi: Lompakko luo QR-koodin, jonka voit lukea tämän näytön QR-koodipainikkeella.</string> <string name="create">Luo</string> <string name="d">pv</string> <string name="danger_zone">Vaara-alue</string> @@ -2677,11 +2661,8 @@ <string name="proxy_section">Proxy-relait</string> <string name="proxy_section_explainer">Aggregaattorirelait, joita sovelluksen täytyy käyttää feedien lataamiseen, kuten filter.nostr.wine. Tämä korvaa outbox-mallin ja saa sovelluksen yhdistämään vain listoissasi oleviin relaihin.</string> <string name="public_chat">Julkinen keskustelu</string> - <string name="public_chat_explainer">Julkiset chatit ovat näkyvissä kaikille Nostrissa ja kuka tahansa - voi osallistua niihin. Ne sopivat hyvin avoimiin yhteisöihin tiettyjen aiheiden ympärille. - Moderointia voidaan hallita poistamalla julkaisuja sen relay-palvelimilta</string> - <string name="public_chat_relays_explainer">Syötä 1–3 relay-palvelinta, jotka isännöivät tätä ryhmää. - Nostr-asiakkaat käyttävät tätä asetusta tietääkseen, mistä ladata viestejä ja mihin lähettää viestisi.</string> + <string name="public_chat_explainer">Julkiset chatit ovat näkyvissä kaikille Nostrissa ja kuka tahansa voi osallistua niihin. Ne sopivat hyvin avoimiin yhteisöihin tiettyjen aiheiden ympärille. Moderointia voidaan hallita poistamalla julkaisuja sen relay-palvelimilta</string> + <string name="public_chat_relays_explainer">Syötä 1–3 relay-palvelinta, jotka isännöivät tätä ryhmää. Nostr-asiakkaat käyttävät tätä asetusta tietääkseen, mistä ladata viestejä ja mihin lähettää viestisi.</string> <string name="public_chat_relays_title">relay-palvelimet</string> <string name="public_chat_title">Julkisen chatin metatiedot</string> <string name="public_chats">Julkiset keskustelut</string> @@ -2706,10 +2687,7 @@ <string name="read_only_user">Vain luku -käyttäjä</string> <string name="record_a_message_description">Paina ja pidä pohjassa tallentaaksesi ääniviesti</string> <string name="relay_chat">relay-chat</string> - <string name="relay_chat_explainer">relay-chatit ovat chat-ryhmiä, joita hallinnoi niiden kotipalvelin. - Ne ovat näkyvissä kaikille Nostrissa ja kuka tahansa voi osallistua niihin. - Ne sopivat hyvin avoimiin yhteisöihin tiettyjen aiheiden ympärille. Osa näistä ryhmistä on katoavia, - joten chat-viestit häviävät ajan myötä</string> + <string name="relay_chat_explainer">relay-chatit ovat chat-ryhmiä, joita hallinnoi niiden kotipalvelin. Ne ovat näkyvissä kaikille Nostrissa ja kuka tahansa voi osallistua niihin. Ne sopivat hyvin avoimiin yhteisöihin tiettyjen aiheiden ympärille. Osa näistä ryhmistä on katoavia, joten chat-viestit häviävät ajan myötä</string> <string name="relay_chat_title">relay-chatit</string> <string name="relay_management_tab_events">Tapahtumat</string> <string name="relay_management_tab_ips">IP-osoitteet</string> diff --git a/commonsUI/src/commonMain/composeResources/values-fr-rCA/strings.xml b/commonsUI/src/commonMain/composeResources/values-fr-rCA/strings.xml index 6dbdac7e80..677d4afb32 100644 --- a/commonsUI/src/commonMain/composeResources/values-fr-rCA/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-fr-rCA/strings.xml @@ -218,7 +218,7 @@ <string name="copy_user_id">Copier l'ID utilisateur</string> <string name="unblock_user">Débloquer l'utilisateur</string> <string name="user_is_blocked_hidden">Cet utilisateur est bloqué/muet. Ses publications sont masquées.</string> - <string name="npub_hex_username">npub, hex, nom d'utilisateur </string> + <string name="npub_hex_username">npub, hex, nom d'utilisateur</string> <string name="clear">Nettoyer</string> <string name="app_logo">Logo de l'App</string> <string name="nsec_npub_hex_private_key">nsec / npub / hex private key</string> @@ -266,15 +266,8 @@ <string name="mark_all_known_as_read">Marquer tous les Connus comme lu</string> <string name="mark_all_new_as_read">Marquer tous les Nouveaux comme lu</string> <string name="mark_all_as_read">Tout marquer comme lu</string> - <string name="account_backup_tips2_md"> ## Sauvegarde des Clés et Conseils de Sécurité - \n\nVotre compte est sécurisé par une clé secrète. La clé est une longue séquence de caractères commençant par **nsec1**. Toute personne ayant accès à cette clé secrète peut publier et modifier votre identité. - \n\n- Ne mettez **pas** votre clé secrète sur un site web ou un logiciel auquel vous ne faites pas confiance. - \n- Les développeurs d'Amethyst ne vous demanderont **jamais** votre clé secrète. - \n- **Faites** une sauvegarde sécurisée de votre clé secrète pour la récupération de votre compte. Nous vous recommandons d'utiliser un gestionnaire de mots de passe. - </string> - <string name="account_backup_tips3_md"> Pour plus de sécurité, vous pouvez chiffrer votre clé avec un mot de passe. Cette clé commence par **ncryptsec1** et ne peut être utilisée sans votre mot de passe. - \n\nSi vous perdez votre mot de passe, vous ne pourrez pas récupérer votre clé. - </string> + <string name="account_backup_tips2_md">## Sauvegarde des Clés et Conseils de Sécurité \n\nVotre compte est sécurisé par une clé secrète. La clé est une longue séquence de caractères commençant par **nsec1**. Toute personne ayant accès à cette clé secrète peut publier et modifier votre identité. \n\n- Ne mettez **pas** votre clé secrète sur un site web ou un logiciel auquel vous ne faites pas confiance. \n- Les développeurs d'Amethyst ne vous demanderont **jamais** votre clé secrète. \n- **Faites** une sauvegarde sécurisée de votre clé secrète pour la récupération de votre compte. Nous vous recommandons d'utiliser un gestionnaire de mots de passe.</string> + <string name="account_backup_tips3_md">Pour plus de sécurité, vous pouvez chiffrer votre clé avec un mot de passe. Cette clé commence par **ncryptsec1** et ne peut être utilisée sans votre mot de passe. \n\nSi vous perdez votre mot de passe, vous ne pourrez pas récupérer votre clé.</string> <string name="encrypt_and_copy_my_secret_key">Chiffrer et copier ma clé secrète</string> <string name="badge_award_image_for">Image de badge pour %1$s</string> <string name="badge_award_image">Image du badge de récompense</string> @@ -823,7 +816,7 @@ <string name="max_subs">Souscription maximale</string> <string name="max_filters_per_sub">Nombre maximum de filtres par sub</string> <string name="max_limit_events_returning">Limite Max (Renvoi des Évènements)</string> - <string name="max_subid_length">Longueur maximale du SubID </string> + <string name="max_subid_length">Longueur maximale du SubID</string> <string name="cashu_mint_label">Mint : %1$s</string> <string name="cashu_redeem">Échanger</string> <string name="cashu_copy_token">Copier le Jeton</string> @@ -938,9 +931,7 @@ <string name="push_server_none_explainer">Désactive les Notifications Push</string> <string name="push_server_uses_app_explainer">Utilise l'application %1$s</string> <string name="push_server_install_app">Configuration des Notifications Push</string> - <string name="push_server_install_app_description"> Pour recevoir des notifications push, installez n'importe quelle application qui supporte les [Unified Push](https://unifiedpush.org/), tel que [Ntfy](https://ntfy.sh/). - Après installation, sélectionnez l'application que vous voulez utiliser dans les Paramètres. - </string> + <string name="push_server_install_app_description">Pour recevoir des notifications push, installez n'importe quelle application qui supporte les [Unified Push](https://unifiedpush.org/), tel que [Ntfy](https://ntfy.sh/). Après installation, sélectionnez l'application que vous voulez utiliser dans les Paramètres.</string> <string name="thread_title">Fil de discussion</string> <string name="send_the_seller_a_message">Envoyer un message au vendeur</string> <string name="hi_there_is_this_still_available">Bonjour, est-ce toujours disponible ?</string> @@ -2603,11 +2594,8 @@ <string name="proxy_section">Relais Proxy</string> <string name="proxy_section_explainer">Relais agrégateurs que l'application doit utiliser pour télécharger vos flux, tels que filter.nostr.wine. Cela remplace le modèle de boîte d'envoi et permettra à l'application de se connecter uniquement aux relais de vos listes.</string> <string name="public_chat">Chat public</string> - <string name="public_chat_explainer">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</string> - <string name="public_chat_relays_explainer">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.</string> + <string name="public_chat_explainer">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</string> + <string name="public_chat_relays_explainer">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.</string> <string name="public_chat_relays_title">Relais</string> <string name="public_chat_title">Métadonnées de tchat public</string> <string name="public_chats">Chats publics</string> @@ -2632,10 +2620,7 @@ <string name="read_only_user">Utilisateur en lecture seule</string> <string name="record_a_message_description">Cliquez et maintenez pour enregistrer un message</string> <string name="relay_chat">Relais de discussion</string> - <string name="relay_chat_explainer">Les relais de discussions sont des groupes de discussion contrôlés par leur relais d'origine. - Ils sont visibles par tous les utilisateurs de Nostr et tout le monde peut y participer. - Ils sont idéaux pour les communautés ouvertes autour de sujets spécifiques. Certains de ces groupes sont éphémères - et, par conséquent, les messages de discussion disparaissent avec le temps</string> + <string name="relay_chat_explainer">Les relais de discussions sont des groupes de discussion contrôlés par leur relais d'origine. Ils sont visibles par tous les utilisateurs de Nostr et tout le monde peut y participer. Ils sont idéaux pour les communautés ouvertes autour de sujets spécifiques. Certains de ces groupes sont éphémères et, par conséquent, les messages de discussion disparaissent avec le temps</string> <string name="relay_chat_title">Relais de discussions</string> <string name="relay_management_tab_events">Événements</string> <string name="relay_management_tab_pubkeys">Utilisateurs</string> diff --git a/commonsUI/src/commonMain/composeResources/values-fr-rFR/strings.xml b/commonsUI/src/commonMain/composeResources/values-fr-rFR/strings.xml index 41d6f05237..76a4f93daf 100644 --- a/commonsUI/src/commonMain/composeResources/values-fr-rFR/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-fr-rFR/strings.xml @@ -220,7 +220,7 @@ <string name="copy_user_id">Copier l'ID utilisateur</string> <string name="unblock_user">Débloquer l'utilisateur</string> <string name="user_is_blocked_hidden">Cet utilisateur est bloqué/muet. Ses publications sont masquées.</string> - <string name="npub_hex_username">npub, hex, nom d'utilisateur </string> + <string name="npub_hex_username">npub, hex, nom d'utilisateur</string> <string name="clear">Nettoyer</string> <string name="app_logo">Logo de l'App</string> <string name="nsec_npub_hex_private_key">nsec / npub / hex private key</string> @@ -268,15 +268,8 @@ <string name="mark_all_known_as_read">Marquer tous les Connus comme lu</string> <string name="mark_all_new_as_read">Marquer tous les Nouveaux comme lu</string> <string name="mark_all_as_read">Tout marquer comme lu</string> - <string name="account_backup_tips2_md"> ## Sauvegarde des Clés et Conseils de Sécurité - \n\nVotre compte est sécurisé par une clé secrète. La clé est une longue séquence de caractères commençant par **nsec1**. Toute personne ayant accès à cette clé secrète peut publier et modifier votre identité. - \n\n- Ne mettez **pas** votre clé secrète sur un site web ou un logiciel auquel vous ne faites pas confiance. - \n- Les développeurs d'Amethyst ne vous demanderont **jamais** votre clé secrète. - \n- **Faites** une sauvegarde sécurisée de votre clé secrète pour la récupération de votre compte. Nous vous recommandons d'utiliser un gestionnaire de mots de passe. - </string> - <string name="account_backup_tips3_md"> Pour plus de sécurité, vous pouvez chiffrer votre clé avec un mot de passe. Cette clé commence par **ncryptsec1** et ne peut être utilisée sans votre mot de passe. - \n\nSi vous perdez votre mot de passe, vous ne pourrez pas récupérer votre clé. - </string> + <string name="account_backup_tips2_md">## Sauvegarde des Clés et Conseils de Sécurité \n\nVotre compte est sécurisé par une clé secrète. La clé est une longue séquence de caractères commençant par **nsec1**. Toute personne ayant accès à cette clé secrète peut publier et modifier votre identité. \n\n- Ne mettez **pas** votre clé secrète sur un site web ou un logiciel auquel vous ne faites pas confiance. \n- Les développeurs d'Amethyst ne vous demanderont **jamais** votre clé secrète. \n- **Faites** une sauvegarde sécurisée de votre clé secrète pour la récupération de votre compte. Nous vous recommandons d'utiliser un gestionnaire de mots de passe.</string> + <string name="account_backup_tips3_md">Pour plus de sécurité, vous pouvez chiffrer votre clé avec un mot de passe. Cette clé commence par **ncryptsec1** et ne peut être utilisée sans votre mot de passe. \n\nSi vous perdez votre mot de passe, vous ne pourrez pas récupérer votre clé.</string> <string name="encrypt_and_copy_my_secret_key">Chiffrer et copier ma clé secrète</string> <string name="badge_award_image_for">Image de badge pour %1$s</string> <string name="badge_award_image">Image du badge de récompense</string> @@ -905,7 +898,7 @@ <string name="max_subs">Souscription maximale</string> <string name="max_filters_per_sub">Nombre maximum de filtres par sub</string> <string name="max_limit_events_returning">Limite Max (Renvoi des Évènements)</string> - <string name="max_subid_length">Longueur maximale du SubID </string> + <string name="max_subid_length">Longueur maximale du SubID</string> <string name="cashu_mint_label">Mint : %1$s</string> <string name="cashu_redeem">Échanger</string> <string name="cashu_copy_token">Copier le Jeton</string> @@ -1039,9 +1032,7 @@ <string name="push_server_none_explainer">Désactive les Notifications Push</string> <string name="push_server_uses_app_explainer">Utilise l'application %1$s</string> <string name="push_server_install_app">Configuration des Notifications Push</string> - <string name="push_server_install_app_description"> Pour recevoir des notifications push, installez n'importe quelle application qui supporte les [Unified Push](https://unifiedpush.org/), tel que [Ntfy](https://ntfy.sh/). - Après installation, sélectionnez l'application que vous voulez utiliser dans les Paramètres. - </string> + <string name="push_server_install_app_description">Pour recevoir des notifications push, installez n'importe quelle application qui supporte les [Unified Push](https://unifiedpush.org/), tel que [Ntfy](https://ntfy.sh/). Après installation, sélectionnez l'application que vous voulez utiliser dans les Paramètres.</string> <string name="thread_title">Fil de discussion</string> <string name="send_the_seller_a_message">Envoyer un message au vendeur</string> <string name="hi_there_is_this_still_available">Bonjour, est-ce toujours disponible ?</string> @@ -2883,11 +2874,8 @@ <string name="proxy_section">Relais Proxy</string> <string name="proxy_section_explainer">Relais agrégateurs que l'application doit utiliser pour télécharger vos flux, tels que filter.nostr.wine. Cela remplace le modèle de boîte d'envoi et permettra à l'application de se connecter uniquement aux relais de vos listes.</string> <string name="public_chat">Chat public</string> - <string name="public_chat_explainer">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</string> - <string name="public_chat_relays_explainer">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.</string> + <string name="public_chat_explainer">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</string> + <string name="public_chat_relays_explainer">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.</string> <string name="public_chat_relays_title">Relais</string> <string name="public_chat_title">Métadonnées de tchat public</string> <string name="public_chats">Chats publics</string> @@ -2912,10 +2900,7 @@ <string name="read_only_user">Utilisateur en lecture seule</string> <string name="record_a_message_description">Cliquez et maintenez pour enregistrer un message</string> <string name="relay_chat">Relais de discussion</string> - <string name="relay_chat_explainer">Les relais de discussions sont des groupes de discussion contrôlés par leur relais d'origine. - Ils sont visibles par tous les utilisateurs de Nostr et tout le monde peut y participer. - Ils sont idéaux pour les communautés ouvertes autour de sujets spécifiques. Certains de ces groupes sont éphémères - et, par conséquent, les messages de discussion disparaissent avec le temps</string> + <string name="relay_chat_explainer">Les relais de discussions sont des groupes de discussion contrôlés par leur relais d'origine. Ils sont visibles par tous les utilisateurs de Nostr et tout le monde peut y participer. Ils sont idéaux pour les communautés ouvertes autour de sujets spécifiques. Certains de ces groupes sont éphémères et, par conséquent, les messages de discussion disparaissent avec le temps</string> <string name="relay_chat_title">Relais de discussions</string> <string name="relay_group_flag_private">Privé</string> <string name="relay_management_tab_events">Événements</string> diff --git a/commonsUI/src/commonMain/composeResources/values-fr/strings.xml b/commonsUI/src/commonMain/composeResources/values-fr/strings.xml index 2f9de43271..41b7112fcb 100644 --- a/commonsUI/src/commonMain/composeResources/values-fr/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-fr/strings.xml @@ -211,7 +211,7 @@ <string name="copy_user_id">Copier l'ID utilisateur</string> <string name="unblock_user">Débloquer l'utilisateur</string> <string name="user_is_blocked_hidden">Cet utilisateur est bloqué/muet. Ses publications sont masquées.</string> - <string name="npub_hex_username">npub, hex, nom d'utilisateur </string> + <string name="npub_hex_username">npub, hex, nom d'utilisateur</string> <string name="clear">Nettoyer</string> <string name="app_logo">Logo de l'App</string> <string name="nsec_npub_hex_private_key">nsec / npub / hex private key</string> @@ -259,15 +259,8 @@ <string name="mark_all_known_as_read">Marquer tous les Connus comme lu</string> <string name="mark_all_new_as_read">Marquer tous les Nouveaux comme lu</string> <string name="mark_all_as_read">Tout marquer comme lu</string> - <string name="account_backup_tips2_md"> ## Sauvegarde des Clés et Conseils de Sécurité - \n\nVotre compte est sécurisé par une clé secrète. La clé est une longue séquence de caractères commençant par **nsec1**. Toute personne ayant accès à cette clé secrète peut publier et modifier votre identité. - \n\n- Ne mettez **pas** votre clé secrète sur un site web ou un logiciel auquel vous ne faites pas confiance. - \n- Les développeurs d'Amethyst ne vous demanderont **jamais** votre clé secrète. - \n- **Faites** une sauvegarde sécurisée de votre clé secrète pour la récupération de votre compte. Nous vous recommandons d'utiliser un gestionnaire de mots de passe. - </string> - <string name="account_backup_tips3_md"> Pour plus de sécurité, vous pouvez chiffrer votre clé avec un mot de passe. Cette clé commence par **ncryptsec1** et ne peut être utilisée sans votre mot de passe. - \n\nSi vous perdez votre mot de passe, vous ne pourrez pas récupérer votre clé. - </string> + <string name="account_backup_tips2_md">## Sauvegarde des Clés et Conseils de Sécurité \n\nVotre compte est sécurisé par une clé secrète. La clé est une longue séquence de caractères commençant par **nsec1**. Toute personne ayant accès à cette clé secrète peut publier et modifier votre identité. \n\n- Ne mettez **pas** votre clé secrète sur un site web ou un logiciel auquel vous ne faites pas confiance. \n- Les développeurs d'Amethyst ne vous demanderont **jamais** votre clé secrète. \n- **Faites** une sauvegarde sécurisée de votre clé secrète pour la récupération de votre compte. Nous vous recommandons d'utiliser un gestionnaire de mots de passe.</string> + <string name="account_backup_tips3_md">Pour plus de sécurité, vous pouvez chiffrer votre clé avec un mot de passe. Cette clé commence par **ncryptsec1** et ne peut être utilisée sans votre mot de passe. \n\nSi vous perdez votre mot de passe, vous ne pourrez pas récupérer votre clé.</string> <string name="encrypt_and_copy_my_secret_key">Chiffrer et copier ma clé secrète</string> <string name="badge_award_image_for">Image de badge pour %1$s</string> <string name="badge_award_image">Image du badge de récompense</string> @@ -844,7 +837,7 @@ <string name="max_subs">Souscription maximale</string> <string name="max_filters_per_sub">Nombre maximum de filtres par sub</string> <string name="max_limit_events_returning">Limite Max (Renvoi des Évènements)</string> - <string name="max_subid_length">Longueur maximale du SubID </string> + <string name="max_subid_length">Longueur maximale du SubID</string> <string name="cashu_mint_label">Mint : %1$s</string> <string name="cashu_redeem">Échanger</string> <string name="cashu_copy_token">Copier le Jeton</string> @@ -960,9 +953,7 @@ <string name="push_server_none_explainer">Désactive les Notifications Push</string> <string name="push_server_uses_app_explainer">Utilise l'application %1$s</string> <string name="push_server_install_app">Configuration des Notifications Push</string> - <string name="push_server_install_app_description"> Pour recevoir des notifications push, installez n'importe quelle application qui supporte les [Unified Push](https://unifiedpush.org/), tel que [Ntfy](https://ntfy.sh/). - Après installation, sélectionnez l'application que vous voulez utiliser dans les Paramètres. - </string> + <string name="push_server_install_app_description">Pour recevoir des notifications push, installez n'importe quelle application qui supporte les [Unified Push](https://unifiedpush.org/), tel que [Ntfy](https://ntfy.sh/). Après installation, sélectionnez l'application que vous voulez utiliser dans les Paramètres.</string> <string name="thread_title">Fil de discussion</string> <string name="send_the_seller_a_message">Envoyer un message au vendeur</string> <string name="hi_there_is_this_still_available">Bonjour, est-ce toujours disponible ?</string> @@ -2743,11 +2734,8 @@ <string name="proxy_section">Relais Proxy</string> <string name="proxy_section_explainer">Relais agrégateurs que l'application doit utiliser pour télécharger vos flux, tels que filter.nostr.wine. Cela remplace le modèle de boîte d'envoi et permettra à l'application de se connecter uniquement aux relais de vos listes.</string> <string name="public_chat">Chat public</string> - <string name="public_chat_explainer">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</string> - <string name="public_chat_relays_explainer">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.</string> + <string name="public_chat_explainer">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</string> + <string name="public_chat_relays_explainer">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.</string> <string name="public_chat_relays_title">Relais</string> <string name="public_chat_title">Métadonnées de tchat public</string> <string name="public_chats">Chats publics</string> @@ -2772,10 +2760,7 @@ <string name="read_only_user">Utilisateur en lecture seule</string> <string name="record_a_message_description">Cliquez et maintenez pour enregistrer un message</string> <string name="relay_chat">Relais de discussion</string> - <string name="relay_chat_explainer">Les relais de discussions sont des groupes de discussion contrôlés par leur relais d'origine. - Ils sont visibles par tous les utilisateurs de Nostr et tout le monde peut y participer. - Ils sont idéaux pour les communautés ouvertes autour de sujets spécifiques. Certains de ces groupes sont éphémères - et, par conséquent, les messages de discussion disparaissent avec le temps</string> + <string name="relay_chat_explainer">Les relais de discussions sont des groupes de discussion contrôlés par leur relais d'origine. Ils sont visibles par tous les utilisateurs de Nostr et tout le monde peut y participer. Ils sont idéaux pour les communautés ouvertes autour de sujets spécifiques. Certains de ces groupes sont éphémères et, par conséquent, les messages de discussion disparaissent avec le temps</string> <string name="relay_chat_title">Relais de discussions</string> <string name="relay_management_tab_events">Événements</string> <string name="relay_management_tab_ips">IPs</string> diff --git a/commonsUI/src/commonMain/composeResources/values-hi-rIN/strings.xml b/commonsUI/src/commonMain/composeResources/values-hi-rIN/strings.xml index 2d16c9c513..cde8a721e1 100644 --- a/commonsUI/src/commonMain/composeResources/values-hi-rIN/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-hi-rIN/strings.xml @@ -646,15 +646,8 @@ <string name="mark_all_known_as_read">सब ज्ञात को पढ लिया चिह्नित करें</string> <string name="mark_all_new_as_read">सब नये को पढ लिया चिह्नित करें</string> <string name="mark_all_as_read">सब को पढ लिया चिह्नित करें</string> - <string name="account_backup_tips2_md">## कुंचिका सुरक्षार्थ अनुकृति तथा सुरक्षा सुझाव - \n\nआपकी लेखा एक गुप्त कुंचिका से सुरक्षित किया गया है। कुंचिका अक्षरों की एक लम्बी श्रृंखला है जो **nsec1** से आरम्भ होता है। जिस किसी के पास यह कुंचिका है वह पत्र प्रकाशित कर सकेगा तथा आपका परिचय परिवर्तित कर सकेगा। - \n\n- आपकी गुप्त कुंचिका किसी भी जालस्थान अथवा क्रमक में **ना** डालें जिसपर आपका विश्वास ना हो। - \n- अमेथिस्ट क्रमलेख विकासकर्ता आपकी गुप्त कुंचिका **कभी नहीं** पूछेंगे। - \n- आपकी गुप्त कुंचिका की एक सुरक्षित अनुकृति **अवश्य** रखें अपनी लेखा पुनः प्राप्त करने के लिए। पारणशब्ध प्रबंधन क्रमक की पुनः प्रशंसा करते हैं। - </string> - <string name="account_backup_tips3_md"> अधिक सुरक्षा के लिए आप अपनी कुंचिका का रहस्यीकरण कर सकते हैं एक पारणशब्द के साथ। यह कुंचिका आरम्भ होगी **ncryptsec1** से तथा पारणशब्द के बिना प्रयोग साध्य नहीं। - \n\n यदि आप अपना पारणशब्द खो देते हैं, तो आप अपनी कुंचिका को पुनः प्राप्त नहीं कर सकते। - </string> + <string name="account_backup_tips2_md">## कुंचिका सुरक्षार्थ अनुकृति तथा सुरक्षा सुझाव \n\nआपकी लेखा एक गुप्त कुंचिका से सुरक्षित किया गया है। कुंचिका अक्षरों की एक लम्बी श्रृंखला है जो **nsec1** से आरम्भ होता है। जिस किसी के पास यह कुंचिका है वह पत्र प्रकाशित कर सकेगा तथा आपका परिचय परिवर्तित कर सकेगा। \n\n- आपकी गुप्त कुंचिका किसी भी जालस्थान अथवा क्रमक में **ना** डालें जिसपर आपका विश्वास ना हो। \n- अमेथिस्ट क्रमलेख विकासकर्ता आपकी गुप्त कुंचिका **कभी नहीं** पूछेंगे। \n- आपकी गुप्त कुंचिका की एक सुरक्षित अनुकृति **अवश्य** रखें अपनी लेखा पुनः प्राप्त करने के लिए। पारणशब्ध प्रबंधन क्रमक की पुनः प्रशंसा करते हैं।</string> + <string name="account_backup_tips3_md">अधिक सुरक्षा के लिए आप अपनी कुंचिका का रहस्यीकरण कर सकते हैं एक पारणशब्द के साथ। यह कुंचिका आरम्भ होगी **ncryptsec1** से तथा पारणशब्द के बिना प्रयोग साध्य नहीं। \n\n यदि आप अपना पारणशब्द खो देते हैं, तो आप अपनी कुंचिका को पुनः प्राप्त नहीं कर सकते।</string> <string name="backup_keys_nudge_title">अपने कुंचिकाओं को सुरक्षित रखें</string> <string name="backup_keys_nudge_body">आपकी गुप्त कुंचिका एकमात्र विधि है इस लेखा का अभिगमन करने के लिए। यदि आप इसे खो देते हैं तो इसे कभी भी पुनःप्राप्त नहीं कर सकते। इसे कहीं सुरक्षित रखें अभी इस समय।</string> <string name="backup_keys_nudge_backup_now">अभी इसी समय सुरक्षित अनुकृति बनाएँ</string> @@ -1834,9 +1827,7 @@ <string name="push_server_none_explainer">प्रेषित सूचनाएँ अक्षम करता है</string> <string name="push_server_uses_app_explainer">%1$s क्रमक का प्रयोग करता है</string> <string name="push_server_install_app">प्रेषित सूचना स्थापन</string> - <string name="push_server_install_app_description"> प्रेषित सूचनाएँ प्राप्त करने के लिए, किसी भी क्रमक की स्थापना करें जो [संयुक्त प्रेषण](https://unifiedpush.org/) का अवलम्बन करता है, जैसे [एनटीफै](https://ntfy.sh/)। - स्थापना पश्चात, जिस क्रमक का उपयोग करना चाहते हैं उसका चयन करें स्थापना विकल्पों में। - </string> + <string name="push_server_install_app_description">प्रेषित सूचनाएँ प्राप्त करने के लिए, किसी भी क्रमक की स्थापना करें जो [संयुक्त प्रेषण](https://unifiedpush.org/) का अवलम्बन करता है, जैसे [एनटीफै](https://ntfy.sh/)। स्थापना पश्चात, जिस क्रमक का उपयोग करना चाहते हैं उसका चयन करें स्थापना विकल्पों में।</string> <string name="thread_title">सूत्र</string> <string name="send_the_seller_a_message">विक्रेता को संदेश भेजें</string> <string name="hi_there_is_this_still_available">नमस्ते, क्या यह अब भी उपलब्ध है?</string> @@ -3585,17 +3576,9 @@ <string name="could_not_fetch_invoice_from_details">चालान नहीं लाया जा सका %1$s से : %2$s</string> <string name="could_not_open_the_compressed_file">संकुचित अभिलेख को खोल नहीं पाए</string> <string name="couldnt_find_nwc_wallets">धनकोष अप्राप्त</string> - <string name="couldnt_find_nwc_wallets_description">अमेथिस्ट को कोई धनकोष क्रमक प्राप्य नहीं जो नोस्टर धनकोष संयोजन का अवलम्बन करता हो। -\n\nआप यह कर सकते हैं : -\n\t१॰ नोस्टर धनकोष संयोजन अवलम्बन का सत्यापन करें। दुबारा जाँचें की आपका मनचाहा धनकोष क्रमक नोस्टर धनकोष संयोजन के साथ संगत है। -\n\t२॰ संयोजन बनाएँ। आपके धनकोष क्रमक में जाकर एक नया नोस्टर धनकोष संयोजन बनाएँ। -\n\t३॰ अमेथिस्ट के साथ जोडें। आपका धनकोष क्रमक सामान्यतः तीन विकल्प देगा। -\n\t\t३॰१ स्वचालित जोड। एक घुण्डी जिससे स्वचालित रूप से खोलें तथा अमेथिस्ट से जोडें। यदि हो तो उस घुण्डी को टाँकें तथा अमेथिस्ट खोलें तथा नव्य पटल पर अभिलेखन करें। -\n\t\t३॰२ मनुष्यकृत जोड। एक संयोजन वैश्विकसंसाधनपरिचायक जिसकी अनुकृति करके सीधे इस पटल पर प्रविष्ट कर सकते हैं। -\n\t\t३॰३ क्यूआर॰ चित्र अक्षरराशि। धनकोष क्रमक एक क्यूआर॰ चित्र अक्षरराशि बनएगा जिसे आप परख सकते हैं इस पटल पर क्यूआर॰ क्रमचित्र घुण्डी द्वारा। - </string> + <string name="couldnt_find_nwc_wallets_description">अमेथिस्ट को कोई धनकोष क्रमक प्राप्य नहीं जो नोस्टर धनकोष संयोजन का अवलम्बन करता हो। \n\nआप यह कर सकते हैं : \n\t१॰ नोस्टर धनकोष संयोजन अवलम्बन का सत्यापन करें। दुबारा जाँचें की आपका मनचाहा धनकोष क्रमक नोस्टर धनकोष संयोजन के साथ संगत है। \n\t२॰ संयोजन बनाएँ। आपके धनकोष क्रमक में जाकर एक नया नोस्टर धनकोष संयोजन बनाएँ। \n\t३॰ अमेथिस्ट के साथ जोडें। आपका धनकोष क्रमक सामान्यतः तीन विकल्प देगा। \n\t\t३॰१ स्वचालित जोड। एक घुण्डी जिससे स्वचालित रूप से खोलें तथा अमेथिस्ट से जोडें। यदि हो तो उस घुण्डी को टाँकें तथा अमेथिस्ट खोलें तथा नव्य पटल पर अभिलेखन करें। \n\t\t३॰२ मनुष्यकृत जोड। एक संयोजन वैश्विकसंसाधनपरिचायक जिसकी अनुकृति करके सीधे इस पटल पर प्रविष्ट कर सकते हैं। \n\t\t३॰३ क्यूआर॰ चित्र अक्षरराशि। धनकोष क्रमक एक क्यूआर॰ चित्र अक्षरराशि बनएगा जिसे आप परख सकते हैं इस पटल पर क्यूआर॰ क्रमचित्र घुण्डी द्वारा।</string> <string name="create">बनाएँ</string> - <string name="d"> दि॰</string> + <string name="d">दि॰</string> <string name="danger_zone">संकट क्षेत्र</string> <string name="dark">अन्धकारमय</string> <string name="description">विवरण</string> @@ -3791,7 +3774,7 @@ <string name="git_repositories_search_close">छलनी आवृत</string> <string name="git_repositories_search_open">क्रमलेखकोश छलनी</string> <string name="goal_progress">%1$s वित्तपोषित %2$s साट्स उद्देश्य में से</string> - <string name="h"> घं॰</string> + <string name="h">घं॰</string> <string name="hash_verification_failed">यह विषयवस्तु परिवर्तित हुआ है। हो सकता है लेखक ने परिवर्तन देखा नहीं अथवा अनुमति दिया नहीं।</string> <string name="hash_verification_info_title">इसका अर्थ क्या है?</string> <string name="hash_verification_passed">यह विषयवस्तु वैसे ही है जैसे पत्र प्रकाशन पर</string> @@ -4080,7 +4063,7 @@ <string name="login_with_a_private_key_to_be_able_to_upload">आप ख्याप्य कुंचिका का प्रयोग कर रहे हैं जिससे केवल पढ सकते हैं। आरोहण करने के लिए गुप्त कुंचिका के साथ प्रवेशांकन करें</string> <string name="login_with_a_private_key_to_like_posts">आप ख्याप्य कुंचिका का प्रयोग कर रहे हैं जिससे केवल पढ सकते हैं। प्रकाशित पत्रों को चाहने के लिए गुप्त कुंचिका के साथ प्रवेशांकन करें</string> <string name="longs">चलचित्र</string> - <string name="m"> मि॰</string> + <string name="m">मि॰</string> <string name="manage_emoji_packs">मेरे भावचिह्न पोटलियाँ</string> <string name="marmot_add_photo">चित्र जोडें</string> <string name="marmot_adding_user">जोडा जा रहा है %1$s…</string> @@ -4711,11 +4694,8 @@ <string name="proxy_section">प्रतिनिधि पुनःप्रसारक</string> <string name="proxy_section_explainer">संग्रहकर्ता पुनःप्रसारक जो क्रमक द्वारा उपयुक्त होना चाहिए आपके सूचनावलियों का अवरोहण करने के लिए। जैसे कि filter.nostr.wine । यह निर्गतपेटिका प्रतिरूप को प्रतिस्थापित करेगा तथा क्रमक आपके सूचियों में पुनःप्रसारकों के साथ ही जुडेगा।</string> <string name="public_chat">सार्वजनिक चर्चा</string> - <string name="public_chat_explainer">सार्वजनिक चर्चाएँ सबके लिए दृश्यमान हैं नोस्टर पर तथा सभी - इनमें भाग ले सकते हैं। ये उत्कृष्ट हैं खुले समुदायों के लिए विशिष्ट विषयों के प्रसंग में। - नियमन का नियन्त्रण किया जा सकता है पत्रों को मिटाने से पुनःप्रसारकों में</string> - <string name="public_chat_relays_explainer">इस समुदाय के निवास के लिए १ - ३ पुनःप्रसारकों को जोडें। - नोस्टर ग्राहक इस स्थापना विकल्प का प्रयोग करेंगे यह जानने के लिए कि कहाँ से सन्देशों का अवरोहण करना है तथा कहाँ आपके सन्देश भेजने हैं।</string> + <string name="public_chat_explainer">सार्वजनिक चर्चाएँ सबके लिए दृश्यमान हैं नोस्टर पर तथा सभी इनमें भाग ले सकते हैं। ये उत्कृष्ट हैं खुले समुदायों के लिए विशिष्ट विषयों के प्रसंग में। नियमन का नियन्त्रण किया जा सकता है पत्रों को मिटाने से पुनःप्रसारकों में</string> + <string name="public_chat_relays_explainer">इस समुदाय के निवास के लिए १ - ३ पुनःप्रसारकों को जोडें। नोस्टर ग्राहक इस स्थापना विकल्प का प्रयोग करेंगे यह जानने के लिए कि कहाँ से सन्देशों का अवरोहण करना है तथा कहाँ आपके सन्देश भेजने हैं।</string> <string name="public_chat_relays_title">पुनःप्रसारक</string> <string name="public_chat_title">सार्वजनिक चर्चा उपतथ्य</string> <string name="public_chats">सार्वजनिक चर्चा</string> @@ -4780,10 +4760,7 @@ <string name="relay_auth_search_keywords">प्रमाण प्रमाणीकरण प्रवेशांकन पुनःप्रसारक हस्ताक्षरण सत्यापन निप॰४२ परिचय</string> <string name="relay_auth_settings_title">पुनःप्रसारक प्रमाणीकरण</string> <string name="relay_chat">पुनःप्रसारक चर्चा</string> - <string name="relay_chat_explainer">पुनःप्रसारक चर्चाएँ आवासी पुनःप्रसारक द्वारा नियन्त्रित चर्चा समुदाएँ हैं। - वे नोस्टर पर सभी के लिए दृश्य हैं तथा उनमें सभी भाग ले सकते हैं। - वे उत्कृष्ट हैं खुले समुदायों के लिए विशिष्ट विषयों पर। इनमें से कुछ समुदाएँ अस्थायी हैं। - तथा इसीलिए समय के साथ चर्चा सन्देश अदृश्य हो जाते हैं</string> + <string name="relay_chat_explainer">पुनःप्रसारक चर्चाएँ आवासी पुनःप्रसारक द्वारा नियन्त्रित चर्चा समुदाएँ हैं। वे नोस्टर पर सभी के लिए दृश्य हैं तथा उनमें सभी भाग ले सकते हैं। वे उत्कृष्ट हैं खुले समुदायों के लिए विशिष्ट विषयों पर। इनमें से कुछ समुदाएँ अस्थायी हैं। तथा इसीलिए समय के साथ चर्चा सन्देश अदृश्य हो जाते हैं</string> <string name="relay_chat_title">पुनःप्रसारक चर्चाएँ</string> <string name="relay_explain_account_data">आपके अपने परिचय तथा स्थापना विकल्प तथा पाण्डुलिपियाँ। आपके मुख्य पुनःप्रसारकों पर।</string> <string name="relay_explain_community_chats">पुनःप्रसारक जिनपर प्रत्येक समुदाय अपने समतलों को प्रकाशित करते हैं।</string> diff --git a/commonsUI/src/commonMain/composeResources/values-hu-rHU/strings.xml b/commonsUI/src/commonMain/composeResources/values-hu-rHU/strings.xml index 4931f70f61..f7872f3f26 100644 --- a/commonsUI/src/commonMain/composeResources/values-hu-rHU/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-hu-rHU/strings.xml @@ -311,7 +311,7 @@ <string name="podcast_value_split_percent">%1$d%%</string> <string name="podcast_value_for_value">Értéket az értékért</string> <string name="relay_monitor_rtt_open">Megnyitás</string> - <string name="relay_monitor_rtt_read">Olvasás </string> + <string name="relay_monitor_rtt_read">Olvasás</string> <string name="relay_monitor_rtt_write">Írás</string> <string name="relay_monitor_network">Hálózat</string> <string name="relay_monitor_relay_type">Típus</string> @@ -461,8 +461,7 @@ <string name="send_payment_receipt_section">Zap-igazolás</string> <string name="send_payment_receipt_clink">Közvetlen lightning-fizetés - nem lesz zapigazolás közzétéve a Nostr-on.</string> <string name="send_payment_receipt_onchain">Egy láncon belüli zapigazolás közzé lesz téve a Nostr-on, így a címzett megtalálhatja a kifizetést.</string> - <string name="send_payment_receipt_onchain_address">Fizetés közvetlenül a láncon belüli pénztárcából a profil megadott bitcoin-címére (%1$s) - nem lesz zapigazolás közzétéve. -</string> + <string name="send_payment_receipt_onchain_address">Fizetés közvetlenül a láncon belüli pénztárcából a profil megadott bitcoin-címére (%1$s) - nem lesz zapigazolás közzétéve.</string> <string name="send_payment_receipt_cashu">A nutzap-esemény magát az ecasht kézbesíti, így az mindig közzé lesz téve.</string> <string name="send_payment_requesting_invoice">Számla kérése…</string> <string name="send_payment_requesting_invoice_nostr">Számla kérése a Nostr hálózaton keresztül…</string> @@ -647,15 +646,8 @@ <string name="mark_all_known_as_read">Az összes ismert megjelölése olvasottként</string> <string name="mark_all_new_as_read">Az összes új üzenet megjelölése olvasottként</string> <string name="mark_all_as_read">Összes megjelölése olvasottként</string> - <string name="account_backup_tips2_md"> ## Kulcs- és biztonsági mentési tippek - \n\nFiókját egy titkos kulcs védi. A kulcs egy hosszú véletlenszerű karakterlánc, amely **nsec1**-vel kezdődik. Bárki, aki az Ön titkos kulcsához hozzáfér, az Ön személyazonosságának használatával bármilyen tartalmat közzétehet. - \n\n- **Ne** helyezze el a titkos kulcsát olyan webhelyen vagy szoftverben, amelyben nem bízik. - \n- Az Amethyst fejlesztői az Ön titkos kulcsát **soha** nem fogják Öntől elkérni. - \n- A fiók-helyreállításának érdekében, a titkos kulcsáról **mindig** készítsen biztonsági másolatot. Javasoljuk egy jelszókezelő használatát. - </string> - <string name="account_backup_tips3_md"> A nagyobb biztonság érdekében titkosítsa egy jelszóval. A jelszóval védett kulcs **ncryptsec1**-vel kezdődik, és a jelszava nélkül nem használható. - \n\nHa elveszíti a jelszavát, nem tudja visszaállítani a kulcsát. - </string> + <string name="account_backup_tips2_md">## Kulcs- és biztonsági mentési tippek \n\nFiókját egy titkos kulcs védi. A kulcs egy hosszú véletlenszerű karakterlánc, amely **nsec1**-vel kezdődik. Bárki, aki az Ön titkos kulcsához hozzáfér, az Ön személyazonosságának használatával bármilyen tartalmat közzétehet. \n\n- **Ne** helyezze el a titkos kulcsát olyan webhelyen vagy szoftverben, amelyben nem bízik. \n- Az Amethyst fejlesztői az Ön titkos kulcsát **soha** nem fogják Öntől elkérni. \n- A fiók-helyreállításának érdekében, a titkos kulcsáról **mindig** készítsen biztonsági másolatot. Javasoljuk egy jelszókezelő használatát.</string> + <string name="account_backup_tips3_md">A nagyobb biztonság érdekében titkosítsa egy jelszóval. A jelszóval védett kulcs **ncryptsec1**-vel kezdődik, és a jelszava nélkül nem használható. \n\nHa elveszíti a jelszavát, nem tudja visszaállítani a kulcsát.</string> <string name="backup_keys_nudge_title">Biztonsági mentés készítése a kulcsokról</string> <string name="backup_keys_nudge_body">A titkos kulcs megadása az egyetlen módja a fiók elérésének. Ha elveszíti, akkor soha nem tudja visszaszerezni. Mentse el biztonságos helyre most.</string> <string name="backup_keys_nudge_backup_now">Biztonsági mentés most</string> @@ -779,7 +771,7 @@ <string name="award_badge_remove_recipient">Eltávolítás</string> <string name="badge_untitled">Cím nélküli kitűző</string> <string name="badge_awardees_label">A kitűzőt %1$d érdemelte ki</string> - <string name="profile_badges_header">Kitűzők · %1$d </string> + <string name="profile_badges_header">Kitűzők · %1$d</string> <string name="profile_badges_description">Válassza ki, hogy a megszerzett kitűzők közül melyek jelenjenek meg a profilban.</string> <string name="profile_badges_empty">Ön még nem kapott kitűzőt.</string> <string name="profile_apps_header">Alkalmazások · %1$d</string> @@ -1835,9 +1827,7 @@ <string name="push_server_none_explainer">Push-értesítések letiltása</string> <string name="push_server_uses_app_explainer">%1$s alkalmazást használ</string> <string name="push_server_install_app">Push-értesítések beállítása</string> - <string name="push_server_install_app_description"> A push-értesítések fogadásához olyan alkalmazást telepítsen, amely támogatja a [Unified Push](https://unifiedpush.org/), például az [Ntfy](https://ntfy.sh/) alkalmazást. - A telepítés után a beállításokban válassza ki a használni kívánt alkalmazást. - </string> + <string name="push_server_install_app_description">A push-értesítések fogadásához olyan alkalmazást telepítsen, amely támogatja a [Unified Push](https://unifiedpush.org/), például az [Ntfy](https://ntfy.sh/) alkalmazást. A telepítés után a beállításokban válassza ki a használni kívánt alkalmazást.</string> <string name="thread_title">Bejegyzés</string> <string name="send_the_seller_a_message">Üzenet küldése az eladónak</string> <string name="hi_there_is_this_still_available">Tisztelt hölgyem / uram! Ez még aktuális?</string> @@ -3124,7 +3114,7 @@ <string name="app_notification_calls_channel_description">Értesítések a bejövő hang- és videóhívásokhoz</string> <string name="app_notification_calls_channel_name">Bejövő hívások</string> <string name="app_notification_chess_challenge_accepted">%1$s elfogadta az Ön sakk-kihívását</string> - <string name="app_notification_chess_channel_description">Értesítés a sakkjátékkal kapcsolatos eseményekre </string> + <string name="app_notification_chess_channel_description">Értesítés a sakkjátékkal kapcsolatos eseményekre</string> <string name="app_notification_chess_channel_name">Sakk</string> <string name="app_notification_chess_summary">Sakkfrissítések</string> <string name="app_notification_chess_your_turn">%1$s lépett - Ön következik</string> @@ -3586,15 +3576,7 @@ <string name="could_not_fetch_invoice_from_details">Nem sikerült lekérni a számlát a következőtől: %1$s: %2$s</string> <string name="could_not_open_the_compressed_file">Nem sikerült megnyitni a tömörített fájlt</string> <string name="couldnt_find_nwc_wallets">Nem található pénztárca</string> - <string name="couldnt_find_nwc_wallets_description">Az Amethyst nem talált olyan pénztárca-alkalmazást, amely támogatja a Nostr Wallet Connect (NWC) szolgáltatást. -\n\nAmit tehet: -\n\t1. NWC-támogatás ellenőrzése: Ellenőrizze, hogy a kívánt pénztárca-alkalmazás kompatibilis-e a Nostr Wallet Connect szolgáltatással. -\n\t2. Kapcsolat létrehozása: Nyissa meg a pénztárcaalkalmazást, és hozzon létre egy új NWC-kapcsolatot. -\n\t3. Kapcsolódás az Amethysthez: A pénztárcája általában három lehetőséget kínál: -\n\t\t3.1 Automatikus kapcsolódás: Egy gomb az Amethyst automatikus megnyitásához és kapcsolódáshoz. Koppintson erre a gombra, nyissa meg az Amethyst alkalmazást, és mentse el az új képernyőn. -\n\t\t3.2 Kézi kapcsolódás: Egy kapcsolódási cím, amelyet kimásolhat, és a képernyőn található beillesztés gombra koppintva közvetlenül beírhatja. -\n\t\t3.3 QR-kód: A pénztárca létrehoz egy QR-kódot, amelyet a képernyőn található QR-kód gombról olvashat be. - </string> + <string name="couldnt_find_nwc_wallets_description">Az Amethyst nem talált olyan pénztárca-alkalmazást, amely támogatja a Nostr Wallet Connect (NWC) szolgáltatást. \n\nAmit tehet: \n\t1. NWC-támogatás ellenőrzése: Ellenőrizze, hogy a kívánt pénztárca-alkalmazás kompatibilis-e a Nostr Wallet Connect szolgáltatással. \n\t2. Kapcsolat létrehozása: Nyissa meg a pénztárcaalkalmazást, és hozzon létre egy új NWC-kapcsolatot. \n\t3. Kapcsolódás az Amethysthez: A pénztárcája általában három lehetőséget kínál: \n\t\t3.1 Automatikus kapcsolódás: Egy gomb az Amethyst automatikus megnyitásához és kapcsolódáshoz. Koppintson erre a gombra, nyissa meg az Amethyst alkalmazást, és mentse el az új képernyőn. \n\t\t3.2 Kézi kapcsolódás: Egy kapcsolódási cím, amelyet kimásolhat, és a képernyőn található beillesztés gombra koppintva közvetlenül beírhatja. \n\t\t3.3 QR-kód: A pénztárca létrehoz egy QR-kódot, amelyet a képernyőn található QR-kód gombról olvashat be.</string> <string name="create">Létrehozás</string> <string name="d">n</string> <string name="danger_zone">Kritikus beállítások</string> @@ -3884,7 +3866,7 @@ <string name="kind_adventure_reading">Kalandos olvasmány</string> <string name="kind_adventure_scene">Kalandjelenet</string> <string name="kind_app_curation_set">Alkalmazáslista</string> - <string name="kind_app_recommendations">Ajánlott alkalmazások </string> + <string name="kind_app_recommendations">Ajánlott alkalmazások</string> <string name="kind_appointment">Találkozó</string> <string name="kind_apps">Alkalmazások</string> <string name="kind_appt_rsvp">Időpont-visszaigazolás</string> @@ -4712,11 +4694,8 @@ <string name="proxy_section">Proxyzott átjátszók</string> <string name="proxy_section_explainer">Összesítő átjátszók, amelyeket az alkalmazásnak használnia kell a hírfolyamok letöltéséhez, például filter.nostr.wine. Ez helyettesíti a kimeneti modellt, és az alkalmazás csak a listákban szereplő átjátszókhoz fog csatlakozni.</string> <string name="public_chat">Nyilvános csevegés</string> - <string name="public_chat_explainer">A nyilvános csevegések mindenki számára láthatóak a Nostr-on, és bárki - részt vehet bennük. Ezek kiválóan alkalmasak bizonyos témák köré szerveződő nyílt közösségek számára. - A moderálás az átjátszókon lévő hozzászólások törlésével szabályozható</string> - <string name="public_chat_relays_explainer">Adjon hozzá 1–3 olyan átjátszót, amelyek kiszolgálják ezt a csoportot. - A Nostr-kliensek ezt a beállítást használják arra, hogy tudják, honnan töltsék le az üzeneteket, és hová küldjék az üzeneteket.</string> + <string name="public_chat_explainer">A nyilvános csevegések mindenki számára láthatóak a Nostr-on, és bárki részt vehet bennük. Ezek kiválóan alkalmasak bizonyos témák köré szerveződő nyílt közösségek számára. A moderálás az átjátszókon lévő hozzászólások törlésével szabályozható</string> + <string name="public_chat_relays_explainer">Adjon hozzá 1–3 olyan átjátszót, amelyek kiszolgálják ezt a csoportot. A Nostr-kliensek ezt a beállítást használják arra, hogy tudják, honnan töltsék le az üzeneteket, és hová küldjék az üzeneteket.</string> <string name="public_chat_relays_title">Átjátszók</string> <string name="public_chat_title">Nyilvános csevegés metaadatai</string> <string name="public_chats">Nyilvános csevegések</string> @@ -4781,10 +4760,7 @@ <string name="relay_auth_search_keywords">hitelesítés, átjátszó, aláírás, ellenőrzés, nip-42, azonosság</string> <string name="relay_auth_settings_title">Átjátszó-hitelesítés</string> <string name="relay_chat">Átjátszócsevegés</string> - <string name="relay_chat_explainer">Az átjátszócsevegések olyan csevegőcsoportok, amelyeket az otthoni átjátszó irányít. - Ezek mindenki számára láthatóak a Nostr-on, és bárki részt vehet rajtuk. - Remekül alkalmasak nyitott közösségek kialakítására bizonyos témák körül. Néhány ilyen csoport - rövid élettartamú, ezért a csevegési üzenetek idővel eltűnnek</string> + <string name="relay_chat_explainer">Az átjátszócsevegések olyan csevegőcsoportok, amelyeket az otthoni átjátszó irányít. Ezek mindenki számára láthatóak a Nostr-on, és bárki részt vehet rajtuk. Remekül alkalmasak nyitott közösségek kialakítására bizonyos témák körül. Néhány ilyen csoport rövid élettartamú, ezért a csevegési üzenetek idővel eltűnnek</string> <string name="relay_chat_title">Átjátszócsevegések</string> <string name="relay_explain_account_data">Saját profil, -beállítások és -piszkozatok a gazda-átjátszókon.</string> <string name="relay_explain_community_chats">Azok az átjátszók, amelyekre az egyes közösségek közzéteszik a terveiket.</string> diff --git a/commonsUI/src/commonMain/composeResources/values-in-rID/strings.xml b/commonsUI/src/commonMain/composeResources/values-in-rID/strings.xml index 7b53a2b3e2..3ce5510f41 100644 --- a/commonsUI/src/commonMain/composeResources/values-in-rID/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-in-rID/strings.xml @@ -266,15 +266,8 @@ <string name="mark_all_known_as_read">Tandai semua yang dikenal sebagai telah dibaca</string> <string name="mark_all_new_as_read">Tandai semua yang baru sebagai telah dibaca</string> <string name="mark_all_as_read">Tandai semua sebagai telah dibaca</string> - <string name="account_backup_tips2_md"> ## Tips Cadangan Kunci dan Keamanan - \n\nAkun Anda diamankan dengan kunci rahasia. Kunci ini adalah rangkaian karakter panjang yang diawali dengan **nsec1**. Siapa pun yang memiliki akses ke kunci rahasia ini dapat memposting dan mengubah identitas Anda. - \n\n- **Jangan** memasukkan kunci rahasia Anda ke situs web atau perangkat lunak yang tidak Anda percaya. - \n- Pengembang Amethyst **tidak akan pernah** meminta kunci rahasia Anda. - \n- **Lakukan** pencadangan kunci rahasia Anda secara aman untuk pemulihan akun. Kami merekomendasikan penggunaan pengelola kata sandi. - </string> - <string name="account_backup_tips3_md"> Untuk keamanan tambahan, Anda dapat mengenkripsi kunci dengan kata sandi. Kunci ini diawali dengan **ncryptsec1** dan tidak dapat digunakan tanpa kata sandi Anda. - \n\nJika Anda kehilangan kata sandi, Anda tidak akan dapat memulihkan kunci Anda. - </string> + <string name="account_backup_tips2_md">## Tips Cadangan Kunci dan Keamanan \n\nAkun Anda diamankan dengan kunci rahasia. Kunci ini adalah rangkaian karakter panjang yang diawali dengan **nsec1**. Siapa pun yang memiliki akses ke kunci rahasia ini dapat memposting dan mengubah identitas Anda. \n\n- **Jangan** memasukkan kunci rahasia Anda ke situs web atau perangkat lunak yang tidak Anda percaya. \n- Pengembang Amethyst **tidak akan pernah** meminta kunci rahasia Anda. \n- **Lakukan** pencadangan kunci rahasia Anda secara aman untuk pemulihan akun. Kami merekomendasikan penggunaan pengelola kata sandi.</string> + <string name="account_backup_tips3_md">Untuk keamanan tambahan, Anda dapat mengenkripsi kunci dengan kata sandi. Kunci ini diawali dengan **ncryptsec1** dan tidak dapat digunakan tanpa kata sandi Anda. \n\nJika Anda kehilangan kata sandi, Anda tidak akan dapat memulihkan kunci Anda.</string> <string name="encrypt_and_copy_my_secret_key">Enkripsi dan salin kunci rahasia saya</string> <string name="badge_award_image_for">Gambar penghargaan lencana untuk %1$s</string> <string name="badge_award_image">"Gambar penghargaan lencana</string> @@ -853,13 +846,11 @@ <string name="meeting_space_closed_tag">DITUTUP</string> <string name="meeting_space_planned_tag">TERJADWAL</string> <string name="meeting_space_planned_starts_at">Mulai %1$s</string> - <string name="are_you_sure_you_want_to_log_out">Keluar akan menghapus semua informasi yang disimpan secara lokal. -Pastikan kunci pribadi Anda telah simpan untuk menghindari kehilangan akun Anda. Apakah Anda ingin melanjutkan?</string> + <string name="are_you_sure_you_want_to_log_out">Keluar akan menghapus semua informasi yang disimpan secara lokal. Pastikan kunci pribadi Anda telah simpan untuk menghindari kehilangan akun Anda. Apakah Anda ingin melanjutkan?</string> <string name="followed_tags">Tags yang Diikuti</string> <string name="discover_follows_explainer">Ini adalah daftar pengguna yang Anda rekomendasikan kepada orang lain. Hanya pengguna publik yang diizinkan.</string> <string name="community_approved_posts">Post yang disetujui</string> - <string name="groups_no_descriptor">Grup ini tidak memiliki deskripsi atau peraturan. -Beri tahu kepada pemilik untuk menambahnya</string> + <string name="groups_no_descriptor">Grup ini tidak memiliki deskripsi atau peraturan. Beri tahu kepada pemilik untuk menambahnya</string> <string name="community_no_descriptor">Komunitas ini tidak memiliki deskripsi. Beri tahu kepada pemilik untuk menambahnya</string> <string name="user_preferences">Preferensi Pengguna</string> <string name="settings_search_placeholder">Cari pengaturan</string> @@ -951,9 +942,7 @@ Beri tahu kepada pemilik untuk menambahnya</string> <string name="push_server_none_explainer">Matikan Pemberitahuan Push</string> <string name="push_server_uses_app_explainer">Memakai aplikasi %1$s</string> <string name="push_server_install_app">Pengaturan Pemberitahuan Push</string> - <string name="push_server_install_app_description"> Untuk menerima Pemberitahuan Push, Install aplikasi yang mendukung [Unified Push](https://unifiedpush.org/), seperti [Ntfy](https://ntfy.sh/). - Setelah diinstall, Pilih aplikasi yang ingin digunakan di pengaturan. - </string> + <string name="push_server_install_app_description">Untuk menerima Pemberitahuan Push, Install aplikasi yang mendukung [Unified Push](https://unifiedpush.org/), seperti [Ntfy](https://ntfy.sh/). Setelah diinstall, Pilih aplikasi yang ingin digunakan di pengaturan.</string> <string name="thread_title">Utas</string> <string name="send_the_seller_a_message">Kirim pesan ke penjual</string> <string name="hi_there_is_this_still_available">Halo, apakah ini masih tersedia?</string> @@ -1991,15 +1980,7 @@ Beri tahu kepada pemilik untuk menambahnya</string> <string name="could_not_fetch_invoice_from_details">Tidak dapat mengambil invoice dari %1$s: %2$s</string> <string name="could_not_open_the_compressed_file">Tidak dapat membuka file terkompresi</string> <string name="couldnt_find_nwc_wallets">Dompet tidak ditemukan</string> - <string name="couldnt_find_nwc_wallets_description">Amethyst tidak dapat menemukan aplikasi dompet apa pun yang mendukung Nostr Wallet Connect (NWC). -\n\nYang dapat Anda lakukan: -\n\t1. Verifikasi Dukungan NWC: Pastikan aplikasi dompet pilihan Anda kompatibel dengan Nostr Wallet Connect. -\n\t2. Buat Koneksi: Buka aplikasi dompet Anda dan buat koneksi NWC baru. -\n\t3. Hubungkan ke Amethyst: Dompet Anda biasanya akan memberikan tiga pilihan: -\n\t\t3.1 Tautan Otomatis: Tombol untuk secara otomatis membuka dan terhubung ke Amethyst. Jika ada, klik tombol tersebut, buka Amethyst dan tekan simpan di layar baru. -\n\t\t3.2 Tautan Manual: URI Koneksi yang dapat Anda salin dan klik ikon tempel di layar ini untuk memasukkannya secara langsung. -\n\t\t3.3 Kode QR: Dompet membuat kode QR yang dapat Anda baca dari tombol kode QR di layar ini. - </string> + <string name="couldnt_find_nwc_wallets_description">Amethyst tidak dapat menemukan aplikasi dompet apa pun yang mendukung Nostr Wallet Connect (NWC). \n\nYang dapat Anda lakukan: \n\t1. Verifikasi Dukungan NWC: Pastikan aplikasi dompet pilihan Anda kompatibel dengan Nostr Wallet Connect. \n\t2. Buat Koneksi: Buka aplikasi dompet Anda dan buat koneksi NWC baru. \n\t3. Hubungkan ke Amethyst: Dompet Anda biasanya akan memberikan tiga pilihan: \n\t\t3.1 Tautan Otomatis: Tombol untuk secara otomatis membuka dan terhubung ke Amethyst. Jika ada, klik tombol tersebut, buka Amethyst dan tekan simpan di layar baru. \n\t\t3.2 Tautan Manual: URI Koneksi yang dapat Anda salin dan klik ikon tempel di layar ini untuk memasukkannya secara langsung. \n\t\t3.3 Kode QR: Dompet membuat kode QR yang dapat Anda baca dari tombol kode QR di layar ini.</string> <string name="create">Buat</string> <string name="d">h</string> <string name="danger_zone">Zona Berbahaya</string> @@ -2183,8 +2164,7 @@ Beri tahu kepada pemilik untuk menambahnya</string> <string name="image_saved_to_the_gallery">Gambar disimpan ke galeri</string> <string name="impersonation">Identitas palsu</string> <string name="inbox_relays_title">Relay Kotak Masuk</string> - <string name="incorrect_invoice_amount_sats_from_it_should_have_been">Jumlah faktur salah (%1$s sat) dari %2$s. -Seharusnya %3$s</string> + <string name="incorrect_invoice_amount_sats_from_it_should_have_been">Jumlah faktur salah (%1$s sat) dari %2$s. Seharusnya %3$s</string> <string name="indexer_section">Relay Indeks</string> <string name="indexer_section_explainer">Relay yang berspesialisasi dalam menyimpan metadata dan daftar relay semua orang, seperti purplepag.es. Amethyst akan menggunakan relay ini untuk menemukan pengguna yang tidak ada dalam daftar Anda.</string> <string name="interest_set_creation_screen_title">Kumpulan Minat Baru</string> @@ -2618,11 +2598,8 @@ Seharusnya %3$s</string> <string name="proxy_section">Relay Proxy</string> <string name="proxy_section_explainer">Relay agregator yang harus digunakan aplikasi untuk mengunduh feed Anda, seperti filter.nostr.wine. Ini menggantikan model outbox dan akan membuat aplikasi hanya terhubung ke relay dalam daftar Anda.</string> <string name="public_chat">Obrolan Publik</string> - <string name="public_chat_explainer">Obrolan publik terlihat oleh semua orang di Nostr dan siapa pun - dapat berpartisipasi. Sangat cocok untuk komunitas terbuka seputar topik tertentu. - Moderasi dapat dilakukan dengan menghapus postingan di relay-nya</string> - <string name="public_chat_relays_explainer">Masukkan antara 1–3 relay yang menjadi host grup ini. - Klien Nostr menggunakan pengaturan ini untuk mengetahui tempat mengunduh dan mengirim pesan Anda.</string> + <string name="public_chat_explainer">Obrolan publik terlihat oleh semua orang di Nostr dan siapa pun dapat berpartisipasi. Sangat cocok untuk komunitas terbuka seputar topik tertentu. Moderasi dapat dilakukan dengan menghapus postingan di relay-nya</string> + <string name="public_chat_relays_explainer">Masukkan antara 1–3 relay yang menjadi host grup ini. Klien Nostr menggunakan pengaturan ini untuk mengetahui tempat mengunduh dan mengirim pesan Anda.</string> <string name="public_chat_relays_title">relay</string> <string name="public_chat_title">Metadata Obrolan Publik</string> <string name="public_chats">Obrolan Publik</string> @@ -2647,10 +2624,7 @@ Seharusnya %3$s</string> <string name="read_only_user">Pengguna hanya-baca</string> <string name="record_a_message_description">Tekan dan tahan untuk merekam pesan</string> <string name="relay_chat">Obrolan relay</string> - <string name="relay_chat_explainer">Obrolan relay adalah grup obrolan yang dikendalikan oleh relay induknya. - Obrolan ini terlihat oleh semua orang di Nostr dan siapa pun dapat berpartisipasi. - Sangat cocok untuk komunitas terbuka seputar topik tertentu. Beberapa grup ini bersifat sementara - sehingga pesan obrolan akan menghilang seiring waktu</string> + <string name="relay_chat_explainer">Obrolan relay adalah grup obrolan yang dikendalikan oleh relay induknya. Obrolan ini terlihat oleh semua orang di Nostr dan siapa pun dapat berpartisipasi. Sangat cocok untuk komunitas terbuka seputar topik tertentu. Beberapa grup ini bersifat sementara sehingga pesan obrolan akan menghilang seiring waktu</string> <string name="relay_chat_title">Obrolan relay</string> <string name="relay_management_tab_events">Acara</string> <string name="relay_management_tab_ips">IP</string> diff --git a/commonsUI/src/commonMain/composeResources/values-in/strings.xml b/commonsUI/src/commonMain/composeResources/values-in/strings.xml index 7635510369..2fe9f6457e 100644 --- a/commonsUI/src/commonMain/composeResources/values-in/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-in/strings.xml @@ -172,7 +172,7 @@ <string name="username">Nama pengguna</string> <string name="about_me">Tentang saya</string> <string name="avatar_url">URL Avatar</string> - <string name="banner_url">URL Spanduk/Banner </string> + <string name="banner_url">URL Spanduk/Banner</string> <string name="website_url">URL Situs Web</string> <string name="pronouns">Kata ganti</string> <string name="stale_relay_hint_label">relay sumber mungkin sudah usang</string> @@ -257,17 +257,8 @@ <string name="mark_all_known_as_read">Tandai semua yg dikenal telah dibaca</string> <string name="mark_all_new_as_read">Tandai pesan baru telah dibaca</string> <string name="mark_all_as_read">Tandai semua telah dibaca</string> - <string name="account_backup_tips2_md"> - ## Tips Cadangan Kunci dan Keamanan - \n\nAkun Anda diamankan dengan kunci rahasia. Kunci ini adalah rangkaian karakter panjang yang diawali dengan **nsec1**. Siapa pun yang memiliki akses ke kunci rahasia ini dapat memposting dan mengubah identitas Anda. - \n\n- **Jangan** memasukkan kunci rahasia Anda ke situs web atau perangkat lunak yang tidak Anda percaya. - \n- Pengembang Amethyst **tidak akan pernah** meminta kunci rahasia Anda. - \n- **Lakukan** pencadangan kunci rahasia Anda secara aman untuk pemulihan akun. Kami merekomendasikan penggunaan pengelola kata sandi. - </string> - <string name="account_backup_tips3_md"> - Untuk keamanan tambahan, Anda dapat mengenkripsi kunci dengan kata sandi. Kunci ini diawali dengan **ncryptsec1** dan tidak dapat digunakan tanpa kata sandi Anda. - \n\nJika Anda kehilangan kata sandi, Anda tidak akan dapat memulihkan kunci Anda. - </string> + <string name="account_backup_tips2_md">## Tips Cadangan Kunci dan Keamanan \n\nAkun Anda diamankan dengan kunci rahasia. Kunci ini adalah rangkaian karakter panjang yang diawali dengan **nsec1**. Siapa pun yang memiliki akses ke kunci rahasia ini dapat memposting dan mengubah identitas Anda. \n\n- **Jangan** memasukkan kunci rahasia Anda ke situs web atau perangkat lunak yang tidak Anda percaya. \n- Pengembang Amethyst **tidak akan pernah** meminta kunci rahasia Anda. \n- **Lakukan** pencadangan kunci rahasia Anda secara aman untuk pemulihan akun. Kami merekomendasikan penggunaan pengelola kata sandi.</string> + <string name="account_backup_tips3_md">Untuk keamanan tambahan, Anda dapat mengenkripsi kunci dengan kata sandi. Kunci ini diawali dengan **ncryptsec1** dan tidak dapat digunakan tanpa kata sandi Anda. \n\nJika Anda kehilangan kata sandi, Anda tidak akan dapat memulihkan kunci Anda.</string> <string name="encrypt_and_copy_my_secret_key">Enkripsi dan salin kunci rahasia saya</string> <string name="badge_award_image_for">Gambar penghargaan lencana untuk %1$s</string> <string name="badge_award_image">"Gambar penghargaan lencana</string> @@ -960,10 +951,7 @@ <string name="push_server_none_explainer">Menonaktifkan Notifikasi Push</string> <string name="push_server_uses_app_explainer">Menggunakan aplikasi %1$s</string> <string name="push_server_install_app">Pengaturan Notifikasi Push</string> - <string name="push_server_install_app_description"> - Untuk menerima notifikasi push, instal aplikasi yang mendukung [Unified Push](https://unifiedpush.org/), seperti [Ntfy](https://ntfy.sh/). - Setelah menginstal, pilih aplikasi yang ingin Anda gunakan di Pengaturan. - </string> + <string name="push_server_install_app_description">Untuk menerima notifikasi push, instal aplikasi yang mendukung [Unified Push](https://unifiedpush.org/), seperti [Ntfy](https://ntfy.sh/). Setelah menginstal, pilih aplikasi yang ingin Anda gunakan di Pengaturan.</string> <string name="thread_title">Utas</string> <string name="send_the_seller_a_message">Kirim pesan ke penjual</string> <string name="hi_there_is_this_still_available">Halo, apakah ini masih tersedia?</string> @@ -2048,15 +2036,7 @@ <string name="could_not_fetch_invoice_from_details">Tidak dapat mengambil invoice dari %1$s: %2$s</string> <string name="could_not_open_the_compressed_file">Tidak dapat membuka file terkompresi</string> <string name="couldnt_find_nwc_wallets">Dompet tidak ditemukan</string> - <string name="couldnt_find_nwc_wallets_description">Amethyst tidak dapat menemukan aplikasi dompet apa pun yang mendukung Nostr Wallet Connect (NWC). -\n\nYang dapat Anda lakukan: -\n\t1. Verifikasi Dukungan NWC: Pastikan aplikasi dompet pilihan Anda kompatibel dengan Nostr Wallet Connect. -\n\t2. Buat Koneksi: Buka aplikasi dompet Anda dan buat koneksi NWC baru. -\n\t3. Hubungkan ke Amethyst: Dompet Anda biasanya akan memberikan tiga pilihan: -\n\t\t3.1 Tautan Otomatis: Tombol untuk secara otomatis membuka dan terhubung ke Amethyst. Jika ada, klik tombol tersebut, buka Amethyst dan tekan simpan di layar baru. -\n\t\t3.2 Tautan Manual: URI Koneksi yang dapat Anda salin dan klik ikon tempel di layar ini untuk memasukkannya secara langsung. -\n\t\t3.3 Kode QR: Dompet membuat kode QR yang dapat Anda baca dari tombol kode QR di layar ini. - </string> + <string name="couldnt_find_nwc_wallets_description">Amethyst tidak dapat menemukan aplikasi dompet apa pun yang mendukung Nostr Wallet Connect (NWC). \n\nYang dapat Anda lakukan: \n\t1. Verifikasi Dukungan NWC: Pastikan aplikasi dompet pilihan Anda kompatibel dengan Nostr Wallet Connect. \n\t2. Buat Koneksi: Buka aplikasi dompet Anda dan buat koneksi NWC baru. \n\t3. Hubungkan ke Amethyst: Dompet Anda biasanya akan memberikan tiga pilihan: \n\t\t3.1 Tautan Otomatis: Tombol untuk secara otomatis membuka dan terhubung ke Amethyst. Jika ada, klik tombol tersebut, buka Amethyst dan tekan simpan di layar baru. \n\t\t3.2 Tautan Manual: URI Koneksi yang dapat Anda salin dan klik ikon tempel di layar ini untuk memasukkannya secara langsung. \n\t\t3.3 Kode QR: Dompet membuat kode QR yang dapat Anda baca dari tombol kode QR di layar ini.</string> <string name="create">Buat</string> <string name="d">hari</string> <string name="danger_zone">Zona Berbahaya</string> @@ -2723,11 +2703,8 @@ <string name="proxy_section">Relay Proxy</string> <string name="proxy_section_explainer">Relay agregator yang harus digunakan aplikasi untuk mengunduh feed Anda, seperti filter.nostr.wine. Ini menggantikan model outbox dan akan membuat aplikasi hanya terhubung ke relay dalam daftar Anda.</string> <string name="public_chat">Percakapan Publik</string> - <string name="public_chat_explainer">Obrolan publik terlihat oleh semua orang di Nostr dan siapa pun - dapat berpartisipasi. Sangat cocok untuk komunitas terbuka seputar topik tertentu. - Moderasi dapat dilakukan dengan menghapus postingan di relay-nya</string> - <string name="public_chat_relays_explainer">Masukkan antara 1–3 relay yang menjadi host grup ini. - Klien Nostr menggunakan pengaturan ini untuk mengetahui tempat mengunduh dan mengirim pesan Anda.</string> + <string name="public_chat_explainer">Obrolan publik terlihat oleh semua orang di Nostr dan siapa pun dapat berpartisipasi. Sangat cocok untuk komunitas terbuka seputar topik tertentu. Moderasi dapat dilakukan dengan menghapus postingan di relay-nya</string> + <string name="public_chat_relays_explainer">Masukkan antara 1–3 relay yang menjadi host grup ini. Klien Nostr menggunakan pengaturan ini untuk mengetahui tempat mengunduh dan mengirim pesan Anda.</string> <string name="public_chat_relays_title">relay</string> <string name="public_chat_title">Metadata Obrolan Publik</string> <string name="public_chats">Obrolan Publik</string> @@ -2752,10 +2729,7 @@ <string name="read_only_user">Pengguna hanya-baca</string> <string name="record_a_message_description">Tekan dan tahan untuk merekam pesan</string> <string name="relay_chat">Obrolan relay</string> - <string name="relay_chat_explainer">Obrolan relay adalah grup obrolan yang dikendalikan oleh relay induknya. - Obrolan ini terlihat oleh semua orang di Nostr dan siapa pun dapat berpartisipasi. - Sangat cocok untuk komunitas terbuka seputar topik tertentu. Beberapa grup ini bersifat sementara - sehingga pesan obrolan akan menghilang seiring waktu</string> + <string name="relay_chat_explainer">Obrolan relay adalah grup obrolan yang dikendalikan oleh relay induknya. Obrolan ini terlihat oleh semua orang di Nostr dan siapa pun dapat berpartisipasi. Sangat cocok untuk komunitas terbuka seputar topik tertentu. Beberapa grup ini bersifat sementara sehingga pesan obrolan akan menghilang seiring waktu</string> <string name="relay_chat_title">Obrolan relay</string> <string name="relay_management_tab_events">Acara</string> <string name="relay_management_tab_ips">IP</string> diff --git a/commonsUI/src/commonMain/composeResources/values-it-rIT/strings.xml b/commonsUI/src/commonMain/composeResources/values-it-rIT/strings.xml index aff7bdaf21..54245dec39 100644 --- a/commonsUI/src/commonMain/composeResources/values-it-rIT/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-it-rIT/strings.xml @@ -266,15 +266,8 @@ <string name="mark_all_known_as_read">Segna tutti i conosciuti come letti</string> <string name="mark_all_new_as_read">Segna tutti i nuovi come letti</string> <string name="mark_all_as_read">Segna tutti come letti</string> - <string name="account_backup_tips2_md"> ## Suggerimenti per il backup e la sicurezza della chiave - \n\nIl tuo account è protetto da una chiave segreta. La chiave è una lunga sequenza di caratteri che inizia con **nsec1**. Chiunque abbia accesso a questa chiave segreta può pubblicare e modificare la tua identità. - \n\n- **Non** inserire la tua chiave segreta in nessun sito web o software di cui non ti fidi. - \n- Gli sviluppatori di Amethyst non chiederanno **mai** la tua chiave segreta. - \n- **Conserva** un backup sicuro della tua chiave segreta per il recupero dell'account. Si consiglia l'uso di un gestore di password. - </string> - <string name="account_backup_tips3_md"> Per maggiore sicurezza, puoi cifrare la tua chiave con una password. Questa chiave inizia con **ncryptsec1** e non può essere utilizzata senza la tua password. - \n\nSe perdi la password, non potrai recuperare la tua chiave. - </string> + <string name="account_backup_tips2_md">## Suggerimenti per il backup e la sicurezza della chiave \n\nIl tuo account è protetto da una chiave segreta. La chiave è una lunga sequenza di caratteri che inizia con **nsec1**. Chiunque abbia accesso a questa chiave segreta può pubblicare e modificare la tua identità. \n\n- **Non** inserire la tua chiave segreta in nessun sito web o software di cui non ti fidi. \n- Gli sviluppatori di Amethyst non chiederanno **mai** la tua chiave segreta. \n- **Conserva** un backup sicuro della tua chiave segreta per il recupero dell'account. Si consiglia l'uso di un gestore di password.</string> + <string name="account_backup_tips3_md">Per maggiore sicurezza, puoi cifrare la tua chiave con una password. Questa chiave inizia con **ncryptsec1** e non può essere utilizzata senza la tua password. \n\nSe perdi la password, non potrai recuperare la tua chiave.</string> <string name="encrypt_and_copy_my_secret_key">Cifra e copia la mia chiave segreta</string> <string name="badge_award_image_for">Immagine premio badge per %1$s</string> <string name="badge_award_image">"Immagine del premio badge</string> @@ -947,9 +940,7 @@ <string name="push_server_none_explainer">Disabilita le notifiche push</string> <string name="push_server_uses_app_explainer">Usa l'app %1$s</string> <string name="push_server_install_app">Configurazione notifiche push</string> - <string name="push_server_install_app_description"> Per ricevere notifiche push, installa qualsiasi app che supporta [Unified Push](https://unifiedpush.org/), come [Ntfy](https://ntfy.sh/). - Dopo l'installazione, seleziona l'app che desideri usare nelle Impostazioni. - </string> + <string name="push_server_install_app_description">Per ricevere notifiche push, installa qualsiasi app che supporta [Unified Push](https://unifiedpush.org/), come [Ntfy](https://ntfy.sh/). Dopo l'installazione, seleziona l'app che desideri usare nelle Impostazioni.</string> <string name="send_the_seller_a_message">Invia un messaggio al venditore</string> <string name="hi_there_is_this_still_available">Ciao, è ancora disponibile?</string> <string name="classifieds_title">Titolo</string> @@ -1986,15 +1977,7 @@ <string name="could_not_fetch_invoice_from_details">Impossibile recuperare la fattura da %1$s: %2$s</string> <string name="could_not_open_the_compressed_file">Impossibile aprire il file compresso</string> <string name="couldnt_find_nwc_wallets">Wallet non trovato</string> - <string name="couldnt_find_nwc_wallets_description">Amethyst non ha trovato app wallet compatibili con Nostr Wallet Connect (NWC). -\n\nCosa puoi fare: -\n\t1. Verifica il supporto NWC: controlla che la tua app wallet preferita sia compatibile con Nostr Wallet Connect. -\n\t2. Crea connessione: vai nella tua app wallet e crea una nuova connessione NWC. -\n\t3. Collega ad Amethyst: la tua app wallet ti offrirà solitamente tre opzioni: -\n\t\t3.1 Collegamento automatico: un pulsante per aprire e connettersi automaticamente ad Amethyst. In tal caso, clicca quel pulsante, apri Amethyst e premi salva nella nuova schermata. -\n\t\t3.2 Collegamento manuale: un URI di connessione che puoi copiare e incollare tramite l'icona di incolla in questa schermata. -\n\t\t3.3 Codice QR: il wallet crea un codice QR che puoi leggere tramite il pulsante QR in questa schermata. - </string> + <string name="couldnt_find_nwc_wallets_description">Amethyst non ha trovato app wallet compatibili con Nostr Wallet Connect (NWC). \n\nCosa puoi fare: \n\t1. Verifica il supporto NWC: controlla che la tua app wallet preferita sia compatibile con Nostr Wallet Connect. \n\t2. Crea connessione: vai nella tua app wallet e crea una nuova connessione NWC. \n\t3. Collega ad Amethyst: la tua app wallet ti offrirà solitamente tre opzioni: \n\t\t3.1 Collegamento automatico: un pulsante per aprire e connettersi automaticamente ad Amethyst. In tal caso, clicca quel pulsante, apri Amethyst e premi salva nella nuova schermata. \n\t\t3.2 Collegamento manuale: un URI di connessione che puoi copiare e incollare tramite l'icona di incolla in questa schermata. \n\t\t3.3 Codice QR: il wallet crea un codice QR che puoi leggere tramite il pulsante QR in questa schermata.</string> <string name="create">Crea</string> <string name="d">g</string> <string name="danger_zone">Zona pericolosa</string> @@ -2645,11 +2628,8 @@ <string name="proxy_section">Relay proxy</string> <string name="proxy_section_explainer">Relay aggregatori che l'app deve usare per scaricare i tuoi feed, come filter.nostr.wine. Questo sostituisce il modello outbox e farà connettere l'app solo ai relay nelle tue liste.</string> <string name="public_chat">Chat Pubblica</string> - <string name="public_chat_explainer">Le chat pubbliche sono visibili a tutti su Nostr e chiunque - può parteciparvi. Sono ideali per comunità aperte su argomenti specifici. - La moderazione può essere controllata eliminando i post sui relay associati</string> - <string name="public_chat_relays_explainer">Inserisci tra 1 e 3 relay che ospitano questo gruppo. - I client Nostr usano questa impostazione per sapere da dove scaricare i messaggi e dove inviare i tuoi messaggi.</string> + <string name="public_chat_explainer">Le chat pubbliche sono visibili a tutti su Nostr e chiunque può parteciparvi. Sono ideali per comunità aperte su argomenti specifici. La moderazione può essere controllata eliminando i post sui relay associati</string> + <string name="public_chat_relays_explainer">Inserisci tra 1 e 3 relay che ospitano questo gruppo. I client Nostr usano questa impostazione per sapere da dove scaricare i messaggi e dove inviare i tuoi messaggi.</string> <string name="public_chat_relays_title">Relay</string> <string name="public_chat_title">Metadati chat pubblica</string> <string name="public_chats">Chat pubbliche</string> @@ -2674,10 +2654,7 @@ <string name="read_only_user">Utente in sola lettura</string> <string name="record_a_message_description">Tieni premuto per registrare un messaggio</string> <string name="relay_chat">Chat relay</string> - <string name="relay_chat_explainer">Le chat relay sono gruppi di chat controllati dal loro relay di riferimento. - Sono visibili a tutti su Nostr e chiunque può parteciparvi. - Sono ideali per comunità aperte su argomenti specifici. Alcuni di questi gruppi sono effimeri - e quindi i messaggi della chat scompaiono nel tempo</string> + <string name="relay_chat_explainer">Le chat relay sono gruppi di chat controllati dal loro relay di riferimento. Sono visibili a tutti su Nostr e chiunque può parteciparvi. Sono ideali per comunità aperte su argomenti specifici. Alcuni di questi gruppi sono effimeri e quindi i messaggi della chat scompaiono nel tempo</string> <string name="relay_chat_title">Chat relay</string> <string name="relay_management_tab_events">Eventi</string> <string name="relay_management_tab_ips">IP</string> diff --git a/commonsUI/src/commonMain/composeResources/values-ja-rJP/strings.xml b/commonsUI/src/commonMain/composeResources/values-ja-rJP/strings.xml index 04d9abd43e..089c9f9857 100644 --- a/commonsUI/src/commonMain/composeResources/values-ja-rJP/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-ja-rJP/strings.xml @@ -69,7 +69,7 @@ <string name="scan_qr">QRコードをスキャン</string> <string name="show_anyway">それでも表示する</string> <string name="post_was_hidden">このポストは、非表示にしているユーザーまたはワードが含まれているため隠されています</string> - <string name="post_was_flagged_as_inappropriate_by">このユーザによって投稿が不適切なものとしてマークされています: </string> + <string name="post_was_flagged_as_inappropriate_by">このユーザによって投稿が不適切なものとしてマークされています:</string> <string name="post_not_found">投稿が見つかりません</string> <string name="channel_image">チャンネル画像</string> <string name="referenced_event_not_found">参照先のイベントが見つかりません</string> @@ -271,15 +271,8 @@ <string name="mark_all_known_as_read">すべての「知り合い」を既読にする</string> <string name="mark_all_new_as_read">すべての「新規リクエスト」を既読にする</string> <string name="mark_all_as_read">すべて既読にする</string> - <string name="account_backup_tips2_md"> ## 鍵のバックアップとセキュリティのヒント - \n\nアカウントは秘密鍵によって保護されています。この鍵は **nsec1** で始まる長い文字列です。この秘密鍵にアクセスできる人は誰でも、あなたの名前でポストしたり、プロフィールを変更したりできます。 - \n\n- 信頼していないウェブサイトやアプリに秘密鍵を **入力しないでください**。 - \n- Amethyst の開発者が秘密鍵を **求めることは絶対にありません**。 - \n- アカウントの復元のために、秘密鍵の安全なバックアップを **必ず** 保管してください。パスワードマネージャーの使用をお勧めします。 - </string> - <string name="account_backup_tips3_md"> さらにセキュリティを高めるために、鍵をパスワードで暗号化することができます。この鍵は **ncryptsec1** で始まり、パスワードなしでは使用できません。 - \n\nパスワードを忘れると、鍵を復元できなくなります。 - </string> + <string name="account_backup_tips2_md">## 鍵のバックアップとセキュリティのヒント \n\nアカウントは秘密鍵によって保護されています。この鍵は **nsec1** で始まる長い文字列です。この秘密鍵にアクセスできる人は誰でも、あなたの名前でポストしたり、プロフィールを変更したりできます。 \n\n- 信頼していないウェブサイトやアプリに秘密鍵を **入力しないでください**。 \n- Amethyst の開発者が秘密鍵を **求めることは絶対にありません**。 \n- アカウントの復元のために、秘密鍵の安全なバックアップを **必ず** 保管してください。パスワードマネージャーの使用をお勧めします。</string> + <string name="account_backup_tips3_md">さらにセキュリティを高めるために、鍵をパスワードで暗号化することができます。この鍵は **ncryptsec1** で始まり、パスワードなしでは使用できません。 \n\nパスワードを忘れると、鍵を復元できなくなります。</string> <string name="encrypt_and_copy_my_secret_key">秘密鍵を暗号化してコピー</string> <string name="badge_award_image_for">%1$sのバッジ授与画像</string> <string name="badge_award_image">バッジ授与の画像</string> @@ -965,9 +958,7 @@ <string name="push_server_none_explainer">プッシュ通知を無効にします</string> <string name="push_server_uses_app_explainer">アプリ%1$sを使用</string> <string name="push_server_install_app">プッシュ通知の設定</string> - <string name="push_server_install_app_description"> プッシュ通知を受け取るには、[Ntfy](https://ntfy.sh/)など[Unified Push](https://unifiedpush.org/)に対応したアプリをインストールしてください。 - インストール後、設定で使用するアプリを選択してください。 - </string> + <string name="push_server_install_app_description">プッシュ通知を受け取るには、[Ntfy](https://ntfy.sh/)など[Unified Push](https://unifiedpush.org/)に対応したアプリをインストールしてください。 インストール後、設定で使用するアプリを選択してください。</string> <string name="thread_title">スレッド</string> <string name="send_the_seller_a_message">出品者にメッセージを送る</string> <string name="hi_there_is_this_still_available">こんにちは、こちらはまだ入手可能ですか?</string> @@ -2016,15 +2007,7 @@ <string name="could_not_fetch_invoice_from_details">%1$s から請求書を取得できませんでした: %2$s</string> <string name="could_not_open_the_compressed_file">圧縮ファイルを開けませんでした</string> <string name="couldnt_find_nwc_wallets">ウォレットが見つかりません</string> - <string name="couldnt_find_nwc_wallets_description">Amethyst は Nostr Wallet Connect(NWC)に対応したウォレットアプリを見つけられませんでした。 -\n\n対処方法: -\n\t1. NWC サポートを確認: お使いのウォレットアプリが Nostr Wallet Connect に対応しているか再確認してください。 -\n\t2. 接続を作成: ウォレットアプリで新しい NWC 接続を作成してください。 -\n\t3. Amethyst にリンク: ウォレットは通常、以下の3つの方法を提供します: -\n\t\t3.1 自動リンク: 自動的に Amethyst を開いて接続するボタン。その場合、ボタンをクリックして Amethyst を開き、新しい画面で保存を押してください。 -\n\t\t3.2 手動リンク: この画面の貼り付けアイコンをクリックして直接入力できる接続 URI。 -\n\t\t3.3 QR コード: ウォレットが作成する QR コード。この画面の QR コードボタンから読み取れます。 - </string> + <string name="couldnt_find_nwc_wallets_description">Amethyst は Nostr Wallet Connect(NWC)に対応したウォレットアプリを見つけられませんでした。 \n\n対処方法: \n\t1. NWC サポートを確認: お使いのウォレットアプリが Nostr Wallet Connect に対応しているか再確認してください。 \n\t2. 接続を作成: ウォレットアプリで新しい NWC 接続を作成してください。 \n\t3. Amethyst にリンク: ウォレットは通常、以下の3つの方法を提供します: \n\t\t3.1 自動リンク: 自動的に Amethyst を開いて接続するボタン。その場合、ボタンをクリックして Amethyst を開き、新しい画面で保存を押してください。 \n\t\t3.2 手動リンク: この画面の貼り付けアイコンをクリックして直接入力できる接続 URI。 \n\t\t3.3 QR コード: ウォレットが作成する QR コード。この画面の QR コードボタンから読み取れます。</string> <string name="create">作成</string> <string name="d">日</string> <string name="danger_zone">危険ゾーン</string> @@ -2667,11 +2650,8 @@ <string name="proxy_section">Proxy Relay</string> <string name="proxy_section_explainer">filter.nostr.wine のように、アプリがフィードをダウンロードするために使用するアグリゲーター relay です。これにより outbox モデルが置き換えられ、アプリはリスト内の relay のみに接続するようになります。</string> <string name="public_chat">公開チャット</string> - <string name="public_chat_explainer">パブリックチャットは Nostr 上の全員に公開されており、誰でも - 参加できます。特定のトピックを中心としたオープンなコミュニティに最適です。 - モデレーションは、その relay 上のポストを削除することで管理できます</string> - <string name="public_chat_relays_explainer">このグループをホストする relay を 1〜3 件入力してください。 - Nostr クライアントはこの設定を使用して、メッセージのダウンロード先と送信先を決定します。</string> + <string name="public_chat_explainer">パブリックチャットは Nostr 上の全員に公開されており、誰でも 参加できます。特定のトピックを中心としたオープンなコミュニティに最適です。 モデレーションは、その relay 上のポストを削除することで管理できます</string> + <string name="public_chat_relays_explainer">このグループをホストする relay を 1〜3 件入力してください。 Nostr クライアントはこの設定を使用して、メッセージのダウンロード先と送信先を決定します。</string> <string name="public_chat_relays_title">Relay</string> <string name="public_chat_title">パブリックチャットのメタデータ</string> <string name="public_chats">パブリックチャット</string> @@ -2696,10 +2676,7 @@ <string name="read_only_user">読み取り専用ユーザー</string> <string name="record_a_message_description">長押ししてメッセージを録音</string> <string name="relay_chat">relay チャット</string> - <string name="relay_chat_explainer">relay チャットは、ホスト relay によって管理されるチャットグループです。 - Nostr 上の全員に公開されており、誰でも参加できます。 - 特定のトピックを中心としたオープンなコミュニティに最適です。一部のグループは一時的なもので、 - チャットメッセージは時間とともに消えます</string> + <string name="relay_chat_explainer">relay チャットは、ホスト relay によって管理されるチャットグループです。 Nostr 上の全員に公開されており、誰でも参加できます。 特定のトピックを中心としたオープンなコミュニティに最適です。一部のグループは一時的なもので、 チャットメッセージは時間とともに消えます</string> <string name="relay_chat_title">relay チャット</string> <string name="relay_management_tab_events">イベント</string> <string name="relay_management_tab_ips">IP</string> diff --git a/commonsUI/src/commonMain/composeResources/values-ja/strings.xml b/commonsUI/src/commonMain/composeResources/values-ja/strings.xml index a9b6f5afcf..8e2365c27e 100644 --- a/commonsUI/src/commonMain/composeResources/values-ja/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-ja/strings.xml @@ -49,7 +49,7 @@ <string name="scan_qr">QRコードをスキャン</string> <string name="show_anyway">それでも表示する</string> <string name="post_was_hidden">このポストは、非表示にしているユーザーまたはワードが含まれているため隠されています</string> - <string name="post_was_flagged_as_inappropriate_by">このユーザによって投稿が不適切なものとしてマークされています: </string> + <string name="post_was_flagged_as_inappropriate_by">このユーザによって投稿が不適切なものとしてマークされています:</string> <string name="post_not_found">投稿が見つかりません</string> <string name="post_not_found_short">👀</string> <string name="channel_image">チャンネル画像</string> @@ -257,17 +257,8 @@ <string name="mark_all_known_as_read">すべての「知り合い」を既読にする</string> <string name="mark_all_new_as_read">すべての「新規リクエスト」を既読にする</string> <string name="mark_all_as_read">すべて既読にする</string> - <string name="account_backup_tips2_md"> - ## 鍵のバックアップとセキュリティのヒント - \n\nアカウントは秘密鍵によって保護されています。この鍵は **nsec1** で始まる長い文字列です。この秘密鍵にアクセスできる人は誰でも、あなたの名前でポストしたり、プロフィールを変更したりできます。 - \n\n- 信頼していないウェブサイトやアプリに秘密鍵を **入力しないでください**。 - \n- Amethyst の開発者が秘密鍵を **求めることは絶対にありません**。 - \n- アカウントの復元のために、秘密鍵の安全なバックアップを **必ず** 保管してください。パスワードマネージャーの使用をお勧めします。 - </string> - <string name="account_backup_tips3_md"> - さらにセキュリティを高めるために、鍵をパスワードで暗号化することができます。この鍵は **ncryptsec1** で始まり、パスワードなしでは使用できません。 - \n\nパスワードを忘れると、鍵を復元できなくなります。 - </string> + <string name="account_backup_tips2_md">## 鍵のバックアップとセキュリティのヒント \n\nアカウントは秘密鍵によって保護されています。この鍵は **nsec1** で始まる長い文字列です。この秘密鍵にアクセスできる人は誰でも、あなたの名前でポストしたり、プロフィールを変更したりできます。 \n\n- 信頼していないウェブサイトやアプリに秘密鍵を **入力しないでください**。 \n- Amethyst の開発者が秘密鍵を **求めることは絶対にありません**。 \n- アカウントの復元のために、秘密鍵の安全なバックアップを **必ず** 保管してください。パスワードマネージャーの使用をお勧めします。</string> + <string name="account_backup_tips3_md">さらにセキュリティを高めるために、鍵をパスワードで暗号化することができます。この鍵は **ncryptsec1** で始まり、パスワードなしでは使用できません。 \n\nパスワードを忘れると、鍵を復元できなくなります。</string> <string name="encrypt_and_copy_my_secret_key">秘密鍵を暗号化してコピー</string> <string name="badge_award_image_for">%1$sのバッジ授与画像</string> <string name="badge_award_image">バッジ授与の画像</string> @@ -960,10 +951,7 @@ <string name="push_server_none_explainer">プッシュ通知を無効にします</string> <string name="push_server_uses_app_explainer">アプリ%1$sを使用</string> <string name="push_server_install_app">プッシュ通知の設定</string> - <string name="push_server_install_app_description"> - プッシュ通知を受け取るには、[Ntfy](https://ntfy.sh/)など[Unified Push](https://unifiedpush.org/)に対応したアプリをインストールしてください。 - インストール後、設定で使用するアプリを選択してください。 - </string> + <string name="push_server_install_app_description">プッシュ通知を受け取るには、[Ntfy](https://ntfy.sh/)など[Unified Push](https://unifiedpush.org/)に対応したアプリをインストールしてください。 インストール後、設定で使用するアプリを選択してください。</string> <string name="thread_title">スレッド</string> <string name="send_the_seller_a_message">出品者にメッセージを送る</string> <string name="hi_there_is_this_still_available">こんにちは、こちらはまだ入手可能ですか?</string> @@ -2048,15 +2036,7 @@ <string name="could_not_fetch_invoice_from_details">%1$s から請求書を取得できませんでした: %2$s</string> <string name="could_not_open_the_compressed_file">圧縮ファイルを開けませんでした</string> <string name="couldnt_find_nwc_wallets">ウォレットが見つかりません</string> - <string name="couldnt_find_nwc_wallets_description">Amethyst は Nostr Wallet Connect(NWC)に対応したウォレットアプリを見つけられませんでした。 -\n\n対処方法: -\n\t1. NWC サポートを確認: お使いのウォレットアプリが Nostr Wallet Connect に対応しているか再確認してください。 -\n\t2. 接続を作成: ウォレットアプリで新しい NWC 接続を作成してください。 -\n\t3. Amethyst にリンク: ウォレットは通常、以下の3つの方法を提供します: -\n\t\t3.1 自動リンク: 自動的に Amethyst を開いて接続するボタン。その場合、ボタンをクリックして Amethyst を開き、新しい画面で保存を押してください。 -\n\t\t3.2 手動リンク: この画面の貼り付けアイコンをクリックして直接入力できる接続 URI。 -\n\t\t3.3 QR コード: ウォレットが作成する QR コード。この画面の QR コードボタンから読み取れます。 - </string> + <string name="couldnt_find_nwc_wallets_description">Amethyst は Nostr Wallet Connect(NWC)に対応したウォレットアプリを見つけられませんでした。 \n\n対処方法: \n\t1. NWC サポートを確認: お使いのウォレットアプリが Nostr Wallet Connect に対応しているか再確認してください。 \n\t2. 接続を作成: ウォレットアプリで新しい NWC 接続を作成してください。 \n\t3. Amethyst にリンク: ウォレットは通常、以下の3つの方法を提供します: \n\t\t3.1 自動リンク: 自動的に Amethyst を開いて接続するボタン。その場合、ボタンをクリックして Amethyst を開き、新しい画面で保存を押してください。 \n\t\t3.2 手動リンク: この画面の貼り付けアイコンをクリックして直接入力できる接続 URI。 \n\t\t3.3 QR コード: ウォレットが作成する QR コード。この画面の QR コードボタンから読み取れます。</string> <string name="create">作成</string> <string name="d">日</string> <string name="danger_zone">危険ゾーン</string> @@ -2723,11 +2703,8 @@ <string name="proxy_section">Proxy Relay</string> <string name="proxy_section_explainer">filter.nostr.wine のように、アプリがフィードをダウンロードするために使用するアグリゲーター relay です。これにより outbox モデルが置き換えられ、アプリはリスト内の relay のみに接続するようになります。</string> <string name="public_chat">公開チャット</string> - <string name="public_chat_explainer">パブリックチャットは Nostr 上の全員に公開されており、誰でも - 参加できます。特定のトピックを中心としたオープンなコミュニティに最適です。 - モデレーションは、その relay 上のポストを削除することで管理できます</string> - <string name="public_chat_relays_explainer">このグループをホストする relay を 1〜3 件入力してください。 - Nostr クライアントはこの設定を使用して、メッセージのダウンロード先と送信先を決定します。</string> + <string name="public_chat_explainer">パブリックチャットは Nostr 上の全員に公開されており、誰でも 参加できます。特定のトピックを中心としたオープンなコミュニティに最適です。 モデレーションは、その relay 上のポストを削除することで管理できます</string> + <string name="public_chat_relays_explainer">このグループをホストする relay を 1〜3 件入力してください。 Nostr クライアントはこの設定を使用して、メッセージのダウンロード先と送信先を決定します。</string> <string name="public_chat_relays_title">Relay</string> <string name="public_chat_title">パブリックチャットのメタデータ</string> <string name="public_chats">パブリックチャット</string> @@ -2752,10 +2729,7 @@ <string name="read_only_user">読み取り専用ユーザー</string> <string name="record_a_message_description">長押ししてメッセージを録音</string> <string name="relay_chat">relay チャット</string> - <string name="relay_chat_explainer">relay チャットは、ホスト relay によって管理されるチャットグループです。 - Nostr 上の全員に公開されており、誰でも参加できます。 - 特定のトピックを中心としたオープンなコミュニティに最適です。一部のグループは一時的なもので、 - チャットメッセージは時間とともに消えます</string> + <string name="relay_chat_explainer">relay チャットは、ホスト relay によって管理されるチャットグループです。 Nostr 上の全員に公開されており、誰でも参加できます。 特定のトピックを中心としたオープンなコミュニティに最適です。一部のグループは一時的なもので、 チャットメッセージは時間とともに消えます</string> <string name="relay_chat_title">relay チャット</string> <string name="relay_management_tab_events">イベント</string> <string name="relay_management_tab_ips">IP</string> diff --git a/commonsUI/src/commonMain/composeResources/values-ko-rKR/strings.xml b/commonsUI/src/commonMain/composeResources/values-ko-rKR/strings.xml index 5ef5365607..4e876d3cf0 100644 --- a/commonsUI/src/commonMain/composeResources/values-ko-rKR/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-ko-rKR/strings.xml @@ -266,15 +266,8 @@ <string name="mark_all_known_as_read">알려진 항목 모두 읽음 표시</string> <string name="mark_all_new_as_read">새 항목 모두 읽음 표시</string> <string name="mark_all_as_read">모두 읽음 표시</string> - <string name="account_backup_tips2_md"> ## 키 백업 및 보안 팁 - \n\n계정은 비밀 키로 보호됩니다. 키는 **nsec1**로 시작하는 긴 문자열입니다. 이 비밀 키에 접근할 수 있는 누구든 게시물 작성 및 신원 변경이 가능합니다. - \n\n- 신뢰하지 않는 웹사이트나 소프트웨어에 비밀 키를 **입력하지** 마세요. - \n- Amethyst 개발자는 **절대** 비밀 키를 요청하지 않습니다. - \n- 계정 복구를 위해 비밀 키를 안전하게 백업해 **두세요**. 비밀번호 관리자 사용을 권장합니다. - </string> - <string name="account_backup_tips3_md"> 추가 보안을 위해 비밀번호로 키를 암호화할 수 있습니다. 이 키는 **ncryptsec1**로 시작하며 비밀번호 없이는 사용할 수 없습니다. - \n\n비밀번호를 분실하면 키를 복구할 수 없습니다. - </string> + <string name="account_backup_tips2_md">## 키 백업 및 보안 팁 \n\n계정은 비밀 키로 보호됩니다. 키는 **nsec1**로 시작하는 긴 문자열입니다. 이 비밀 키에 접근할 수 있는 누구든 게시물 작성 및 신원 변경이 가능합니다. \n\n- 신뢰하지 않는 웹사이트나 소프트웨어에 비밀 키를 **입력하지** 마세요. \n- Amethyst 개발자는 **절대** 비밀 키를 요청하지 않습니다. \n- 계정 복구를 위해 비밀 키를 안전하게 백업해 **두세요**. 비밀번호 관리자 사용을 권장합니다.</string> + <string name="account_backup_tips3_md">추가 보안을 위해 비밀번호로 키를 암호화할 수 있습니다. 이 키는 **ncryptsec1**로 시작하며 비밀번호 없이는 사용할 수 없습니다. \n\n비밀번호를 분실하면 키를 복구할 수 없습니다.</string> <string name="encrypt_and_copy_my_secret_key">내 비밀 키 암호화 및 복사</string> <string name="badge_award_image_for">%1$s의 배지 수상 이미지</string> <string name="badge_award_image">"배지 수상 이미지</string> @@ -958,9 +951,7 @@ <string name="push_server_none_explainer">푸시 알림 비활성화</string> <string name="push_server_uses_app_explainer">앱 %1$s 사용</string> <string name="push_server_install_app">푸시 알림 설정</string> - <string name="push_server_install_app_description"> 푸시 알림을 받으려면 [Ntfy](https://ntfy.sh/)와 같은 [Unified Push](https://unifiedpush.org/)를 지원하는 앱을 설치하세요. - 설치 후 설정에서 사용할 앱을 선택하세요. - </string> + <string name="push_server_install_app_description">푸시 알림을 받으려면 [Ntfy](https://ntfy.sh/)와 같은 [Unified Push](https://unifiedpush.org/)를 지원하는 앱을 설치하세요. 설치 후 설정에서 사용할 앱을 선택하세요.</string> <string name="thread_title">스레드</string> <string name="send_the_seller_a_message">판매자에게 메시지 보내기</string> <string name="hi_there_is_this_still_available">안녕하세요, 아직 구매 가능한가요?</string> @@ -2005,15 +1996,7 @@ <string name="could_not_fetch_invoice_from_details">%1$s에서 인보이스를 가져올 수 없습니다: %2$s</string> <string name="could_not_open_the_compressed_file">압축 파일을 열 수 없습니다</string> <string name="couldnt_find_nwc_wallets">지갑을 찾을 수 없음</string> - <string name="couldnt_find_nwc_wallets_description">Amethyst에서 Nostr Wallet Connect (NWC)를 지원하는 지갑 앱을 찾을 수 없습니다. -\n\n할 수 있는 작업: -\n\t1. NWC 지원 확인: 사용하려는 지갑 앱이 Nostr Wallet Connect와 호환되는지 다시 확인하세요. -\n\t2. 연결 생성: 지갑 앱에서 새 NWC 연결을 생성하세요. -\n\t3. Amethyst에 연결: 지갑에서 보통 세 가지 옵션을 제공합니다: -\n\t\t3.1 자동 연결: Amethyst를 자동으로 열고 연결하는 버튼. 해당 버튼을 클릭하고 Amethyst를 열어 새 화면에서 저장을 누르세요. -\n\t\t3.2 수동 연결: 연결 URI를 복사하고 이 화면의 붙여넣기 아이콘을 클릭하여 직접 입력하세요. -\n\t\t3.3 QR 코드: 지갑이 QR 코드를 생성하면 이 화면의 QR 코드 버튼으로 읽을 수 있습니다. - </string> + <string name="couldnt_find_nwc_wallets_description">Amethyst에서 Nostr Wallet Connect (NWC)를 지원하는 지갑 앱을 찾을 수 없습니다. \n\n할 수 있는 작업: \n\t1. NWC 지원 확인: 사용하려는 지갑 앱이 Nostr Wallet Connect와 호환되는지 다시 확인하세요. \n\t2. 연결 생성: 지갑 앱에서 새 NWC 연결을 생성하세요. \n\t3. Amethyst에 연결: 지갑에서 보통 세 가지 옵션을 제공합니다: \n\t\t3.1 자동 연결: Amethyst를 자동으로 열고 연결하는 버튼. 해당 버튼을 클릭하고 Amethyst를 열어 새 화면에서 저장을 누르세요. \n\t\t3.2 수동 연결: 연결 URI를 복사하고 이 화면의 붙여넣기 아이콘을 클릭하여 직접 입력하세요. \n\t\t3.3 QR 코드: 지갑이 QR 코드를 생성하면 이 화면의 QR 코드 버튼으로 읽을 수 있습니다.</string> <string name="create">만들기</string> <string name="d">일</string> <string name="danger_zone">위험 구역</string> @@ -2653,11 +2636,8 @@ <string name="proxy_section">프록시 Relay</string> <string name="proxy_section_explainer">filter.nostr.wine 같이 피드를 다운로드하는 데 앱이 사용해야 하는 집계 relay입니다. 이 설정은 아웃박스 모델을 대체하며 앱이 목록에 있는 relay에만 연결하도록 합니다.</string> <string name="public_chat">공개 채팅</string> - <string name="public_chat_explainer">공개 채팅은 Nostr의 모든 사람에게 공개되며 - 누구나 참여할 수 있습니다. 특정 주제에 관한 열린 커뮤니티에 적합합니다. - relay에서 게시물을 삭제하여 관리할 수 있습니다</string> - <string name="public_chat_relays_explainer">이 그룹을 호스팅하는 relay를 1~3개 입력하세요. - Nostr 클라이언트는 이 설정을 통해 메시지를 다운로드하고 전송할 위치를 파악합니다.</string> + <string name="public_chat_explainer">공개 채팅은 Nostr의 모든 사람에게 공개되며 누구나 참여할 수 있습니다. 특정 주제에 관한 열린 커뮤니티에 적합합니다. relay에서 게시물을 삭제하여 관리할 수 있습니다</string> + <string name="public_chat_relays_explainer">이 그룹을 호스팅하는 relay를 1~3개 입력하세요. Nostr 클라이언트는 이 설정을 통해 메시지를 다운로드하고 전송할 위치를 파악합니다.</string> <string name="public_chat_relays_title">relay</string> <string name="public_chat_title">공개 채팅 메타데이터</string> <string name="public_chats">공개 채팅</string> @@ -2682,10 +2662,7 @@ <string name="read_only_user">읽기 전용 사용자</string> <string name="record_a_message_description">길게 눌러 메시지를 녹음하세요</string> <string name="relay_chat">relay 채팅</string> - <string name="relay_chat_explainer">relay 채팅은 홈 relay가 관리하는 채팅 그룹입니다. - Nostr의 모든 사람에게 공개되며 누구나 참여할 수 있습니다. - 특정 주제에 관한 열린 커뮤니티에 적합합니다. 일부 그룹은 임시적이라 - 채팅 메시지가 시간이 지나면 사라집니다</string> + <string name="relay_chat_explainer">relay 채팅은 홈 relay가 관리하는 채팅 그룹입니다. Nostr의 모든 사람에게 공개되며 누구나 참여할 수 있습니다. 특정 주제에 관한 열린 커뮤니티에 적합합니다. 일부 그룹은 임시적이라 채팅 메시지가 시간이 지나면 사라집니다</string> <string name="relay_chat_title">relay 채팅</string> <string name="relay_management_tab_events">이벤트</string> <string name="relay_management_tab_ips">IP</string> diff --git a/commonsUI/src/commonMain/composeResources/values-lv-rLV/strings.xml b/commonsUI/src/commonMain/composeResources/values-lv-rLV/strings.xml index 0ce2714a9a..2cb28ecf8f 100644 --- a/commonsUI/src/commonMain/composeResources/values-lv-rLV/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-lv-rLV/strings.xml @@ -277,15 +277,8 @@ <string name="mark_all_known_as_read">Atzīmēt visu zināmo kā lasītu</string> <string name="mark_all_new_as_read">Atzīmēt visu jauno kā lasītu</string> <string name="mark_all_as_read">Atzīmēt visu kā lasītu</string> - <string name="account_backup_tips2_md"> ## Atslēgas dublēšanas un drošības padomi - \n\nJūsu konts ir aizsargāts ar slepeno atslēgu. Atslēga ir gara rakstzīmju secība, kas sākas ar **nsec1**. Ikviens, kam ir piekļuve šai slepenai atslēgai, var publicēt un mainīt jūsu identitāti. - \n\n- **Neievietojiet** savu slepeno atslēgu nevienā tīmekļa vietnē vai programmatūrā, kurai neuzticaties. - \n- Amethyst izstrādātāji **nekad** neprasīs jūsu slepeno atslēgu. - \n- **Glabājiet** drošu slepenas atslēgas dublējumu konta atjaunošanai. Mēs iesakām izmantot paroļu pārvaldnieku. - </string> - <string name="account_backup_tips3_md"> Papildu drošībai variet šifrēt atslēgu ar paroli. Šī atslēga sāksies ar **ncryptsec1** virkni un to nevarēs izmantot bez šīs paroles. - \n\nJa jūs aizmirsīsiet paroli, vairs nevarēsiet atgūt savu atslēgu. - </string> + <string name="account_backup_tips2_md">## Atslēgas dublēšanas un drošības padomi \n\nJūsu konts ir aizsargāts ar slepeno atslēgu. Atslēga ir gara rakstzīmju secība, kas sākas ar **nsec1**. Ikviens, kam ir piekļuve šai slepenai atslēgai, var publicēt un mainīt jūsu identitāti. \n\n- **Neievietojiet** savu slepeno atslēgu nevienā tīmekļa vietnē vai programmatūrā, kurai neuzticaties. \n- Amethyst izstrādātāji **nekad** neprasīs jūsu slepeno atslēgu. \n- **Glabājiet** drošu slepenas atslēgas dublējumu konta atjaunošanai. Mēs iesakām izmantot paroļu pārvaldnieku.</string> + <string name="account_backup_tips3_md">Papildu drošībai variet šifrēt atslēgu ar paroli. Šī atslēga sāksies ar **ncryptsec1** virkni un to nevarēs izmantot bez šīs paroles. \n\nJa jūs aizmirsīsiet paroli, vairs nevarēsiet atgūt savu atslēgu.</string> <string name="backup_keys_nudge_body">Jūsu slepenā atslēga ir vienīgais veids, kā piekļūt šim kontam. Ja to pazaudēsiet, tad nekad nevarēsiet atgūt piekļuvi kontam. Saglabājiet atslēgu drošā vietā tagad.</string> <string name="backup_keys_nudge_already_saved">Esiet tās saglabājuši</string> <string name="encrypt_and_copy_my_secret_key">Šifrēt un kopēt manu slepeno atslēgu</string> @@ -970,9 +963,7 @@ <string name="push_server_none_explainer">Atspējo push paziņojumus</string> <string name="push_server_uses_app_explainer">Izmanto lietotni %1$s</string> <string name="push_server_install_app">Push paziņojumu iestatīšana</string> - <string name="push_server_install_app_description"> Lai saņemtu push paziņojumus, instalējiet jebkuru lietotni, kas atbalsta [Unified Push](https://unifiedpush.org/), piemēram, [Ntfy](https://ntfy.sh/). - Pēc instalēšanas iestatījumos atlasiet lietotni, kuru vēlaties izmantot. - </string> + <string name="push_server_install_app_description">Lai saņemtu push paziņojumus, instalējiet jebkuru lietotni, kas atbalsta [Unified Push](https://unifiedpush.org/), piemēram, [Ntfy](https://ntfy.sh/). Pēc instalēšanas iestatījumos atlasiet lietotni, kuru vēlaties izmantot.</string> <string name="thread_title">Diskusija</string> <string name="send_the_seller_a_message">Nosūtīt pārdevējam ziņojumu</string> <string name="hi_there_is_this_still_available">Sveiki, vai tas joprojām ir pieejams?</string> @@ -2037,15 +2028,7 @@ <string name="could_not_fetch_invoice_from_details">Nevarēja iegūt rēķinu no %1$s: %2$s</string> <string name="could_not_open_the_compressed_file">Nevarēja atvērt saspiestu failu</string> <string name="couldnt_find_nwc_wallets">Maks nav atrasts</string> - <string name="couldnt_find_nwc_wallets_description">Amethyst nevarēja atrast nevienu maka lietotni, kas atbalsta Nostr Wallet Connect (NWC). -\n\nKo varat darīt: -\n\t1. Pārbaudiet NWC atbalstu: Pārliecinieties, vai jūsu vēlamā maka lietotne ir saderīga ar Nostr Wallet Connect. -\n\t2. Izveidojiet savienojumu: Atveriet savu maka lietotni un izveidojiet jaunu NWC savienojumu. -\n\t3. Saistiet ar Amethyst: Jūsu maks parasti piedāvās trīs iespējas: -\n\t\t3.1 Automātiska saistīšana: Poga, lai automātiski atvērtu un savienotu ar Amethyst. Ja tā, noklikšķiniet uz šīs pogas, atveriet Amethyst un nospiediet saglabāt jaunajā ekrānā. -\n\t\t3.2 Manuāla saistīšana: Savienojuma URI, ko varat kopēt un noklikšķināt uz ielīmēšanas ikonas šajā ekrānā, lai to ievadītu tieši. -\n\t\t3.3 QR kods: Maks izveido QR kodu, kuru varat nolasīt ar QR koda pogu šajā ekrānā. - </string> + <string name="couldnt_find_nwc_wallets_description">Amethyst nevarēja atrast nevienu maka lietotni, kas atbalsta Nostr Wallet Connect (NWC). \n\nKo varat darīt: \n\t1. Pārbaudiet NWC atbalstu: Pārliecinieties, vai jūsu vēlamā maka lietotne ir saderīga ar Nostr Wallet Connect. \n\t2. Izveidojiet savienojumu: Atveriet savu maka lietotni un izveidojiet jaunu NWC savienojumu. \n\t3. Saistiet ar Amethyst: Jūsu maks parasti piedāvās trīs iespējas: \n\t\t3.1 Automātiska saistīšana: Poga, lai automātiski atvērtu un savienotu ar Amethyst. Ja tā, noklikšķiniet uz šīs pogas, atveriet Amethyst un nospiediet saglabāt jaunajā ekrānā. \n\t\t3.2 Manuāla saistīšana: Savienojuma URI, ko varat kopēt un noklikšķināt uz ielīmēšanas ikonas šajā ekrānā, lai to ievadītu tieši. \n\t\t3.3 QR kods: Maks izveido QR kodu, kuru varat nolasīt ar QR koda pogu šajā ekrānā.</string> <string name="create">Izveidot</string> <string name="danger_zone">Bīstamā zona</string> <string name="dark">Tumšs</string> @@ -2724,11 +2707,8 @@ <string name="proxy_section">Starpniekservera Relay</string> <string name="proxy_section_explainer">Agregātori relay, ko lietotnei jāizmanto, lai lejupielādētu jūsu plūsmas, piemēram, filter.nostr.wine. Tas aizstāj izejošo ziņojumu modeli un liks lietotnei pieslēgties tikai jūsu sarakstos esošajiem relay.</string> <string name="public_chat">Publiskā tērzēšana</string> - <string name="public_chat_explainer">Publiskās tērzēšanas ir redzamas visiem Nostr lietotājiem un ikviens - var tajās piedalīties. Tās ir lieliski piemērotas atvērtām kopienām par konkrētām tēmām. - Moderēšanu var kontrolēt, dzēšot ierakstus tās relay</string> - <string name="public_chat_relays_explainer">Ievadiet no 1 līdz 3 relay, kas mitina šo grupu. - Nostr klienti izmanto šo iestatījumu, lai zinātu, no kurienes lejupielādēt ziņojumus un kur nosūtīt jūsu ziņojumus.</string> + <string name="public_chat_explainer">Publiskās tērzēšanas ir redzamas visiem Nostr lietotājiem un ikviens var tajās piedalīties. Tās ir lieliski piemērotas atvērtām kopienām par konkrētām tēmām. Moderēšanu var kontrolēt, dzēšot ierakstus tās relay</string> + <string name="public_chat_relays_explainer">Ievadiet no 1 līdz 3 relay, kas mitina šo grupu. Nostr klienti izmanto šo iestatījumu, lai zinātu, no kurienes lejupielādēt ziņojumus un kur nosūtīt jūsu ziņojumus.</string> <string name="public_chat_relays_title">Relay</string> <string name="public_chat_title">Publiskās tērzēšanas metadati</string> <string name="public_chats">Publiskie čati</string> @@ -2753,10 +2733,7 @@ <string name="read_only_user">Tikai lasāms lietotājs</string> <string name="record_a_message_description">Noklikšķiniet un turiet, lai ierakstītu ziņojumu</string> <string name="relay_chat">Relay tērzēšana</string> - <string name="relay_chat_explainer">Relay tērzēšanas ir tērzēšanas grupas, kuras kontrolē to mājas relay. - Tās ir redzamas visiem Nostr lietotājiem un ikviens var tajās piedalīties. - Tās ir lieliski piemērotas atvērtām kopienām par konkrētām tēmām. Dažas no šīm grupām ir īslaicīgas - un tāpēc tērzēšanas ziņojumi laika gaitā pazūd</string> + <string name="relay_chat_explainer">Relay tērzēšanas ir tērzēšanas grupas, kuras kontrolē to mājas relay. Tās ir redzamas visiem Nostr lietotājiem un ikviens var tajās piedalīties. Tās ir lieliski piemērotas atvērtām kopienām par konkrētām tēmām. Dažas no šīm grupām ir īslaicīgas un tāpēc tērzēšanas ziņojumi laika gaitā pazūd</string> <string name="relay_chat_title">Relay tērzēšanas</string> <string name="relay_management_tab_events">Notikumi</string> <string name="relay_management_tab_ips">IP adreses</string> diff --git a/commonsUI/src/commonMain/composeResources/values-nl/strings.xml b/commonsUI/src/commonMain/composeResources/values-nl/strings.xml index 70ca1190a6..5ddca3ba0e 100644 --- a/commonsUI/src/commonMain/composeResources/values-nl/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-nl/strings.xml @@ -211,7 +211,7 @@ <string name="copy_user_id">Kopieer gebruiker ID</string> <string name="unblock_user">Deblokkeer gebruiker</string> <string name="user_is_blocked_hidden">Deze gebruiker is geblokkeerd/gedempt. Hun berichten zijn verborgen.</string> - <string name="npub_hex_username">npub, hex, gebruikersnaam </string> + <string name="npub_hex_username">npub, hex, gebruikersnaam</string> <string name="clear">Wissen</string> <string name="app_logo">App logo</string> <string name="nsec_npub_hex_private_key">nsec.. of npub..</string> @@ -259,15 +259,8 @@ <string name="mark_all_known_as_read">Bekende markeren als gelezen</string> <string name="mark_all_new_as_read">Nieuwe markeren als gelezen</string> <string name="mark_all_as_read">Alles markeren als gelezen</string> - <string name="account_backup_tips2_md"> ## Sleutel back-up en veiligheidstips - \n\nUw account is beveiligd met een privésleutel. De sleutel is een lange, willekeurige reeks die begint met **nsec1**. Iedereen die toegang heeft tot uw privésleutel kan inhoud publiceren met uw identiteit. - \n\n- Voer uw privésleutel **nooit** in een website of software die u niet vertrouwt. - \n- Amethyst ontwikkelaars zullen u **nooit** om uw privésleutel vragen. - \n- **Bewaar** een veilige back-up van uw privésleutel voor accountherstel. Wij raden u aan een wachtwoordmanager te gebruiken. - </string> - <string name="account_backup_tips3_md"> Voor meer veiligheid kan je je sleutel versleutelen met een wachtwoord. Deze sleutel begint met **ncryptsec1** en kan niet worden gebruikt zonder uw wachtwoord. - \n\nAls je je wachtwoord verliest, je kan je sleutel niet herstellen. - </string> + <string name="account_backup_tips2_md">## Sleutel back-up en veiligheidstips \n\nUw account is beveiligd met een privésleutel. De sleutel is een lange, willekeurige reeks die begint met **nsec1**. Iedereen die toegang heeft tot uw privésleutel kan inhoud publiceren met uw identiteit. \n\n- Voer uw privésleutel **nooit** in een website of software die u niet vertrouwt. \n- Amethyst ontwikkelaars zullen u **nooit** om uw privésleutel vragen. \n- **Bewaar** een veilige back-up van uw privésleutel voor accountherstel. Wij raden u aan een wachtwoordmanager te gebruiken.</string> + <string name="account_backup_tips3_md">Voor meer veiligheid kan je je sleutel versleutelen met een wachtwoord. Deze sleutel begint met **ncryptsec1** en kan niet worden gebruikt zonder uw wachtwoord. \n\nAls je je wachtwoord verliest, je kan je sleutel niet herstellen.</string> <string name="encrypt_and_copy_my_secret_key">Versleutel en kopieer mijn geheime sleutel</string> <string name="badge_award_image_for">Badge award afbeelding voor %1$s</string> <string name="badge_award_image">Badge-awardafbeelding</string> @@ -960,9 +953,7 @@ <string name="push_server_none_explainer">Push-notificaties uitschakelen</string> <string name="push_server_uses_app_explainer">Gebruikt app %1$s</string> <string name="push_server_install_app">Instellen Push-notificaties</string> - <string name="push_server_install_app_description"> Om push-notificaties te ontvangen, installeer een app die [Unified Push] ondersteunt](https://unifiedpush.org/), zoals [Ntfy](https://ntfy.sh/). - Na installatie selecteer de app die je wilt gebruiken in de instellingen. - </string> + <string name="push_server_install_app_description">Om push-notificaties te ontvangen, installeer een app die [Unified Push] ondersteunt](https://unifiedpush.org/), zoals [Ntfy](https://ntfy.sh/). Na installatie selecteer de app die je wilt gebruiken in de instellingen.</string> <string name="thread_title">Draad</string> <string name="send_the_seller_a_message">Stuur de verkoper een bericht</string> <string name="hi_there_is_this_still_available">Hallo daar, is dit nog steeds beschikbaar?</string> diff --git a/commonsUI/src/commonMain/composeResources/values-pl-rPL/strings.xml b/commonsUI/src/commonMain/composeResources/values-pl-rPL/strings.xml index 6b3fd3bf9a..570f8fc2b1 100644 --- a/commonsUI/src/commonMain/composeResources/values-pl-rPL/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-pl-rPL/strings.xml @@ -508,8 +508,7 @@ <string name="about_us">Informacja o grupie…</string> <string name="search_and_add_a_user">Szukaj i dodaj użytkownika</string> <string name="add_a_relay">Dodaj Transmiter</string> - <string name="relay_url_not_valid">Nieprawidłowy adres transmitera. Użyj nazwy hosta lub adresu IP w nawiasach (na przykład -[201:d0e:9ba5:8bbc::1]:8080).</string> + <string name="relay_url_not_valid">Nieprawidłowy adres transmitera. Użyj nazwy hosta lub adresu IP w nawiasach (na przykład [201:d0e:9ba5:8bbc::1]:8080).</string> <string name="my_name">Moje imię @tag</string> <string name="display_name">Nazwa użytkownika</string> <string name="my_display_name">Mój nick</string> @@ -657,15 +656,8 @@ <string name="mark_all_known_as_read">Zaznacz wszystkie popularne jako przeczytane</string> <string name="mark_all_new_as_read">Zaznacz wszystkie nowe jako przeczytane</string> <string name="mark_all_as_read">Zaznacz wszystkie jako przeczytane</string> - <string name="account_backup_tips2_md"> ## Porady dotyczące kopii zapasowej i bezpieczeństwa - \n\nTwoje konto jest zabezpieczone tajnym kluczem. Kluczem jest długa sekwencja znaków, zaczynająca się od **nsec1**. Każdy, kto ma dostęp do tego tajnego klucza może publikować i zmieniać Twoją tożsamość. - \n\n- **nie** umiejszczaj swojego tajnego klucza na żadnej stronie internetowej lub oprogramowaniu, któremu nie ufasz. - \n- Deweloperzy Ametyst **nigdy** nie będą prosić o Twój tajny klucz. - \n- **Zrób ** bezpieczną kopię zapasową swojego tajnego klucza do ewentualnego odzyskania konta. Zalecamy użycie menedżera haseł. - </string> - <string name="account_backup_tips3_md"> Dla dodatkowego bezpieczeństwa możesz zaszyfrować swój klucz hasłem. Ten klucz zaczyna się od **ncryptsec1** i nie może być użyty bez hasła. - \n\nJeśli zgubisz hasło, nie będziesz w stanie odzyskać swojego klucza. - </string> + <string name="account_backup_tips2_md">## Porady dotyczące kopii zapasowej i bezpieczeństwa \n\nTwoje konto jest zabezpieczone tajnym kluczem. Kluczem jest długa sekwencja znaków, zaczynająca się od **nsec1**. Każdy, kto ma dostęp do tego tajnego klucza może publikować i zmieniać Twoją tożsamość. \n\n- **nie** umiejszczaj swojego tajnego klucza na żadnej stronie internetowej lub oprogramowaniu, któremu nie ufasz. \n- Deweloperzy Ametyst **nigdy** nie będą prosić o Twój tajny klucz. \n- **Zrób ** bezpieczną kopię zapasową swojego tajnego klucza do ewentualnego odzyskania konta. Zalecamy użycie menedżera haseł.</string> + <string name="account_backup_tips3_md">Dla dodatkowego bezpieczeństwa możesz zaszyfrować swój klucz hasłem. Ten klucz zaczyna się od **ncryptsec1** i nie może być użyty bez hasła. \n\nJeśli zgubisz hasło, nie będziesz w stanie odzyskać swojego klucza.</string> <string name="backup_keys_nudge_title">Kopia zapasowa kluczy</string> <string name="backup_keys_nudge_body">Twój tajny klucz jest jedynym sposobem na dostęp do tego konta. Jeśli go zgubisz, nigdy nie będzie można go odzyskać. Zapisz go w bezpiecznym miejscu.</string> <string name="backup_keys_nudge_backup_now">Utwórz kopię zapasową</string> @@ -1849,9 +1841,7 @@ <string name="push_server_none_explainer">Wyłącz powiadomienia</string> <string name="push_server_uses_app_explainer">Używa aplikacji %1$s</string> <string name="push_server_install_app">Ustawienia powiadomień</string> - <string name="push_server_install_app_description"> Aby otrzymywać powiadomienia, zainstaluj dowolną aplikację, która obsługuje [Unified Push](https://unifiedpush. rg/), np. [Ntfy](https://ntfy.sh/). - Po zainstalowaniu, wybierz w ustawieniach aplikację, którą chcesz użyć. - </string> + <string name="push_server_install_app_description">Aby otrzymywać powiadomienia, zainstaluj dowolną aplikację, która obsługuje [Unified Push](https://unifiedpush. rg/), np. [Ntfy](https://ntfy.sh/). Po zainstalowaniu, wybierz w ustawieniach aplikację, którą chcesz użyć.</string> <string name="thread_title">Wątek</string> <string name="send_the_seller_a_message">Wyślij sprzedawcy wiadomość</string> <string name="hi_there_is_this_still_available">Cześć, czy to jest nadal dostępne?</string> @@ -3656,15 +3646,7 @@ <string name="could_not_fetch_invoice_from_details">Nie można pobrać faktury od %1$s: %2$s</string> <string name="could_not_open_the_compressed_file">Nie można otworzyć skompresowanego pliku</string> <string name="couldnt_find_nwc_wallets">Nie znaleziono portfela</string> - <string name="couldnt_find_nwc_wallets_description">Amethyst nie znalazł żadnej aplikacji portfela obsługującej Nostr Wallet Connect (NWC). -\n\nCo możesz zrobić: -\n\t1. Zweryfikuj wsparcie NWC: Sprawdź, czy preferowana aplikacja portfela jest kompatybilna z Nostr Wallet Connect. -\n\t2. Utwórz połączenie: Przejdź do aplikacji portfela i utwórz nowe połączenie NWC. -\n\t3. Połącz z Amethyst: Twój portfel zazwyczaj oferuje trzy opcje: -\n\t\t3. Automatyczne połączenie: przycisk, który automatycznie otwiera i łączy się z Amethyst. Jeśli tak, kliknij ten przycisk, otwórz Ametyst i naciśnij przycisk "Zapisz" na nowym ekranie. -\n\t\t3. Połączenie ręczne: adres URI połączenia, który można skopiować i wkleić, klikając ikonę wklejania na tym ekranie, aby wprowadzić go bezpośrednio. -\n\t\t3. Kod QR: Portfel tworzy kod QR, który można odczytać z przycisku QR na tym ekranie. - </string> + <string name="couldnt_find_nwc_wallets_description">Amethyst nie znalazł żadnej aplikacji portfela obsługującej Nostr Wallet Connect (NWC). \n\nCo możesz zrobić: \n\t1. Zweryfikuj wsparcie NWC: Sprawdź, czy preferowana aplikacja portfela jest kompatybilna z Nostr Wallet Connect. \n\t2. Utwórz połączenie: Przejdź do aplikacji portfela i utwórz nowe połączenie NWC. \n\t3. Połącz z Amethyst: Twój portfel zazwyczaj oferuje trzy opcje: \n\t\t3. Automatyczne połączenie: przycisk, który automatycznie otwiera i łączy się z Amethyst. Jeśli tak, kliknij ten przycisk, otwórz Ametyst i naciśnij przycisk "Zapisz" na nowym ekranie. \n\t\t3. Połączenie ręczne: adres URI połączenia, który można skopiować i wkleić, klikając ikonę wklejania na tym ekranie, aby wprowadzić go bezpośrednio. \n\t\t3. Kod QR: Portfel tworzy kod QR, który można odczytać z przycisku QR na tym ekranie.</string> <string name="create">Utwórz</string> <string name="d">d</string> <string name="danger_zone">Niebezpieczna strefa</string> @@ -4966,10 +4948,7 @@ <string name="relay_auth_search_keywords">auth uwierzytelnianie transmiter podpis weryfikacja nip-42 tożsamość</string> <string name="relay_auth_settings_title">Uwierzytelnianie transmitera</string> <string name="relay_chat">Czat Transmitera</string> - <string name="relay_chat_explainer">Czaty transmiterów to grupy czatów kontrolowane przez ich macierzyste transmitery. - Są one widoczne dla wszystkich użytkowników Nostr i każdy może w nich uczestniczyć. - Świetnie nadają się do tworzenia otwartych społeczności wokół określonych tematów. Niektóre z tych grup są efemeryczne - i dlatego wiadomości czatu znikają z czasem</string> + <string name="relay_chat_explainer">Czaty transmiterów to grupy czatów kontrolowane przez ich macierzyste transmitery. Są one widoczne dla wszystkich użytkowników Nostr i każdy może w nich uczestniczyć. Świetnie nadają się do tworzenia otwartych społeczności wokół określonych tematów. Niektóre z tych grup są efemeryczne i dlatego wiadomości czatu znikają z czasem</string> <string name="relay_chat_title">Czaty Transmitera</string> <string name="relay_explain_account_data">Twój profil, ustawienia i wersje robocze na Twoich domowych transmiterach.</string> <string name="relay_explain_community_chats">Transmitery, na których każda społeczność publikuje swoje plany.</string> @@ -5178,8 +5157,7 @@ <string name="route_podcasts">Podcasty</string> <string name="schedule_post_button_add">Zaplanuj post</string> <string name="schedule_post_button_remove">Anuluj planowanie</string> - <string name="schedule_post_warning_multi">Wpisy zaplanowane mogą nie zostać opublikowane, dopóki nie uruchomisz ponownie aplikacji. -Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest aktywne. Włącz opcję „Zawsze włączone” w menu Ustawienia → Preferencje interfejsu użytkownika, aby zapewnić niezawodne planowanie zadań w tle.</string> + <string name="schedule_post_warning_multi">Wpisy zaplanowane mogą nie zostać opublikowane, dopóki nie uruchomisz ponownie aplikacji. Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest aktywne. Włącz opcję „Zawsze włączone” w menu Ustawienia → Preferencje interfejsu użytkownika, aby zapewnić niezawodne planowanie zadań w tle.</string> <string name="schedule_post_warning_single">Zaplanowane posty mogą nie zostać opublikowane, dopóki nie uruchomisz ponownie aplikacji. Aby zapewnić niezawodne planowanie zadań w tle, włącz opcję „Zawsze włączone” w Ustawieniach → Preferencje dotyczące niezawodnego planowania zadań w tle.</string> <string name="scheduled_posts">Zaplanowane posty</string> <string name="scheduled_posts_action_send_now">Wyślij teraz</string> @@ -5792,7 +5770,7 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest <item quantity="other">%1$d sposoby płatności zostały usunięte</item> </plurals> <string name="backup_review_payments_explainer">Osoby, które Ci płacą, widzą tylko to, co znajduje się na tej liście.</string> - <string name="backup_review_places_gone"> miejsca zniknęły</string> + <string name="backup_review_places_gone">miejsca zniknęły</string> <string name="backup_review_places_you_follow">Miejsca które obserwujesz</string> <string name="backup_review_private_added_body">Są teraz zaszyfrowane, co prawdopodobnie wyjaśnia, gdzie podziały się powyższe elementy publiczne.</string> <string name="backup_review_private_added_title">Ustawiłeś te pozycje jako prywatne</string> diff --git a/commonsUI/src/commonMain/composeResources/values-pt-rBR/strings.xml b/commonsUI/src/commonMain/composeResources/values-pt-rBR/strings.xml index 19715d92c0..1a156efffd 100644 --- a/commonsUI/src/commonMain/composeResources/values-pt-rBR/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-pt-rBR/strings.xml @@ -361,7 +361,7 @@ <string name="copy_user_id">Copiar ID do usuário</string> <string name="unblock_user">Desbloquear Usuário</string> <string name="user_is_blocked_hidden">Este usuário está bloqueado/silenciado. Suas publicações estão ocultas.</string> - <string name="npub_hex_username">npub, hex, usuário </string> + <string name="npub_hex_username">npub, hex, usuário</string> <string name="clear">Limpar</string> <string name="app_logo">Logo do aplicativo</string> <string name="nsec_npub_hex_private_key">nsec / npub / hex chave privada</string> @@ -449,8 +449,8 @@ <string name="paid_relay">Retransmissor pago</string> <string name="tor_relay">Forçar uso do Tor ao conectar</string> <string name="posts_received">postagens recebidas</string> - <string name="translations_auto">Autotraduzido </string> - <string name="translations_translated_from"> de </string> + <string name="translations_auto">Autotraduzido</string> + <string name="translations_translated_from">de</string> <string name="translations_to">para</string> <string name="translations_show_in_lang_first">Mostrar em %1$s primeiro</string> <string name="chat_about_topic">Chat público sobre %1$s</string> @@ -463,13 +463,8 @@ <string name="mark_all_known_as_read">Marcar todos conhecidos como lidos</string> <string name="mark_all_new_as_read">Marcar todas novas solicitações como lidas</string> <string name="mark_all_as_read">Marcar todas como lidas</string> - <string name="account_backup_tips2_md"> ## Dicas de Backup de Chave e Segurança - \n\nSua conta é protegida por uma chave secreta. A chave é uma sequência longa de caracteres que começa com **nsec1**. Qualquer pessoa que tenha acesso a esta chave secreta pode postar e alterar sua identidade. - \n\n- **Não** coloque sua chave secreta em nenhum site ou software que você não confie. - \n- Os desenvolvedores da Amethyst **nunca** pedirão sua chave secreta. - \n- **Mantenha** uma cópia de segurança segura de sua chave secreta para recuperação da conta. Recomendamos o uso de um gerenciador de senhas.</string> - <string name="account_backup_tips3_md"> Para segurança adicional, você pode criptografar sua chave com uma senha. Esta chave começa com **ncryptsec1** e não pode ser usada sem sua senha. - \n\nSe você perder sua senha, não será possível recuperar sua chave.</string> + <string name="account_backup_tips2_md">## Dicas de Backup de Chave e Segurança \n\nSua conta é protegida por uma chave secreta. A chave é uma sequência longa de caracteres que começa com **nsec1**. Qualquer pessoa que tenha acesso a esta chave secreta pode postar e alterar sua identidade. \n\n- **Não** coloque sua chave secreta em nenhum site ou software que você não confie. \n- Os desenvolvedores da Amethyst **nunca** pedirão sua chave secreta. \n- **Mantenha** uma cópia de segurança segura de sua chave secreta para recuperação da conta. Recomendamos o uso de um gerenciador de senhas.</string> + <string name="account_backup_tips3_md">Para segurança adicional, você pode criptografar sua chave com uma senha. Esta chave começa com **ncryptsec1** e não pode ser usada sem sua senha. \n\nSe você perder sua senha, não será possível recuperar sua chave.</string> <string name="backup_keys_nudge_title">Faça backup das suas chaves</string> <string name="backup_keys_nudge_body">Sua chave secreta é a única forma de acessar esta conta. Se você perdê-la, ela nunca poderá ser recuperada. Guarde-a em um lugar seguro agora.</string> <string name="backup_keys_nudge_backup_now">Fazer backup agora</string> @@ -1584,9 +1579,7 @@ <string name="push_server_none_explainer">Desativa as Notificações Push</string> <string name="push_server_uses_app_explainer">Usa o aplicativo %1$s</string> <string name="push_server_install_app">Configuração de Notificações Push</string> - <string name="push_server_install_app_description"> Para receber notificações push, instale qualquer aplicativo que suporte [Unified Push](https://unifiedpush.org/), como [Ntfy](https://ntfy.sh/). - Após a instalação, selecione o aplicativo que deseja usar nas configurações. - </string> + <string name="push_server_install_app_description">Para receber notificações push, instale qualquer aplicativo que suporte [Unified Push](https://unifiedpush.org/), como [Ntfy](https://ntfy.sh/). Após a instalação, selecione o aplicativo que deseja usar nas configurações.</string> <string name="thread_title">Linha</string> <string name="send_the_seller_a_message">Envie uma mensagem para o vendedor</string> <string name="hi_there_is_this_still_available">Olá, ainda está disponível?</string> @@ -3323,14 +3316,7 @@ <string name="could_not_fetch_invoice_from_details">Não foi possível obter a fatura de %1$s: %2$s</string> <string name="could_not_open_the_compressed_file">Não foi possível abrir o arquivo compactado</string> <string name="couldnt_find_nwc_wallets">Carteira não encontrada</string> - <string name="couldnt_find_nwc_wallets_description">O Amethyst não conseguiu encontrar nenhum aplicativo de carteira que suporte Nostr Wallet Connect (NWC). -\n\nO que você pode fazer: -\n\t1. Verifique o suporte NWC: Confirme se o aplicativo da sua carteira é compatível com o Nostr Wallet Connect. -\n\t2. Criar conexão: Vá até o aplicativo da sua carteira e crie uma nova conexão NWC. -\n\t3. Vincular ao Amethyst: Sua carteira geralmente oferece três opções: -\n\t\t3.1 Link Automático: Um botão para abrir e conectar automaticamente ao Amethyst. Clique nele, abra o Amethyst e salve na nova tela. -\n\t\t3.2 Link Manual: Um URI de conexão que você pode copiar e colar usando o ícone de colar nesta tela. -\n\t\t3.3 Código QR: A carteira gera um código QR que você pode ler usando o botão de código QR nesta tela.</string> + <string name="couldnt_find_nwc_wallets_description">O Amethyst não conseguiu encontrar nenhum aplicativo de carteira que suporte Nostr Wallet Connect (NWC). \n\nO que você pode fazer: \n\t1. Verifique o suporte NWC: Confirme se o aplicativo da sua carteira é compatível com o Nostr Wallet Connect. \n\t2. Criar conexão: Vá até o aplicativo da sua carteira e crie uma nova conexão NWC. \n\t3. Vincular ao Amethyst: Sua carteira geralmente oferece três opções: \n\t\t3.1 Link Automático: Um botão para abrir e conectar automaticamente ao Amethyst. Clique nele, abra o Amethyst e salve na nova tela. \n\t\t3.2 Link Manual: Um URI de conexão que você pode copiar e colar usando o ícone de colar nesta tela. \n\t\t3.3 Código QR: A carteira gera um código QR que você pode ler usando o botão de código QR nesta tela.</string> <string name="create">Criar</string> <string name="d">d</string> <string name="danger_zone">Zona de Perigo</string> @@ -4427,11 +4413,8 @@ <string name="proxy_section">Relays proxy</string> <string name="proxy_section_explainer">Relays agregadores que o app usa para baixar seus feeds, como filter.nostr.wine. Isso substitui o modelo outbox e faz o app se conectar apenas aos relays das suas listas.</string> <string name="public_chat">Chat público</string> - <string name="public_chat_explainer">Os chats públicos são visíveis para todos no Nostr, e qualquer pessoa - pode participar deles. Eles são ótimos para comunidades abertas em torno de tópicos específicos. - A moderação pode ser controlada excluindo postagens em seus relays</string> - <string name="public_chat_relays_explainer">Inserir entre 1–3 relés que hospedam esse grupo. - os clientes Nostr usam essa configuração para saber de onde fazer o download de mensagens e enviar suas mensagens.</string> + <string name="public_chat_explainer">Os chats públicos são visíveis para todos no Nostr, e qualquer pessoa pode participar deles. Eles são ótimos para comunidades abertas em torno de tópicos específicos. A moderação pode ser controlada excluindo postagens em seus relays</string> + <string name="public_chat_relays_explainer">Inserir entre 1–3 relés que hospedam esse grupo. os clientes Nostr usam essa configuração para saber de onde fazer o download de mensagens e enviar suas mensagens.</string> <string name="public_chat_relays_title">Relés</string> <string name="public_chat_title">Metadados do Chat Público</string> <string name="public_chats">Chats públicos</string> @@ -4496,10 +4479,7 @@ <string name="relay_auth_search_keywords">auth autenticação relay assinar verificar nip-42 identidade</string> <string name="relay_auth_settings_title">Autenticação de relay</string> <string name="relay_chat">Chat Relé</string> - <string name="relay_chat_explainer">Chat Relés são grupos de chat controlados por seu relé de origem. - Eles são visíveis para todos no Nostr e qualquer pessoa pode participar. - São ótimos para comunidades abertas em torno de tópicos específicos. Alguns desses grupos são efêmeros, - portanto, as mensagens desaparecem com o tempo</string> + <string name="relay_chat_explainer">Chat Relés são grupos de chat controlados por seu relé de origem. Eles são visíveis para todos no Nostr e qualquer pessoa pode participar. São ótimos para comunidades abertas em torno de tópicos específicos. Alguns desses grupos são efêmeros, portanto, as mensagens desaparecem com o tempo</string> <string name="relay_chat_title">Chat Relés</string> <string name="relay_explain_account_data">Seu próprio perfil, configurações e rascunhos, nos seus relays de origem.</string> <string name="relay_explain_community_chats">Os relays em que cada comunidade publica seus planos.</string> diff --git a/commonsUI/src/commonMain/composeResources/values-pt-rPT/strings.xml b/commonsUI/src/commonMain/composeResources/values-pt-rPT/strings.xml index b1953ebdcd..d722a0814b 100644 --- a/commonsUI/src/commonMain/composeResources/values-pt-rPT/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-pt-rPT/strings.xml @@ -223,7 +223,7 @@ <string name="copy_user_id">Copiar ID do usuário</string> <string name="unblock_user">Desbloquear Usuário</string> <string name="user_is_blocked_hidden">Este usuário está bloqueado/silenciado. Suas publicações estão ocultas.</string> - <string name="npub_hex_username">npub, hex, usuário </string> + <string name="npub_hex_username">npub, hex, usuário</string> <string name="clear">Limpar</string> <string name="app_logo">Logo do aplicativo</string> <string name="nsec_npub_hex_private_key">nsec / npub / hex chave privada</string> @@ -257,8 +257,8 @@ <string name="paid_relay">Retransmissor pago</string> <string name="tor_relay">Forçar uso do Tor ao conectar</string> <string name="posts_received">postagens recebidas</string> - <string name="translations_auto">Autotraduzido </string> - <string name="translations_translated_from"> de </string> + <string name="translations_auto">Autotraduzido</string> + <string name="translations_translated_from">de</string> <string name="translations_to">para</string> <string name="translations_show_in_lang_first">Mostrar em %1$s primeiro</string> <string name="chat_about_topic">Chat público sobre %1$s</string> @@ -271,13 +271,8 @@ <string name="mark_all_known_as_read">Marcar todos conhecidos como lidos</string> <string name="mark_all_new_as_read">Marcar todas novas solicitações como lidas</string> <string name="mark_all_as_read">Marcar todas como lidas</string> - <string name="account_backup_tips2_md"> ## Dicas de Backup de Chave e Segurança - \n\nSua conta é protegida por uma chave secreta. A chave é uma sequência longa de caracteres que começa com **nsec1**. Qualquer pessoa que tenha acesso a esta chave secreta pode postar e alterar sua identidade. - \n\n- **Não** coloque sua chave secreta em nenhum site ou software que você não confie. - \n- Os desenvolvedores da Amethyst **nunca** pedirão sua chave secreta. - \n- **Mantenha** uma cópia de segurança segura de sua chave secreta para recuperação da conta. Recomendamos o uso de um gerenciador de senhas.</string> - <string name="account_backup_tips3_md"> Para segurança adicional, você pode criptografar sua chave com uma senha. Esta chave começa com **ncryptsec1** e não pode ser usada sem sua senha. - \n\nSe você perder sua senha, não será possível recuperar sua chave.</string> + <string name="account_backup_tips2_md">## Dicas de Backup de Chave e Segurança \n\nSua conta é protegida por uma chave secreta. A chave é uma sequência longa de caracteres que começa com **nsec1**. Qualquer pessoa que tenha acesso a esta chave secreta pode postar e alterar sua identidade. \n\n- **Não** coloque sua chave secreta em nenhum site ou software que você não confie. \n- Os desenvolvedores da Amethyst **nunca** pedirão sua chave secreta. \n- **Mantenha** uma cópia de segurança segura de sua chave secreta para recuperação da conta. Recomendamos o uso de um gerenciador de senhas.</string> + <string name="account_backup_tips3_md">Para segurança adicional, você pode criptografar sua chave com uma senha. Esta chave começa com **ncryptsec1** e não pode ser usada sem sua senha. \n\nSe você perder sua senha, não será possível recuperar sua chave.</string> <string name="encrypt_and_copy_my_secret_key">Criptografar e copiar minha chave secreta</string> <string name="badge_award_image_for">Imagem da medalha para %1$s</string> <string name="badge_award_image">Imagem do prêmio</string> @@ -958,9 +953,7 @@ <string name="push_server_none_explainer">Desativa as Notificações Push</string> <string name="push_server_uses_app_explainer">Usa o aplicativo %1$s</string> <string name="push_server_install_app">Configuração de Notificações Push</string> - <string name="push_server_install_app_description"> Para receber notificações push, instale qualquer aplicativo que suporte [Unified Push](https://unifiedpush.org/), como [Ntfy](https://ntfy.sh/). - Após a instalação, selecione o aplicativo que deseja usar nas configurações. - </string> + <string name="push_server_install_app_description">Para receber notificações push, instale qualquer aplicativo que suporte [Unified Push](https://unifiedpush.org/), como [Ntfy](https://ntfy.sh/). Após a instalação, selecione o aplicativo que deseja usar nas configurações.</string> <string name="thread_title">Linha</string> <string name="send_the_seller_a_message">Envie uma mensagem para o vendedor</string> <string name="hi_there_is_this_still_available">Olá, ainda está disponível?</string> @@ -2003,14 +1996,7 @@ <string name="could_not_fetch_invoice_from_details">Não foi possível obter a fatura de %1$s: %2$s</string> <string name="could_not_open_the_compressed_file">Não foi possível abrir o arquivo compactado</string> <string name="couldnt_find_nwc_wallets">Carteira não encontrada</string> - <string name="couldnt_find_nwc_wallets_description">O Amethyst não conseguiu encontrar nenhum aplicativo de carteira que suporte Nostr Wallet Connect (NWC). -\n\nO que você pode fazer: -\n\t1. Verifique o suporte NWC: Confirme se o aplicativo da sua carteira é compatível com o Nostr Wallet Connect. -\n\t2. Criar conexão: Vá até o aplicativo da sua carteira e crie uma nova conexão NWC. -\n\t3. Vincular ao Amethyst: Sua carteira geralmente oferece três opções: -\n\t\t3.1 Link Automático: Um botão para abrir e conectar automaticamente ao Amethyst. Clique nele, abra o Amethyst e salve na nova tela. -\n\t\t3.2 Link Manual: Um URI de conexão que você pode copiar e colar usando o ícone de colar nesta tela. -\n\t\t3.3 Código QR: A carteira gera um código QR que você pode ler usando o botão de código QR nesta tela.</string> + <string name="couldnt_find_nwc_wallets_description">O Amethyst não conseguiu encontrar nenhum aplicativo de carteira que suporte Nostr Wallet Connect (NWC). \n\nO que você pode fazer: \n\t1. Verifique o suporte NWC: Confirme se o aplicativo da sua carteira é compatível com o Nostr Wallet Connect. \n\t2. Criar conexão: Vá até o aplicativo da sua carteira e crie uma nova conexão NWC. \n\t3. Vincular ao Amethyst: Sua carteira geralmente oferece três opções: \n\t\t3.1 Link Automático: Um botão para abrir e conectar automaticamente ao Amethyst. Clique nele, abra o Amethyst e salve na nova tela. \n\t\t3.2 Link Manual: Um URI de conexão que você pode copiar e colar usando o ícone de colar nesta tela. \n\t\t3.3 Código QR: A carteira gera um código QR que você pode ler usando o botão de código QR nesta tela.</string> <string name="create">Criar</string> <string name="danger_zone">Zona de Perigo</string> <string name="dark">Escuro</string> @@ -2662,11 +2648,8 @@ <string name="proxy_section">Relays proxy</string> <string name="proxy_section_explainer">Relays agregadores que o app usa para baixar seus feeds, como filter.nostr.wine. Isso substitui o modelo outbox e faz o app se conectar apenas aos relays das suas listas.</string> <string name="public_chat">Chat público</string> - <string name="public_chat_explainer">Os chats públicos são visíveis para todos no Nostr, e qualquer pessoa - pode participar deles. Eles são ótimos para comunidades abertas em torno de tópicos específicos. - A moderação pode ser controlada excluindo postagens em seus relays</string> - <string name="public_chat_relays_explainer">Inserir entre 1–3 relés que hospedam esse grupo. - os clientes Nostr usam essa configuração para saber de onde fazer o download de mensagens e enviar suas mensagens.</string> + <string name="public_chat_explainer">Os chats públicos são visíveis para todos no Nostr, e qualquer pessoa pode participar deles. Eles são ótimos para comunidades abertas em torno de tópicos específicos. A moderação pode ser controlada excluindo postagens em seus relays</string> + <string name="public_chat_relays_explainer">Inserir entre 1–3 relés que hospedam esse grupo. os clientes Nostr usam essa configuração para saber de onde fazer o download de mensagens e enviar suas mensagens.</string> <string name="public_chat_relays_title">Relés</string> <string name="public_chat_title">Metadados do Chat Público</string> <string name="public_chats">Chats públicos</string> @@ -2691,10 +2674,7 @@ <string name="read_only_user">Usuário somente leitura</string> <string name="record_a_message_description">Clique e segure para gravar uma mensagem</string> <string name="relay_chat">Chat Relé</string> - <string name="relay_chat_explainer">Chat Relés são grupos de chat controlados por seu relé de origem. - Eles são visíveis para todos no Nostr e qualquer pessoa pode participar. - São ótimos para comunidades abertas em torno de tópicos específicos. Alguns desses grupos são efêmeros, - portanto, as mensagens desaparecem com o tempo</string> + <string name="relay_chat_explainer">Chat Relés são grupos de chat controlados por seu relé de origem. Eles são visíveis para todos no Nostr e qualquer pessoa pode participar. São ótimos para comunidades abertas em torno de tópicos específicos. Alguns desses grupos são efêmeros, portanto, as mensagens desaparecem com o tempo</string> <string name="relay_chat_title">Chat Relés</string> <string name="relay_management_tab_events">Eventos</string> <string name="relay_management_tab_kinds">Tipos</string> diff --git a/commonsUI/src/commonMain/composeResources/values-ru-rRU/strings.xml b/commonsUI/src/commonMain/composeResources/values-ru-rRU/strings.xml index 42c411c558..d89352fd93 100644 --- a/commonsUI/src/commonMain/composeResources/values-ru-rRU/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-ru-rRU/strings.xml @@ -226,7 +226,7 @@ <string name="copy_user_id">Копировать ID пользователя</string> <string name="unblock_user">Разблокировать</string> <string name="user_is_blocked_hidden">Этот пользователь заблокирован/заглушён. Его посты скрыты.</string> - <string name="npub_hex_username">npub, hex, никнейм </string> + <string name="npub_hex_username">npub, hex, никнейм</string> <string name="clear">Очистить</string> <string name="app_logo">Логотип приложения</string> <string name="nsec_npub_hex_private_key">nsec / npub / приватный ключ в hex</string> @@ -274,15 +274,8 @@ <string name="mark_all_known_as_read">Отметить все известные прочитанными</string> <string name="mark_all_new_as_read">Отметить все новые прочитанными</string> <string name="mark_all_as_read">Отметить всё прочитанным</string> - <string name="account_backup_tips2_md"> ## Советы по резервному копированию и безопасности ключей - \n\nВаш аккаунт защищён секретным ключом. Ключ — это длинная последовательность символов, начинающаяся с **nsec1**. Любой, кто получит доступ к этому секретному ключу, сможет публиковать записи и менять вашу личность. - \n\n- **Не** вводите свой секретный ключ на сайтах или в приложениях, которым не доверяете. - \n- Разработчики Amethyst **никогда** не будут запрашивать ваш секретный ключ. - \n- **Обязательно** сохраните надёжную резервную копию секретного ключа для восстановления аккаунта. Рекомендуем использовать менеджер паролей. - </string> - <string name="account_backup_tips3_md"> Для дополнительной безопасности вы можете зашифровать ваш ключ с помощью пароля. Этот ключ начинается с **ncryptsec1** и не может быть использован без пароля. - \n\nЕсли вы потеряете свой пароль, вы не сможете восстановить ваш ключ. - </string> + <string name="account_backup_tips2_md">## Советы по резервному копированию и безопасности ключей \n\nВаш аккаунт защищён секретным ключом. Ключ — это длинная последовательность символов, начинающаяся с **nsec1**. Любой, кто получит доступ к этому секретному ключу, сможет публиковать записи и менять вашу личность. \n\n- **Не** вводите свой секретный ключ на сайтах или в приложениях, которым не доверяете. \n- Разработчики Amethyst **никогда** не будут запрашивать ваш секретный ключ. \n- **Обязательно** сохраните надёжную резервную копию секретного ключа для восстановления аккаунта. Рекомендуем использовать менеджер паролей.</string> + <string name="account_backup_tips3_md">Для дополнительной безопасности вы можете зашифровать ваш ключ с помощью пароля. Этот ключ начинается с **ncryptsec1** и не может быть использован без пароля. \n\nЕсли вы потеряете свой пароль, вы не сможете восстановить ваш ключ.</string> <string name="encrypt_and_copy_my_secret_key">Зашифровать и копировать мой секретный ключ</string> <string name="badge_award_image_for">Картинка значка-награды %1$s</string> <string name="badge_award_image">Изображение награды за значок</string> @@ -970,9 +963,7 @@ <string name="push_server_none_explainer">Отключает push-уведомления</string> <string name="push_server_uses_app_explainer">Использует приложение %1$s</string> <string name="push_server_install_app">Настройки push-уведомлений</string> - <string name="push_server_install_app_description"> Для получения push-уведомлений, установите приложение, которое поддерживает [Unified Push](https://unifiedpush.org/), например [Ntfy](https://ntfy.sh/). - После установки выберите приложение, которое хотите использовать, в настройках. - </string> + <string name="push_server_install_app_description">Для получения push-уведомлений, установите приложение, которое поддерживает [Unified Push](https://unifiedpush.org/), например [Ntfy](https://ntfy.sh/). После установки выберите приложение, которое хотите использовать, в настройках.</string> <string name="thread_title">Ветка</string> <string name="send_the_seller_a_message">Написать продавцу</string> <string name="hi_there_is_this_still_available">Привет, это ещё в наличии?</string> @@ -2046,15 +2037,7 @@ <string name="could_not_fetch_invoice_from_details">Не удалось получить счёт от %1$s: %2$s</string> <string name="could_not_open_the_compressed_file">Не удалось открыть сжатый файл</string> <string name="couldnt_find_nwc_wallets">Кошелёк не найден</string> - <string name="couldnt_find_nwc_wallets_description">Amethyst не нашёл ни одного приложения кошелька с поддержкой Nostr Wallet Connect (NWC). -\n\nЧто можно сделать: -\n\t1. Проверьте поддержку NWC: убедитесь, что ваш кошелёк совместим с Nostr Wallet Connect. -\n\t2. Создайте подключение: откройте приложение кошелька и создайте новое NWC-подключение. -\n\t3. Привяжите к Amethyst: обычно кошелёк предлагает три варианта: -\n\t\t3.1 Автоматическая привязка: кнопка для автоматического открытия и подключения к Amethyst. Нажмите её, откройте Amethyst и сохраните новый экран. -\n\t\t3.2 Ручная привязка: URI подключения, который можно скопировать и вставить с помощью значка вставки на этом экране. -\n\t\t3.3 QR-код: кошелёк создаёт QR-код, который можно считать кнопкой QR-кода на этом экране. - </string> + <string name="couldnt_find_nwc_wallets_description">Amethyst не нашёл ни одного приложения кошелька с поддержкой Nostr Wallet Connect (NWC). \n\nЧто можно сделать: \n\t1. Проверьте поддержку NWC: убедитесь, что ваш кошелёк совместим с Nostr Wallet Connect. \n\t2. Создайте подключение: откройте приложение кошелька и создайте новое NWC-подключение. \n\t3. Привяжите к Amethyst: обычно кошелёк предлагает три варианта: \n\t\t3.1 Автоматическая привязка: кнопка для автоматического открытия и подключения к Amethyst. Нажмите её, откройте Amethyst и сохраните новый экран. \n\t\t3.2 Ручная привязка: URI подключения, который можно скопировать и вставить с помощью значка вставки на этом экране. \n\t\t3.3 QR-код: кошелёк создаёт QR-код, который можно считать кнопкой QR-кода на этом экране.</string> <string name="create">Создать</string> <string name="d">д</string> <string name="danger_zone">Опасная зона</string> @@ -2737,11 +2720,8 @@ <string name="propose_an_edit">Предложить изменение</string> <string name="proxy_section_explainer">Агрегирующие relay-серверы, которые приложение использует для загрузки ваших лент, например filter.nostr.wine. Это заменяет модель outbox и позволит приложению подключаться только к relay-серверам из ваших списков.</string> <string name="public_chat">Публичный чат</string> - <string name="public_chat_explainer">Публичные чаты видны всем в Nostr и открыты - для участия любого желающего. Они отлично подходят для открытых сообществ по определённым темам. - Модерация осуществляется путём удаления постов на relay</string> - <string name="public_chat_relays_explainer">Укажите от 1 до 3 relay, на которых размещена эта группа. - Клиенты Nostr используют эту настройку, чтобы знать, откуда загружать сообщения и куда их отправлять.</string> + <string name="public_chat_explainer">Публичные чаты видны всем в Nostr и открыты для участия любого желающего. Они отлично подходят для открытых сообществ по определённым темам. Модерация осуществляется путём удаления постов на relay</string> + <string name="public_chat_relays_explainer">Укажите от 1 до 3 relay, на которых размещена эта группа. Клиенты Nostr используют эту настройку, чтобы знать, откуда загружать сообщения и куда их отправлять.</string> <string name="public_chat_relays_title">Relay</string> <string name="public_chat_title">Метаданные публичного чата</string> <string name="public_chats">Публичные чаты</string> @@ -2766,10 +2746,7 @@ <string name="read_only_user">Пользователь только для чтения</string> <string name="record_a_message_description">Нажмите и удерживайте, чтобы записать сообщение</string> <string name="relay_chat">Чат relay</string> - <string name="relay_chat_explainer">Чаты relay — это групповые чаты, управляемые их домашним relay. - Они видны всем в Nostr, и любой может в них участвовать. - Они отлично подходят для открытых сообществ по определённым темам. Некоторые группы эфемерны, - поэтому сообщения исчезают со временем</string> + <string name="relay_chat_explainer">Чаты relay — это групповые чаты, управляемые их домашним relay. Они видны всем в Nostr, и любой может в них участвовать. Они отлично подходят для открытых сообществ по определённым темам. Некоторые группы эфемерны, поэтому сообщения исчезают со временем</string> <string name="relay_chat_title">Чаты relay</string> <string name="relay_management_tab_events">События</string> <string name="relay_management_tab_ips">IP-адреса</string> diff --git a/commonsUI/src/commonMain/composeResources/values-ru-rUA/strings.xml b/commonsUI/src/commonMain/composeResources/values-ru-rUA/strings.xml index f88269516e..4b2c8833b7 100644 --- a/commonsUI/src/commonMain/composeResources/values-ru-rUA/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-ru-rUA/strings.xml @@ -267,15 +267,8 @@ <string name="mark_all_known_as_read">Отметить все известные как прочитанные</string> <string name="mark_all_new_as_read">Отметить все новые как прочитанные</string> <string name="mark_all_as_read">Отметить все как прочитанные</string> - <string name="account_backup_tips2_md"> ## Советы по резервному копированию и безопасности ключей - \n\nВаш аккаунт защищён секретным ключом. Ключ — это длинная последовательность символов, начинающаяся с **nsec1**. Любой, кто получит доступ к этому секретному ключу, сможет публиковать записи и менять вашу личность. - \n\n- **Не** вводите свой секретный ключ на сайтах или в приложениях, которым не доверяете. - \n- Разработчики Amethyst **никогда** не будут запрашивать ваш секретный ключ. - \n- **Обязательно** сохраните надёжную резервную копию секретного ключа для восстановления аккаунта. Рекомендуем использовать менеджер паролей. - </string> - <string name="account_backup_tips3_md"> Для дополнительной безопасности вы можете зашифровать ваш ключ паролем. Этот ключ начинается с **ncryptsec1** и не может быть использован без вашего пароля. - \n\nЕсли вы потеряете пароль, вы не сможете восстановить ключ. - </string> + <string name="account_backup_tips2_md">## Советы по резервному копированию и безопасности ключей \n\nВаш аккаунт защищён секретным ключом. Ключ — это длинная последовательность символов, начинающаяся с **nsec1**. Любой, кто получит доступ к этому секретному ключу, сможет публиковать записи и менять вашу личность. \n\n- **Не** вводите свой секретный ключ на сайтах или в приложениях, которым не доверяете. \n- Разработчики Amethyst **никогда** не будут запрашивать ваш секретный ключ. \n- **Обязательно** сохраните надёжную резервную копию секретного ключа для восстановления аккаунта. Рекомендуем использовать менеджер паролей.</string> + <string name="account_backup_tips3_md">Для дополнительной безопасности вы можете зашифровать ваш ключ паролем. Этот ключ начинается с **ncryptsec1** и не может быть использован без вашего пароля. \n\nЕсли вы потеряете пароль, вы не сможете восстановить ключ.</string> <string name="encrypt_and_copy_my_secret_key">Зашифровать и скопировать мой секретный ключ</string> <string name="badge_award_image_for">Изображение награды для %1$s</string> <string name="badge_award_image">Изображение награды за значок</string> @@ -960,9 +953,7 @@ <string name="push_server_none_explainer">Отключает push-уведомления</string> <string name="push_server_uses_app_explainer">Использует приложение %1$s</string> <string name="push_server_install_app">Настройка push-уведомлений</string> - <string name="push_server_install_app_description"> Для получения push-уведомлений установите любое приложение, поддерживающее [Unified Push](https://unifiedpush.org/), например [Ntfy](https://ntfy.sh/). - После установки выберите нужное приложение в Настройках. - </string> + <string name="push_server_install_app_description">Для получения push-уведомлений установите любое приложение, поддерживающее [Unified Push](https://unifiedpush.org/), например [Ntfy](https://ntfy.sh/). После установки выберите нужное приложение в Настройках.</string> <string name="thread_title">Тред</string> <string name="send_the_seller_a_message">Написать продавцу</string> <string name="hi_there_is_this_still_available">Привет, это ещё в наличии?</string> @@ -2032,15 +2023,7 @@ <string name="could_not_fetch_invoice_from_details">Не удалось получить счёт от %1$s: %2$s</string> <string name="could_not_open_the_compressed_file">Не удалось открыть сжатый файл</string> <string name="couldnt_find_nwc_wallets">Кошелёк не найден</string> - <string name="couldnt_find_nwc_wallets_description">Amethyst не нашёл ни одного приложения кошелька с поддержкой Nostr Wallet Connect (NWC). -\n\nЧто можно сделать: -\n\t1. Проверьте поддержку NWC: убедитесь, что ваш кошелёк совместим с Nostr Wallet Connect. -\n\t2. Создайте подключение: откройте приложение кошелька и создайте новое NWC-подключение. -\n\t3. Привяжите к Amethyst: обычно кошелёк предлагает три варианта: -\n\t\t3.1 Автоматическая привязка: кнопка для автоматического открытия и подключения к Amethyst. Нажмите её, откройте Amethyst и сохраните новый экран. -\n\t\t3.2 Ручная привязка: URI подключения, который можно скопировать и вставить с помощью значка вставки на этом экране. -\n\t\t3.3 QR-код: кошелёк создаёт QR-код, который можно считать кнопкой QR-кода на этом экране. - </string> + <string name="couldnt_find_nwc_wallets_description">Amethyst не нашёл ни одного приложения кошелька с поддержкой Nostr Wallet Connect (NWC). \n\nЧто можно сделать: \n\t1. Проверьте поддержку NWC: убедитесь, что ваш кошелёк совместим с Nostr Wallet Connect. \n\t2. Создайте подключение: откройте приложение кошелька и создайте новое NWC-подключение. \n\t3. Привяжите к Amethyst: обычно кошелёк предлагает три варианта: \n\t\t3.1 Автоматическая привязка: кнопка для автоматического открытия и подключения к Amethyst. Нажмите её, откройте Amethyst и сохраните новый экран. \n\t\t3.2 Ручная привязка: URI подключения, который можно скопировать и вставить с помощью значка вставки на этом экране. \n\t\t3.3 QR-код: кошелёк создаёт QR-код, который можно считать кнопкой QR-кода на этом экране.</string> <string name="create">Создать</string> <string name="danger_zone">Опасная зона</string> <string name="dark">Тёмная</string> @@ -2719,11 +2702,8 @@ <string name="propose_an_edit">Предложить правку</string> <string name="proxy_section_explainer">Агрегирующие relay-серверы, которые приложение использует для загрузки ваших лент, например filter.nostr.wine. Это заменяет модель outbox и позволит приложению подключаться только к relay-серверам из ваших списков.</string> <string name="public_chat">Публичный чат</string> - <string name="public_chat_explainer">Публичные чаты видны всем в Nostr и открыты - для участия любого желающего. Они отлично подходят для открытых сообществ по определённым темам. - Модерация осуществляется путём удаления постов на relay</string> - <string name="public_chat_relays_explainer">Укажите от 1 до 3 relay, на которых размещена эта группа. - Клиенты Nostr используют эту настройку, чтобы знать, откуда загружать сообщения и куда их отправлять.</string> + <string name="public_chat_explainer">Публичные чаты видны всем в Nostr и открыты для участия любого желающего. Они отлично подходят для открытых сообществ по определённым темам. Модерация осуществляется путём удаления постов на relay</string> + <string name="public_chat_relays_explainer">Укажите от 1 до 3 relay, на которых размещена эта группа. Клиенты Nostr используют эту настройку, чтобы знать, откуда загружать сообщения и куда их отправлять.</string> <string name="public_chat_relays_title">Relay</string> <string name="public_chat_title">Метаданные публичного чата</string> <string name="public_chats">Публичные чаты</string> @@ -2748,10 +2728,7 @@ <string name="read_only_user">Пользователь только для чтения</string> <string name="record_a_message_description">Нажмите и удерживайте для записи сообщения</string> <string name="relay_chat">Чат relay</string> - <string name="relay_chat_explainer">Чаты relay — это групповые чаты, управляемые их домашним relay. - Они видны всем в Nostr, и любой может в них участвовать. - Они отлично подходят для открытых сообществ по определённым темам. Некоторые группы эфемерны, - поэтому сообщения исчезают со временем</string> + <string name="relay_chat_explainer">Чаты relay — это групповые чаты, управляемые их домашним relay. Они видны всем в Nostr, и любой может в них участвовать. Они отлично подходят для открытых сообществ по определённым темам. Некоторые группы эфемерны, поэтому сообщения исчезают со временем</string> <string name="relay_chat_title">Чаты relay</string> <string name="relay_management_tab_events">События</string> <string name="relay_management_tab_ips">IP-адреса</string> diff --git a/commonsUI/src/commonMain/composeResources/values-ru/strings.xml b/commonsUI/src/commonMain/composeResources/values-ru/strings.xml index 5a3a0ea0eb..9ceaec1f2a 100644 --- a/commonsUI/src/commonMain/composeResources/values-ru/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-ru/strings.xml @@ -215,7 +215,7 @@ <string name="copy_user_id">Копировать ID пользователя</string> <string name="unblock_user">Разблокировать</string> <string name="user_is_blocked_hidden">Этот пользователь заблокирован/заглушён. Его посты скрыты.</string> - <string name="npub_hex_username">npub, hex, никнейм </string> + <string name="npub_hex_username">npub, hex, никнейм</string> <string name="clear">Очистить</string> <string name="app_logo">Логотип приложения</string> <string name="nsec_npub_hex_private_key">nsec / npub / приватный ключ в hex</string> @@ -263,17 +263,8 @@ <string name="mark_all_known_as_read">Отметить все известные прочитанными</string> <string name="mark_all_new_as_read">Отметить все новые прочитанными</string> <string name="mark_all_as_read">Отметить всё прочитанным</string> - <string name="account_backup_tips2_md"> - ## Советы по резервному копированию и безопасности ключей - \n\nВаш аккаунт защищён секретным ключом. Ключ — это длинная последовательность символов, начинающаяся с **nsec1**. Любой, кто получит доступ к этому секретному ключу, сможет публиковать записи и менять вашу личность. - \n\n- **Не** вводите свой секретный ключ на сайтах или в приложениях, которым не доверяете. - \n- Разработчики Amethyst **никогда** не будут запрашивать ваш секретный ключ. - \n- **Обязательно** сохраните надёжную резервную копию секретного ключа для восстановления аккаунта. Рекомендуем использовать менеджер паролей. - </string> - <string name="account_backup_tips3_md"> - Для дополнительной безопасности вы можете зашифровать ваш ключ паролем. Этот ключ начинается с **ncryptsec1** и не может быть использован без вашего пароля. - \n\nЕсли вы потеряете пароль, вы не сможете восстановить ваш ключ. - </string> + <string name="account_backup_tips2_md">## Советы по резервному копированию и безопасности ключей \n\nВаш аккаунт защищён секретным ключом. Ключ — это длинная последовательность символов, начинающаяся с **nsec1**. Любой, кто получит доступ к этому секретному ключу, сможет публиковать записи и менять вашу личность. \n\n- **Не** вводите свой секретный ключ на сайтах или в приложениях, которым не доверяете. \n- Разработчики Amethyst **никогда** не будут запрашивать ваш секретный ключ. \n- **Обязательно** сохраните надёжную резервную копию секретного ключа для восстановления аккаунта. Рекомендуем использовать менеджер паролей.</string> + <string name="account_backup_tips3_md">Для дополнительной безопасности вы можете зашифровать ваш ключ паролем. Этот ключ начинается с **ncryptsec1** и не может быть использован без вашего пароля. \n\nЕсли вы потеряете пароль, вы не сможете восстановить ваш ключ.</string> <string name="encrypt_and_copy_my_secret_key">Зашифровать и скопировать секретный ключ</string> <string name="badge_award_image_for">Картинка значка-награды %1$s</string> <string name="badge_award_image">Изображение награды за значок</string> @@ -966,9 +957,7 @@ <string name="push_server_none_explainer">Отключает push-уведомления</string> <string name="push_server_uses_app_explainer">Использует приложение %1$s</string> <string name="push_server_install_app">Настройки push-уведомлений</string> - <string name="push_server_install_app_description"> Для получения push-уведомлений, установите приложение, которое поддерживает [Unified Push](https://unifiedpush.org/), например [Ntfy](https://ntfy.sh/). - После установки выберите приложение, которое хотите использовать, в настройках. - </string> + <string name="push_server_install_app_description">Для получения push-уведомлений, установите приложение, которое поддерживает [Unified Push](https://unifiedpush.org/), например [Ntfy](https://ntfy.sh/). После установки выберите приложение, которое хотите использовать, в настройках.</string> <string name="thread_title">Ветка</string> <string name="send_the_seller_a_message">Написать продавцу</string> <string name="hi_there_is_this_still_available">Привет, это ещё в наличии?</string> @@ -2077,15 +2066,7 @@ <string name="could_not_fetch_invoice_from_details">Не удалось получить счёт от %1$s: %2$s</string> <string name="could_not_open_the_compressed_file">Не удалось открыть сжатый файл</string> <string name="couldnt_find_nwc_wallets">Кошелёк не найден</string> - <string name="couldnt_find_nwc_wallets_description">Amethyst не нашёл ни одного приложения кошелька с поддержкой Nostr Wallet Connect (NWC). -\n\nЧто можно сделать: -\n\t1. Проверьте поддержку NWC: убедитесь, что ваш кошелёк совместим с Nostr Wallet Connect. -\n\t2. Создайте подключение: откройте приложение кошелька и создайте новое NWC-подключение. -\n\t3. Привяжите к Amethyst: обычно кошелёк предлагает три варианта: -\n\t\t3.1 Автоматическая привязка: кнопка для автоматического открытия и подключения к Amethyst. Нажмите её, откройте Amethyst и сохраните новый экран. -\n\t\t3.2 Ручная привязка: URI подключения, который можно скопировать и вставить с помощью значка вставки на этом экране. -\n\t\t3.3 QR-код: кошелёк создаёт QR-код, который можно считать кнопкой QR-кода на этом экране. - </string> + <string name="couldnt_find_nwc_wallets_description">Amethyst не нашёл ни одного приложения кошелька с поддержкой Nostr Wallet Connect (NWC). \n\nЧто можно сделать: \n\t1. Проверьте поддержку NWC: убедитесь, что ваш кошелёк совместим с Nostr Wallet Connect. \n\t2. Создайте подключение: откройте приложение кошелька и создайте новое NWC-подключение. \n\t3. Привяжите к Amethyst: обычно кошелёк предлагает три варианта: \n\t\t3.1 Автоматическая привязка: кнопка для автоматического открытия и подключения к Amethyst. Нажмите её, откройте Amethyst и сохраните новый экран. \n\t\t3.2 Ручная привязка: URI подключения, который можно скопировать и вставить с помощью значка вставки на этом экране. \n\t\t3.3 QR-код: кошелёк создаёт QR-код, который можно считать кнопкой QR-кода на этом экране.</string> <string name="create">Создать</string> <string name="d">д</string> <string name="danger_zone">Опасная зона</string> @@ -2815,11 +2796,8 @@ <string name="proxy_section">Proxy Relays</string> <string name="proxy_section_explainer">Агрегирующие relay-серверы, которые приложение использует для загрузки ваших лент, например filter.nostr.wine. Это заменяет модель outbox и позволит приложению подключаться только к relay-серверам из ваших списков.</string> <string name="public_chat">Публичный чат</string> - <string name="public_chat_explainer">Публичные чаты видны всем в Nostr и открыты - для участия любого желающего. Они отлично подходят для открытых сообществ по определённым темам. - Модерация осуществляется путём удаления постов на relay</string> - <string name="public_chat_relays_explainer">Укажите от 1 до 3 relay, на которых размещена эта группа. - Клиенты Nostr используют эту настройку, чтобы знать, откуда загружать сообщения и куда их отправлять.</string> + <string name="public_chat_explainer">Публичные чаты видны всем в Nostr и открыты для участия любого желающего. Они отлично подходят для открытых сообществ по определённым темам. Модерация осуществляется путём удаления постов на relay</string> + <string name="public_chat_relays_explainer">Укажите от 1 до 3 relay, на которых размещена эта группа. Клиенты Nostr используют эту настройку, чтобы знать, откуда загружать сообщения и куда их отправлять.</string> <string name="public_chat_relays_title">Relay</string> <string name="public_chat_title">Метаданные публичного чата</string> <string name="public_chats">Публичные чаты</string> @@ -2844,10 +2822,7 @@ <string name="read_only_user">Пользователь только для чтения</string> <string name="record_a_message_description">Нажмите и удерживайте для записи сообщения</string> <string name="relay_chat">Чат relay</string> - <string name="relay_chat_explainer">Чаты relay — это групповые чаты, управляемые их домашним relay. - Они видны всем в Nostr, и любой может в них участвовать. - Они отлично подходят для открытых сообществ по определённым темам. Некоторые группы эфемерны, - поэтому сообщения исчезают со временем</string> + <string name="relay_chat_explainer">Чаты relay — это групповые чаты, управляемые их домашним relay. Они видны всем в Nostr, и любой может в них участвовать. Они отлично подходят для открытых сообществ по определённым темам. Некоторые группы эфемерны, поэтому сообщения исчезают со временем</string> <string name="relay_chat_title">Чаты relay</string> <string name="relay_management_tab_events">События</string> <string name="relay_management_tab_ips">IP-адреса</string> diff --git a/commonsUI/src/commonMain/composeResources/values-sl-rSI/strings.xml b/commonsUI/src/commonMain/composeResources/values-sl-rSI/strings.xml index 2f5ff233bb..8ae6e95125 100644 --- a/commonsUI/src/commonMain/composeResources/values-sl-rSI/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-sl-rSI/strings.xml @@ -472,16 +472,8 @@ <string name="mark_all_known_as_read">Označi vse znane kot prebrane</string> <string name="mark_all_new_as_read">Označi vse nove kot prebrane</string> <string name="mark_all_as_read">Označi vse kot prebrane</string> - <string name="account_backup_tips2_md"> - ## Varnostna kopija ključev in varnostni nasveti - \n\nVaš račun je zaščiten z privatnim ključem. Ta ključ ima dolgo sekvenco znakov kateri se začnejo z **nsec1**. Kdorkoli ima dostop, do tega privatnega ključa, lahko pošiljajo zapiske in spremenijo vašo digitalno identiteto. - \n\n- Nikoli in **Nikdar** ne vpisuj svojega privatnega ključa v spletne strani in aplikacije katerim ne zaupaš. - \n- Amethyst ustvarjalci vas ne bojo **nikoli** vprašali po vašem privatnem ključu. - \n- Ustvari **varnostno kopijo** svojega privatnega ključa, za obnovitev računa. Priporoča se uporaba upravljalnika gesel. - </string> - <string name="account_backup_tips3_md"> Za dodatno varnost, lahko šifriraš svoj ključ z dodatnim geslom. Ta ključ se začne z **ncryptsec1** in ne more bit uporabljen brez dodatnega gesla. - \n\nČe izgubiš geslo, obnova ključa ni več mogoča. - </string> + <string name="account_backup_tips2_md">## Varnostna kopija ključev in varnostni nasveti \n\nVaš račun je zaščiten z privatnim ključem. Ta ključ ima dolgo sekvenco znakov kateri se začnejo z **nsec1**. Kdorkoli ima dostop, do tega privatnega ključa, lahko pošiljajo zapiske in spremenijo vašo digitalno identiteto. \n\n- Nikoli in **Nikdar** ne vpisuj svojega privatnega ključa v spletne strani in aplikacije katerim ne zaupaš. \n- Amethyst ustvarjalci vas ne bojo **nikoli** vprašali po vašem privatnem ključu. \n- Ustvari **varnostno kopijo** svojega privatnega ključa, za obnovitev računa. Priporoča se uporaba upravljalnika gesel.</string> + <string name="account_backup_tips3_md">Za dodatno varnost, lahko šifriraš svoj ključ z dodatnim geslom. Ta ključ se začne z **ncryptsec1** in ne more bit uporabljen brez dodatnega gesla. \n\nČe izgubiš geslo, obnova ključa ni več mogoča.</string> <string name="backup_keys_nudge_title">Varno shrani ključe</string> <string name="backup_keys_nudge_body">Vaš zasebni ključ je edini način za dostop do tega računa. Če ga izgubite, ga ne boste mogli več obnoviti. Shranite ga na varno mesto.</string> <string name="backup_keys_nudge_backup_now">Varno shrani zdaj</string> @@ -1593,9 +1585,7 @@ <string name="push_server_none_explainer">Onemogoči potisna obvestila</string> <string name="push_server_uses_app_explainer">Uporablja aplikacijo %1$s</string> <string name="push_server_install_app">Nastavitev potisnih obvestil</string> - <string name="push_server_install_app_description"> Za prejemanje potisnih obvestil namestite aplikacijo, ki podpira [Unified Push](https://unifiedpush.org/), na primer [Ntfy](https://ntfy.sh/). - Po namestitvi izberite aplikacijo, ki jo želite uporabljati, v nastavitvah. - </string> + <string name="push_server_install_app_description">Za prejemanje potisnih obvestil namestite aplikacijo, ki podpira [Unified Push](https://unifiedpush.org/), na primer [Ntfy](https://ntfy.sh/). Po namestitvi izberite aplikacijo, ki jo želite uporabljati, v nastavitvah.</string> <string name="thread_title">Niz objav</string> <string name="send_the_seller_a_message">Pošlji prodajalcu sporočilo</string> <string name="hi_there_is_this_still_available">Živjo, je to še na voljo?</string> @@ -1856,7 +1846,7 @@ <string name="calendar_day_a11y_today_suffix">danes</string> <string name="calendar_day_a11y_selected_suffix">izbrano</string> <string name="calendar_fab_new_event">Ustvari nov koledarski dogodek</string> - <string name="calendar_fab_new_collection">Ustvari novo zbirko koledarjev </string> + <string name="calendar_fab_new_collection">Ustvari novo zbirko koledarjev</string> <string name="calendar_fab_toggle">Ustvari novo zbirko koledarjev</string> <string name="calendar_untitled">(neimenovano)</string> <string name="calendar_all_day">Ves dan</string> @@ -2042,7 +2032,7 @@ <string name="buzz_dm_more">Več</string> <string name="buzz_dm_add_member">Dodaj člana</string> <string name="buzz_dm_add_member_title">Dodaj nekoga v to ZS</string> - <string name="buzz_dm_add_member_invalid">Neveljaven npub ali hex ključ </string> + <string name="buzz_dm_add_member_invalid">Neveljaven npub ali hex ključ</string> <string name="buzz_dm_just_you">Samo vi</string> <string name="buzz_dm_workspace">Delovni prostor</string> <string name="buzz_dm_recipients">Za</string> @@ -2091,8 +2081,7 @@ <string name="dm_relays_not_found">Nastavite releje za predal zasebnih sporočil</string> <string name="dm_relays_not_found_description">Ta nastavitev omogoča vsem, da vedo, katere releje uporabiti, ko vam pošiljajo sporočila. Brez njih bi lahko zgrešili nekatera sporočila.</string> <string name="dm_relays_not_found_examples2">Dobre možnosti so:\n - auth.nostr1.com (brezplačen)\n - inbox.nostr.wine (plačljiv)\n - relay.0xchat.com (brezplačen)</string> - <string name="dm_relays_not_found_editing">Vnesite 1–3 releje, ki bodo služili kot vaš predal - za zasebna sporočila. Releji za zasebna sporočila bi morali sprejeti katero koli sporočilo od kogar koli, vendar samo vam dovoliti njihov prenos.</string> + <string name="dm_relays_not_found_editing">Vnesite 1–3 releje, ki bodo služili kot vaš predal za zasebna sporočila. Releji za zasebna sporočila bi morali sprejeti katero koli sporočilo od kogar koli, vendar samo vam dovoliti njihov prenos.</string> <string name="dm_relays_not_found_create_now">Nastavi zdaj</string> <string name="recipient_missing_dm_relays">Releji za prejeto pošto (ZS) niso bili najdeni. Sporočil ni mogoče dostaviti, dokler uporabnik ne nastavi seznama svojih relejev.</string> <string name="search_relays_not_found">Nastavi iskalne releje</string> @@ -2339,7 +2328,7 @@ <string name="new_chess_game">Nova partija šaha</string> <string name="chess_loading_game">Nalagam partijo\u2026</string> <string name="chess_game_not_found">Partija ni najdena</string> - <string name="chess_game_waiting">Ta partija je zaključena ali pa, čaka na nasprotnika </string> + <string name="chess_game_waiting">Ta partija je zaključena ali pa, čaka na nasprotnika</string> <string name="chess_game_id">ID Partije: %1$s\u2026</string> <string name="badge_and_n_others"> in %1$d ostalih</string> <string name="remove_language">Odstrani %1$s</string> @@ -2664,9 +2653,7 @@ committed the change, %2$s the member it concerns, %3$s the new name. --> <string name="marmot_remove_photo">Odstrani fotografijo</string> <string name="marmot_keypackage_relays_not_set_title">KeyPackage releji niso nastavljeni</string> - <string name="marmot_keypackage_relays_not_set_message">Še nimate seznama relejev za KeyPackage (MIP-00). Ta seznam drugim sporoča, kje je objavljen vaš KeyPackage, da vas lahko povabijo v skupinske klepete. - -Ali želite za to uporabiti trenutne releje iz odhodnega predala?</string> + <string name="marmot_keypackage_relays_not_set_message">Še nimate seznama relejev za KeyPackage (MIP-00). Ta seznam drugim sporoča, kje je objavljen vaš KeyPackage, da vas lahko povabijo v skupinske klepete. Ali želite za to uporabiti trenutne releje iz odhodnega predala?</string> <string name="marmot_use_outbox_relays">Uporabi odhodne releje</string> <string name="marmot_skip_for_now">Zaenkrat preskoči</string> <string name="marmot_group_info_title">Podatki o skupini</string> @@ -3337,15 +3324,7 @@ Ali želite za to uporabiti trenutne releje iz odhodnega predala?</string> <string name="could_not_fetch_invoice_from_details">Ni bilo mogoče pridobiti fakture od %1$s: %2$s</string> <string name="could_not_open_the_compressed_file">Stisnjene datoteke ni bilo mogoče odpreti</string> <string name="couldnt_find_nwc_wallets">Ne najdem denarnice</string> - <string name="couldnt_find_nwc_wallets_description">Amethyst ni našel nobene denarnice, ki podpira Nostr Wallet Connect (NWC). -\n\nKaj lahko storiš: -\n\t1. Preverite podporo za NWC: Ponovno\t preverite, ali vaša priljubljena denarnica podpira Nostr Wallet Connect. -\n\t2. Ustvari povezavo: Odprite svojo denarnico in ustvarite novo NWC povezavo. -\n\t3. Povežite z Amethyst: Vaša denarnica vam bo ponavadi ponudila tri možnosti: -\n\t\t3.1 Samodejna povezava: Gumb za samodejno odprtje in povezavo z Amethyst. Če je na voljo, kliknite ta gumb, odprite Amethyst in na novem zaslonu pritisnite shrani. -\n\t\tRočna povezava: Povezovalni URI, ki ga lahko kopirate, nato pa na tem zaslonu kliknite ikono za lepljenje, da ga neposredno vnesete. -\n\t\tQR koda: Denarnica ustvari QR-kodo, ki jo skenirajte z gumbom za QR na tem zaslonu. - </string> + <string name="couldnt_find_nwc_wallets_description">Amethyst ni našel nobene denarnice, ki podpira Nostr Wallet Connect (NWC). \n\nKaj lahko storiš: \n\t1. Preverite podporo za NWC: Ponovno\t preverite, ali vaša priljubljena denarnica podpira Nostr Wallet Connect. \n\t2. Ustvari povezavo: Odprite svojo denarnico in ustvarite novo NWC povezavo. \n\t3. Povežite z Amethyst: Vaša denarnica vam bo ponavadi ponudila tri možnosti: \n\t\t3.1 Samodejna povezava: Gumb za samodejno odprtje in povezavo z Amethyst. Če je na voljo, kliknite ta gumb, odprite Amethyst in na novem zaslonu pritisnite shrani. \n\t\tRočna povezava: Povezovalni URI, ki ga lahko kopirate, nato pa na tem zaslonu kliknite ikono za lepljenje, da ga neposredno vnesete. \n\t\tQR koda: Denarnica ustvari QR-kodo, ki jo skenirajte z gumbom za QR na tem zaslonu.</string> <string name="create">Ustvari</string> <string name="d">d</string> <string name="danger_zone">Nevarno območje</string> @@ -3804,26 +3783,16 @@ Ali želite za to uporabiti trenutne releje iz odhodnega predala?</string> <string name="location_change_place">Spremeni lokacijo na karti</string> <string name="location_channels">Lokacijski kanali</string> <string name="location_pick_on_map">Izberi lokacijo na karti</string> - <string name="login_with_a_private_key_to_be_able_to_boost_posts">Uporabljaš javni ključ in javni ključi omogočajo le branje. -Prijavi se s privatnim ključem za posredovanje sporočila</string> - <string name="login_with_a_private_key_to_be_able_to_follow">Uporabljaš javni ključ in javni ključi omogočajo le branje. -Prijavi se s privatnim ključem, za sledenje uporabnika</string> - <string name="login_with_a_private_key_to_be_able_to_hide_word">Uporabljaš javni ključ in javni ključi omogočajo le branje. -Prijavi se s privatnim ključem, za cenzuro besed ali stavkov</string> - <string name="login_with_a_private_key_to_be_able_to_reply">Uporabljaš javni ključ in javni ključi omogočajo le branje. -Prijavi se s privatnim ključem, da omogočiš tudi pisanje</string> - <string name="login_with_a_private_key_to_be_able_to_send_zaps">Uporabljaš javni ključ in javni ključi omogočajo le branje. -Prijavite se s privatnim ključem, da boste lahko pošiljali Zape</string> - <string name="login_with_a_private_key_to_be_able_to_show_word">Uporabljaš javni ključ in javni ključi omogočajo le branje. -Prijavi se s privatnim ključem, za prikaz skritih besed in stavkov</string> - <string name="login_with_a_private_key_to_be_able_to_sign_events">Uporabljaš javni ključ in javni ključi omogočajo le branje. -Za podpisovanje se je potrebno prijaviti s privatnim ključem</string> - <string name="login_with_a_private_key_to_be_able_to_unfollow">Uporabljaš javni ključ in javni ključi omogočajo le branje. -Prijavi se s privatnim ključem, za odstranitev iz seznama sledljivih</string> - <string name="login_with_a_private_key_to_be_able_to_upload">Uporabljaš javni ključ in javni ključi omogočajo le branje. -Za nalaganje se je potrebno prijaviti s privatnim ključem</string> - <string name="login_with_a_private_key_to_like_posts">Uporabljaš javni ključ in javni ključi omogočajo le branje. -Prijavi se s privatnim ključem za všečkanje sporočila</string> + <string name="login_with_a_private_key_to_be_able_to_boost_posts">Uporabljaš javni ključ in javni ključi omogočajo le branje. Prijavi se s privatnim ključem za posredovanje sporočila</string> + <string name="login_with_a_private_key_to_be_able_to_follow">Uporabljaš javni ključ in javni ključi omogočajo le branje. Prijavi se s privatnim ključem, za sledenje uporabnika</string> + <string name="login_with_a_private_key_to_be_able_to_hide_word">Uporabljaš javni ključ in javni ključi omogočajo le branje. Prijavi se s privatnim ključem, za cenzuro besed ali stavkov</string> + <string name="login_with_a_private_key_to_be_able_to_reply">Uporabljaš javni ključ in javni ključi omogočajo le branje. Prijavi se s privatnim ključem, da omogočiš tudi pisanje</string> + <string name="login_with_a_private_key_to_be_able_to_send_zaps">Uporabljaš javni ključ in javni ključi omogočajo le branje. Prijavite se s privatnim ključem, da boste lahko pošiljali Zape</string> + <string name="login_with_a_private_key_to_be_able_to_show_word">Uporabljaš javni ključ in javni ključi omogočajo le branje. Prijavi se s privatnim ključem, za prikaz skritih besed in stavkov</string> + <string name="login_with_a_private_key_to_be_able_to_sign_events">Uporabljaš javni ključ in javni ključi omogočajo le branje. Za podpisovanje se je potrebno prijaviti s privatnim ključem</string> + <string name="login_with_a_private_key_to_be_able_to_unfollow">Uporabljaš javni ključ in javni ključi omogočajo le branje. Prijavi se s privatnim ključem, za odstranitev iz seznama sledljivih</string> + <string name="login_with_a_private_key_to_be_able_to_upload">Uporabljaš javni ključ in javni ključi omogočajo le branje. Za nalaganje se je potrebno prijaviti s privatnim ključem</string> + <string name="login_with_a_private_key_to_like_posts">Uporabljaš javni ključ in javni ključi omogočajo le branje. Prijavi se s privatnim ključem za všečkanje sporočila</string> <string name="longs">Videoposnetki</string> <string name="m">m</string> <string name="manage_emoji_packs">Moji emoji paketi</string> @@ -4122,7 +4091,7 @@ Prijavi se s privatnim ključem za všečkanje sporočila</string> <string name="new_conversation_public_chat_con_2">Brez moderacije</string> <string name="new_conversation_public_chat_cta">Ustvari nov javen klepet</string> <string name="new_conversation_public_chat_pro_1">Pridruži se lahko kdorkoli</string> - <string name="new_conversation_public_chat_pro_2">Preprosto in zlahka dosegljivo </string> + <string name="new_conversation_public_chat_pro_2">Preprosto in zlahka dosegljivo</string> <string name="new_conversation_public_chat_tagline">Javna sporočila na releju</string> <string name="new_conversation_public_chat_title">Javni klepet</string> <string name="new_conversation_relay_group_best">Skupine, ki jih moderira njihov gostiteljski rele</string> @@ -4531,11 +4500,8 @@ Prijavi se s privatnim ključem za všečkanje sporočila</string> <string name="proxy_section">Proxy releji</string> <string name="proxy_section_explainer">Agregatorji, ki jih mora aplikacija uporabljati za prenos vaših vsebin, na primer filter.nostr.wine. To nadomesti model odhodne pošte (outbox model) in bo prisililo aplikacijo k povezovanju prek relejev iz vašega seznama.</string> <string name="public_chat">Javni pogovor</string> - <string name="public_chat_explainer">Javni klepeti so vidni vsem na Nostru in vsakdo - se lahko pridruži. So odlični za odprte skupnosti o specifičnih temah. - Moderiranje je možno z brisanjem objav na njihovih relejih</string> - <string name="public_chat_relays_explainer">Vnesi 1–3 releje, ki gostijo to skupino. - Nostr odjemalci uporabljajo to nastavitev, da vedo, od kod prenesti vaša sporočila in kam jih poslati.</string> + <string name="public_chat_explainer">Javni klepeti so vidni vsem na Nostru in vsakdo se lahko pridruži. So odlični za odprte skupnosti o specifičnih temah. Moderiranje je možno z brisanjem objav na njihovih relejih</string> + <string name="public_chat_relays_explainer">Vnesi 1–3 releje, ki gostijo to skupino. Nostr odjemalci uporabljajo to nastavitev, da vedo, od kod prenesti vaša sporočila in kam jih poslati.</string> <string name="public_chat_relays_title">Releji</string> <string name="public_chat_title">Metapodatki Javnega Klepeta</string> <string name="public_chats">Javni klepeti</string> @@ -4595,10 +4561,7 @@ Prijavi se s privatnim ključem za všečkanje sporočila</string> <string name="relay_auth_search_keywords">Preverjanje pristnosti (Auth), rele (Relay), podpis (Sign), preverjanje (Verify), NIP-42, identiteta</string> <string name="relay_auth_settings_title">Avtentikacija releja</string> <string name="relay_chat">Rele klepet</string> - <string name="relay_chat_explainer">Pogovori prek relejev so pogovorne skupine, ki jih nadzoruje njihov domači rele. - Vidne so vsem na Nostru in v njih lahko sodeluje kdorkoli. - So odlične za odprte skupnosti, povezane s specifičnimi temami. Nekatere od teh skupin so kratkotrajne - zato sporočila v klepetu sčasoma izginejo</string> + <string name="relay_chat_explainer">Pogovori prek relejev so pogovorne skupine, ki jih nadzoruje njihov domači rele. Vidne so vsem na Nostru in v njih lahko sodeluje kdorkoli. So odlične za odprte skupnosti, povezane s specifičnimi temami. Nekatere od teh skupin so kratkotrajne zato sporočila v klepetu sčasoma izginejo</string> <string name="relay_chat_title">Rele klepeti</string> <string name="relay_explain_account_data">Vaš profil, nastavitve in osnutki na vaših domačih relejih.</string> <string name="relay_explain_community_chats">Releji, kamor vsaka skupnost objavlja svoje načrte.</string> @@ -4732,7 +4695,7 @@ Prijavi se s privatnim ključem za všečkanje sporočila</string> <string name="resource_usage_memory_native">Lokalni pomnilnik</string> <string name="resource_usage_memory_notes">Zapiski v pomnilniku</string> <string name="resource_usage_memory_section">Trenutni pomnilnik</string> - <string name="resource_usage_memory_users">Profili v pomnilniku </string> + <string name="resource_usage_memory_users">Profili v pomnilniku</string> <string name="resource_usage_nests">Čas v zvočnih sobah</string> <string name="resource_usage_pow">Rudarjenje PoW</string> <string name="resource_usage_radio_bursts">Obuditve radijskega modula za prenose (ocena)</string> diff --git a/commonsUI/src/commonMain/composeResources/values-sr-rSP/strings.xml b/commonsUI/src/commonMain/composeResources/values-sr-rSP/strings.xml index 6ea50394e6..9aee9b1e4d 100644 --- a/commonsUI/src/commonMain/composeResources/values-sr-rSP/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-sr-rSP/strings.xml @@ -271,15 +271,8 @@ <string name="mark_all_known_as_read">Označi sve poznate kao pročitano</string> <string name="mark_all_new_as_read">Označi sve novo kao pročitano</string> <string name="mark_all_as_read">Označi sve kao pročitano</string> - <string name="account_backup_tips2_md"> ## Saveti za bekap ključa i bezbednost - \n\nVaš nalog je zaštićen tajnim ključem. Ključ je dugačak niz znakova koji počinje sa **nsec1**. Svako ko ima pristup ovom tajnom ključu može da objavljuje i menja vaš identitet. - \n\n- **Ne** unosite vaš tajni ključ ni na jedan veb-sajt ili softver kojemu ne verujete. - \n- Amethyst programeri **nikada** neće tražiti vaš tajni ključ. - \n- **Čuvajte** bezbednu kopiju vašeg tajnog ključa radi oporavka naloga. Preporučujemo korišćenje menadžera lozinki. - </string> - <string name="account_backup_tips3_md"> Za dodatnu bezbednost, možete šifrovati vaš ključ lozinkom. Ovaj ključ počinje sa **ncryptsec1** i ne može se koristiti bez vaše lozinke. - \n\nAko izgubite lozinku, nećete moći da obnovite ključ. - </string> + <string name="account_backup_tips2_md">## Saveti za bekap ključa i bezbednost \n\nVaš nalog je zaštićen tajnim ključem. Ključ je dugačak niz znakova koji počinje sa **nsec1**. Svako ko ima pristup ovom tajnom ključu može da objavljuje i menja vaš identitet. \n\n- **Ne** unosite vaš tajni ključ ni na jedan veb-sajt ili softver kojemu ne verujete. \n- Amethyst programeri **nikada** neće tražiti vaš tajni ključ. \n- **Čuvajte** bezbednu kopiju vašeg tajnog ključa radi oporavka naloga. Preporučujemo korišćenje menadžera lozinki.</string> + <string name="account_backup_tips3_md">Za dodatnu bezbednost, možete šifrovati vaš ključ lozinkom. Ovaj ključ počinje sa **ncryptsec1** i ne može se koristiti bez vaše lozinke. \n\nAko izgubite lozinku, nećete moći da obnovite ključ.</string> <string name="encrypt_and_copy_my_secret_key">Šifruj i kopiraj moj tajni ključ</string> <string name="badge_award_image_for">Slika značke za %1$s</string> <string name="badge_award_image">"Slika značke</string> @@ -959,9 +952,7 @@ <string name="push_server_none_explainer">Onemogućava push obaveštenja</string> <string name="push_server_uses_app_explainer">Koristi aplikaciju %1$s</string> <string name="push_server_install_app">Podešavanje push obaveštenja</string> - <string name="push_server_install_app_description"> Da biste primali push obaveštenja, instalirajte bilo koju aplikaciju koja podržava [Unified Push](https://unifiedpush.org/), kao što je [Ntfy](https://ntfy.sh/). - Nakon instalacije, izaberite aplikaciju koju želite da koristite u Postavkama. - </string> + <string name="push_server_install_app_description">Da biste primali push obaveštenja, instalirajte bilo koju aplikaciju koja podržava [Unified Push](https://unifiedpush.org/), kao što je [Ntfy](https://ntfy.sh/). Nakon instalacije, izaberite aplikaciju koju želite da koristite u Postavkama.</string> <string name="thread_title">Nit</string> <string name="send_the_seller_a_message">Pošaljite prodavcu poruku</string> <string name="hi_there_is_this_still_available">Zdravo, da li je ovo još uvek dostupno?</string> @@ -2014,15 +2005,7 @@ <string name="could_not_fetch_invoice_from_details">Nije moguće dohvatiti fakturu od %1$s: %2$s</string> <string name="could_not_open_the_compressed_file">Komprimovana datoteka se ne može otvoriti</string> <string name="couldnt_find_nwc_wallets">Novčanik nije pronađen</string> - <string name="couldnt_find_nwc_wallets_description">Amethyst nije mogao da pronađe nijednu aplikaciju novčanika koja podržava Nostr Wallet Connect (NWC). -\n\nŠta možete da uradite: -\n\t1. Proverite NWC podršku: Proverite da li vaša preferovana aplikacija novčanika podržava Nostr Wallet Connect. -\n\t2. Kreirajte vezu: Idite na aplikaciju novčanika i kreirajte novu NWC vezu. -\n\t3. Povežite sa Amethyst: Vaš novčanik će obično ponuditi tri opcije: -\n\t\t3.1 Automatska veza: Dugme za automatsko otvaranje i povezivanje sa Amethyst. Ako postoji, kliknite to dugme, otvorite Amethyst i pritisnite sačuvaj na novom ekranu. -\n\t\t3.2 Ručna veza: URI veze koji možete kopirati i kliknuti ikonu za nalepljivanje na ovom ekranu da ga direktno unesete. -\n\t\t3.3 QR kod: Novčanik kreira QR kod koji možete očitati dugmetom za QR kod na ovom ekranu. - </string> + <string name="couldnt_find_nwc_wallets_description">Amethyst nije mogao da pronađe nijednu aplikaciju novčanika koja podržava Nostr Wallet Connect (NWC). \n\nŠta možete da uradite: \n\t1. Proverite NWC podršku: Proverite da li vaša preferovana aplikacija novčanika podržava Nostr Wallet Connect. \n\t2. Kreirajte vezu: Idite na aplikaciju novčanika i kreirajte novu NWC vezu. \n\t3. Povežite sa Amethyst: Vaš novčanik će obično ponuditi tri opcije: \n\t\t3.1 Automatska veza: Dugme za automatsko otvaranje i povezivanje sa Amethyst. Ako postoji, kliknite to dugme, otvorite Amethyst i pritisnite sačuvaj na novom ekranu. \n\t\t3.2 Ručna veza: URI veze koji možete kopirati i kliknuti ikonu za nalepljivanje na ovom ekranu da ga direktno unesete. \n\t\t3.3 QR kod: Novčanik kreira QR kod koji možete očitati dugmetom za QR kod na ovom ekranu.</string> <string name="create">Napravi</string> <string name="danger_zone">Opasna zona</string> <string name="dark">Tamno</string> @@ -2703,11 +2686,8 @@ <string name="proxy_section">Proxy Relay-i</string> <string name="proxy_section_explainer">Agregatorni relay-i koje aplikacija mora koristiti za preuzimanje vaših feedova, poput filter.nostr.wine. Ovo zamenjuje outbox model i čini da se aplikacija povezuje samo na relay-e u vašim listama.</string> <string name="public_chat">Javni čet</string> - <string name="public_chat_explainer">Javni četovi vidljivi su svima na Nostr-u i svako - može da učestvuje. Odlični su za otvorene zajednice oko specifičnih tema. - Moderacija se može kontrolisati brisanjem objava na relay-ima</string> - <string name="public_chat_relays_explainer">Unesite između 1 i 3 relay-a koji hostuju ovu grupu. - Nostr klijenti koriste ovo podešavanje da znaju odakle da preuzmu poruke i kuda da ih šalju.</string> + <string name="public_chat_explainer">Javni četovi vidljivi su svima na Nostr-u i svako može da učestvuje. Odlični su za otvorene zajednice oko specifičnih tema. Moderacija se može kontrolisati brisanjem objava na relay-ima</string> + <string name="public_chat_relays_explainer">Unesite između 1 i 3 relay-a koji hostuju ovu grupu. Nostr klijenti koriste ovo podešavanje da znaju odakle da preuzmu poruke i kuda da ih šalju.</string> <string name="public_chat_relays_title">Relay-i</string> <string name="public_chat_title">Metapodaci javnog četa</string> <string name="public_chats">Javni razgovori</string> @@ -2732,10 +2712,7 @@ <string name="read_only_user">Korisnik samo za čitanje</string> <string name="record_a_message_description">Kliknite i držite za snimanje poruke</string> <string name="relay_chat">Relay čet</string> - <string name="relay_chat_explainer">Relay četovi su grupe za razgovor kojima upravlja njihov matični relay. - Vidljivi su svima na Nostr-u i svako može da učestvuje. - Odlični su za otvorene zajednice oko specifičnih tema. Neke od ovih grupa su efemeralne - i stoga poruke nestaju s vremenom</string> + <string name="relay_chat_explainer">Relay četovi su grupe za razgovor kojima upravlja njihov matični relay. Vidljivi su svima na Nostr-u i svako može da učestvuje. Odlični su za otvorene zajednice oko specifičnih tema. Neke od ovih grupa su efemeralne i stoga poruke nestaju s vremenom</string> <string name="relay_chat_title">Relay četovi</string> <string name="relay_management_tab_events">Događaji</string> <string name="relay_management_tab_ips">IP adrese</string> diff --git a/commonsUI/src/commonMain/composeResources/values-sv-rSE/strings.xml b/commonsUI/src/commonMain/composeResources/values-sv-rSE/strings.xml index f591cc0177..0b434776f0 100644 --- a/commonsUI/src/commonMain/composeResources/values-sv-rSE/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-sv-rSE/strings.xml @@ -361,7 +361,7 @@ <string name="copy_user_id">Kopiera Användarens ID</string> <string name="unblock_user">Sluta blockera Användare</string> <string name="user_is_blocked_hidden">Den här användaren är blockerad/tystad. Deras inlägg är dolda.</string> - <string name="npub_hex_username">npub, hex, användarnamn </string> + <string name="npub_hex_username">npub, hex, användarnamn</string> <string name="clear">Rensa</string> <string name="app_logo">Appens logo</string> <string name="nsec_npub_hex_private_key">nsec / npub / hex privat nyckel</string> @@ -463,13 +463,8 @@ <string name="mark_all_known_as_read">Markera alla kända som lästa</string> <string name="mark_all_new_as_read">Markera alla nya som lästa</string> <string name="mark_all_as_read">Markera allt som läst</string> - <string name="account_backup_tips2_md"> ## Nyckelsäkerhetsråd - \n\nDitt konto är säkrat med en hemlig nyckel. Nyckeln är en lång följd av tecken som börjar med **nsec1**. Den som har tillgång till denna hemliga nyckel kan posta och ändra din identitet. - \n\n- **Lägg inte** din hemliga nyckel på någon webbplats eller i någon mjukvara som du inte litar på. - \n- Utvecklarna av Amethyst kommer **aldrig** att be om din hemliga nyckel. - \n- **Spara** en säkerhetskopia av din hemliga nyckel för återställning av kontot. Vi rekommenderar att du använder en lösenordshanterare.</string> - <string name="account_backup_tips3_md"> För extra säkerhet kan du kryptera din nyckel med ett lösenord. Denna nyckel börjar med **ncryptsec1** och kan inte användas utan ditt lösenord. - \n\nOm du tappar bort ditt lösenord kommer du inte att kunna återställa din nyckel.</string> + <string name="account_backup_tips2_md">## Nyckelsäkerhetsråd \n\nDitt konto är säkrat med en hemlig nyckel. Nyckeln är en lång följd av tecken som börjar med **nsec1**. Den som har tillgång till denna hemliga nyckel kan posta och ändra din identitet. \n\n- **Lägg inte** din hemliga nyckel på någon webbplats eller i någon mjukvara som du inte litar på. \n- Utvecklarna av Amethyst kommer **aldrig** att be om din hemliga nyckel. \n- **Spara** en säkerhetskopia av din hemliga nyckel för återställning av kontot. Vi rekommenderar att du använder en lösenordshanterare.</string> + <string name="account_backup_tips3_md">För extra säkerhet kan du kryptera din nyckel med ett lösenord. Denna nyckel börjar med **ncryptsec1** och kan inte användas utan ditt lösenord. \n\nOm du tappar bort ditt lösenord kommer du inte att kunna återställa din nyckel.</string> <string name="backup_keys_nudge_title">Säkerhetskopiera dina nycklar</string> <string name="backup_keys_nudge_body">Din hemliga nyckel är enda sättet att komma åt det här kontot. Om du förlorar den kan den aldrig återskapas. Spara den på ett säkert ställe nu.</string> <string name="backup_keys_nudge_backup_now">Säkerhetskopiera nu</string> @@ -478,7 +473,7 @@ <string name="encrypt_and_copy_my_secret_key">Kryptera och kopiera min hemliga nyckel</string> <string name="badge_award_image_for">Tilldelad Badge för %1$s</string> <string name="badge_award_image">Bild på utmärkelse</string> - <string name="new_badge_award_notif">Du har blivit tilldelad en ny Badge </string> + <string name="new_badge_award_notif">Du har blivit tilldelad en ny Badge</string> <string name="award_granted_to">Badge utmärkelse tilldelas</string> <string name="account_switch_add_account_dialog_title">Lägg till nytt konto</string> <string name="account_switch_select_account">Välj konto</string> @@ -1584,9 +1579,7 @@ <string name="push_server_none_explainer">Inaktivera Push-meddelande</string> <string name="push_server_uses_app_explainer">Använder appen %1$s</string> <string name="push_server_install_app">Inställning av Push-aviseringar</string> - <string name="push_server_install_app_description"> För att ta emot push-aviseringar, installera vilken app som helst som stöder [Unified Push](https://unifiedpush.org), till exempel [Ntfy](https://ntfy.sh/). - Efter installationen väljer du den app du vill använda i inställningarna. - </string> + <string name="push_server_install_app_description">För att ta emot push-aviseringar, installera vilken app som helst som stöder [Unified Push](https://unifiedpush.org), till exempel [Ntfy](https://ntfy.sh/). Efter installationen väljer du den app du vill använda i inställningarna.</string> <string name="thread_title">Tråd</string> <string name="send_the_seller_a_message">Skicka ett meddelande till säljaren</string> <string name="hi_there_is_this_still_available">Hallå där, är detta fortfarande tillgängligt?</string> @@ -3323,14 +3316,7 @@ <string name="could_not_fetch_invoice_from_details">Kunde inte hämta faktura från %1$s: %2$s</string> <string name="could_not_open_the_compressed_file">Kunde inte öppna den komprimerade filen</string> <string name="couldnt_find_nwc_wallets">Plånbok hittades inte</string> - <string name="couldnt_find_nwc_wallets_description">Amethyst kunde inte hitta några plånboksappar som stöder Nostr Wallet Connect (NWC). -\n\nVad du kan göra: -\n\t1. Verifiera NWC-stöd: Kontrollera att din föredragna plånbok stöder Nostr Wallet Connect. -\n\t2. Skapa anslutning: Gå till din plånbok och skapa en ny NWC-anslutning. -\n\t3. Länka till Amethyst: Din plånbok erbjuder vanligtvis tre alternativ: -\n\t\t3.1 Automatisk länk: En knapp för att automatiskt öppna och ansluta till Amethyst. Klicka på den, öppna Amethyst och spara på den nya skärmen. -\n\t\t3.2 Manuell länk: En anslutnings-URI som du kan kopiera och klistra in via klistra-ikonen på den här skärmen. -\n\t\t3.3 QR-kod: Plånboken skapar en QR-kod som du kan skanna med QR-knappens funktion på den här skärmen.</string> + <string name="couldnt_find_nwc_wallets_description">Amethyst kunde inte hitta några plånboksappar som stöder Nostr Wallet Connect (NWC). \n\nVad du kan göra: \n\t1. Verifiera NWC-stöd: Kontrollera att din föredragna plånbok stöder Nostr Wallet Connect. \n\t2. Skapa anslutning: Gå till din plånbok och skapa en ny NWC-anslutning. \n\t3. Länka till Amethyst: Din plånbok erbjuder vanligtvis tre alternativ: \n\t\t3.1 Automatisk länk: En knapp för att automatiskt öppna och ansluta till Amethyst. Klicka på den, öppna Amethyst och spara på den nya skärmen. \n\t\t3.2 Manuell länk: En anslutnings-URI som du kan kopiera och klistra in via klistra-ikonen på den här skärmen. \n\t\t3.3 QR-kod: Plånboken skapar en QR-kod som du kan skanna med QR-knappens funktion på den här skärmen.</string> <string name="create">Skapa</string> <string name="d">d</string> <string name="danger_zone">Riskzon</string> @@ -4427,11 +4413,8 @@ <string name="proxy_section">Proxyreläer</string> <string name="proxy_section_explainer">Aggregeringsreläer som används för att hämta flöden, t.ex. filter.nostr.wine</string> <string name="public_chat">Publik Chat</string> - <string name="public_chat_explainer">Offentliga chattar är synliga för alla på Nostr och alla - kan delta på dem. De är bra för öppna samhällen kring specifika ämnen. - Moderation kan kontrolleras genom att ta bort inlägg på reläerna</string> - <string name="public_chat_relays_explainer">Infoga mellan 1–3 reläer som är värd för denna grupp. - Nostr klienter använder denna inställning för att veta var du kan ladda ner meddelanden från och skicka dina meddelanden till.</string> + <string name="public_chat_explainer">Offentliga chattar är synliga för alla på Nostr och alla kan delta på dem. De är bra för öppna samhällen kring specifika ämnen. Moderation kan kontrolleras genom att ta bort inlägg på reläerna</string> + <string name="public_chat_relays_explainer">Infoga mellan 1–3 reläer som är värd för denna grupp. Nostr klienter använder denna inställning för att veta var du kan ladda ner meddelanden från och skicka dina meddelanden till.</string> <string name="public_chat_relays_title">Reläer</string> <string name="public_chat_title">Metadata för offentlig chatt</string> <string name="public_chats">Offentliga chattar</string> @@ -4496,10 +4479,7 @@ <string name="relay_auth_search_keywords">auth autentisering relä signera verifiera nip-42 identitet</string> <string name="relay_auth_settings_title">Reläautentisering</string> <string name="relay_chat">Relä Chatt</string> - <string name="relay_chat_explainer">Relä chattar är chattgrupper som kontrolleras av deras hem relä. - De är synliga för alla på Nostr och vem som helst kan delta på dem. - De är bra för öppna gemenskaper kring specifika ämnen. Några av dessa grupper är kortlivade - och därmed försvinner chattmeddelanden över tiden</string> + <string name="relay_chat_explainer">Relä chattar är chattgrupper som kontrolleras av deras hem relä. De är synliga för alla på Nostr och vem som helst kan delta på dem. De är bra för öppna gemenskaper kring specifika ämnen. Några av dessa grupper är kortlivade och därmed försvinner chattmeddelanden över tiden</string> <string name="relay_chat_title">Relä Chattar</string> <string name="relay_explain_account_data">Din egen profil, dina inställningar och utkast, på dina hemreläer.</string> <string name="relay_explain_community_chats">Reläerna som varje gemenskap publicerar sina plan till.</string> diff --git a/commonsUI/src/commonMain/composeResources/values-sw/strings.xml b/commonsUI/src/commonMain/composeResources/values-sw/strings.xml index 9c24ec5784..439b9c7be0 100644 --- a/commonsUI/src/commonMain/composeResources/values-sw/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-sw/strings.xml @@ -268,15 +268,8 @@ <string name="mark_all_known_as_read">Funga kama Zimejulikana</string> <string name="mark_all_new_as_read">Funga kama Mpya</string> <string name="mark_all_as_read">Funga kama Zimejulikana</string> - <string name="account_backup_tips2_md"> ## Vidokezo vya Nakala Rudufu ya Ufunguo na Usalama - \n\nAkaunti yako inalindwa na ufunguo wa siri. Ufunguo ni mfululizo mrefu wa herufi unaoanza na **nsec1**. Mtu yeyote anayepata ufunguo huu wa siri anaweza kuchapisha na kubadilisha utambulisho wako. - \n\n- **Usiweke** ufunguo wako wa siri katika tovuti yoyote au programu unayoitembelewa. - \n- Watengenezaji wa Amethyst **hawatawahi** kuomba ufunguo wako wa siri. - \n- **Hifadhi** nakala salama ya ufunguo wako wa siri kwa ajili ya kurejesha akaunti. Tunapendekeza kutumia kidhibiti cha nenosiri. - </string> - <string name="account_backup_tips3_md"> Kwa usalama zaidi, unaweza kusimba ufunguo wako kwa nenosiri. Ufunguo huu unaanza na **ncryptsec1** na hauwezi kutumika bila nenosiri lako. - \n\nUkipoteza nenosiri lako, hutaweza kurejesha ufunguo wako. - </string> + <string name="account_backup_tips2_md">## Vidokezo vya Nakala Rudufu ya Ufunguo na Usalama \n\nAkaunti yako inalindwa na ufunguo wa siri. Ufunguo ni mfululizo mrefu wa herufi unaoanza na **nsec1**. Mtu yeyote anayepata ufunguo huu wa siri anaweza kuchapisha na kubadilisha utambulisho wako. \n\n- **Usiweke** ufunguo wako wa siri katika tovuti yoyote au programu unayoitembelewa. \n- Watengenezaji wa Amethyst **hawatawahi** kuomba ufunguo wako wa siri. \n- **Hifadhi** nakala salama ya ufunguo wako wa siri kwa ajili ya kurejesha akaunti. Tunapendekeza kutumia kidhibiti cha nenosiri.</string> + <string name="account_backup_tips3_md">Kwa usalama zaidi, unaweza kusimba ufunguo wako kwa nenosiri. Ufunguo huu unaanza na **ncryptsec1** na hauwezi kutumika bila nenosiri lako. \n\nUkipoteza nenosiri lako, hutaweza kurejesha ufunguo wako.</string> <string name="encrypt_and_copy_my_secret_key">Simba na nakili ufunguo wangu wa siri</string> <string name="badge_award_image_for">Picha ya tuzo ya alama kwa %1$s</string> <string name="badge_award_image">"Picha ya tuzo ya beji</string> @@ -961,9 +954,7 @@ <string name="push_server_none_explainer">Inazima Arifa za Push</string> <string name="push_server_uses_app_explainer">Inatumia programu %1$s</string> <string name="push_server_install_app">Mpangilio wa Arifa za Push</string> - <string name="push_server_install_app_description"> Ili kupokea arifa za kusukuma, sakinisha programu yoyote inayounga mkono [Unified Push](https://unifiedpush.org/), kama vile [Ntfy](https://ntfy.sh/). - Baada ya kusakinisha, chagua programu unayotaka kutumia katika Mipangilio. - </string> + <string name="push_server_install_app_description">Ili kupokea arifa za kusukuma, sakinisha programu yoyote inayounga mkono [Unified Push](https://unifiedpush.org/), kama vile [Ntfy](https://ntfy.sh/). Baada ya kusakinisha, chagua programu unayotaka kutumia katika Mipangilio.</string> <string name="thread_title">Nyuzi</string> <string name="send_the_seller_a_message">Tuma ujumbe kwa muuzaji</string> <string name="hi_there_is_this_still_available">Habari, je, hii bado inapatikana?</string> @@ -2016,15 +2007,7 @@ <string name="could_not_fetch_invoice_from_details">Haikuweza kupata ankara kutoka kwa %1$s: %2$s</string> <string name="could_not_open_the_compressed_file">Haikuweza kufungua faili lililosinyazwa</string> <string name="couldnt_find_nwc_wallets">Mkoba haukupatikana</string> - <string name="couldnt_find_nwc_wallets_description">Amethyst haikupata programu yoyote ya mkoba inayounga mkono Nostr Wallet Connect (NWC). -\n\nUnachoweza kufanya: -\n\t1. Thibitisha Usaidizi wa NWC: Hakikisha kuwa programu yako ya mkoba inaoana na Nostr Wallet Connect. -\n\t2. Unda Muunganisho: Nenda kwenye programu yako ya mkoba na unda muunganisho mpya wa NWC. -\n\t3. Unganisha na Amethyst: Mkoba wako kwa kawaida utakupa chaguo tatu: -\n\t\t3.1 Kiungo cha Kiotomatiki: Kitufe cha kufungua na kuunganisha moja kwa moja na Amethyst. Ikiwa ndivyo, bonyeza kitufe hicho, fungua Amethyst na bonyeza hifadhi kwenye skrini mpya. -\n\t\t3.2 Kiungo cha Mkono: URI ya Muunganisho unayoweza kunakili na kubonyeza ikoni ya kubandika kwenye skrini hii kuiingiza moja kwa moja. -\n\t\t3.3 Msimbo wa QR: Mkoba unaunda msimbo wa QR ambao unaweza kuusoma kutoka kwa kitufe cha msimbo wa QR kwenye skrini hii. - </string> + <string name="couldnt_find_nwc_wallets_description">Amethyst haikupata programu yoyote ya mkoba inayounga mkono Nostr Wallet Connect (NWC). \n\nUnachoweza kufanya: \n\t1. Thibitisha Usaidizi wa NWC: Hakikisha kuwa programu yako ya mkoba inaoana na Nostr Wallet Connect. \n\t2. Unda Muunganisho: Nenda kwenye programu yako ya mkoba na unda muunganisho mpya wa NWC. \n\t3. Unganisha na Amethyst: Mkoba wako kwa kawaida utakupa chaguo tatu: \n\t\t3.1 Kiungo cha Kiotomatiki: Kitufe cha kufungua na kuunganisha moja kwa moja na Amethyst. Ikiwa ndivyo, bonyeza kitufe hicho, fungua Amethyst na bonyeza hifadhi kwenye skrini mpya. \n\t\t3.2 Kiungo cha Mkono: URI ya Muunganisho unayoweza kunakili na kubonyeza ikoni ya kubandika kwenye skrini hii kuiingiza moja kwa moja. \n\t\t3.3 Msimbo wa QR: Mkoba unaunda msimbo wa QR ambao unaweza kuusoma kutoka kwa kitufe cha msimbo wa QR kwenye skrini hii.</string> <string name="create">Unda</string> <string name="d">siku</string> <string name="danger_zone">Eneo Hatari</string> @@ -2686,11 +2669,8 @@ <string name="proxy_section">Relay za Wakala</string> <string name="proxy_section_explainer">Relay za mkusanyaji ambazo programu lazima itumie kupakua malisho yako, kama filter.nostr.wine. Hii inabadilisha mfano wa sanduku la nje na itafanya programu iunganike na relay zilizo kwenye orodha zako tu.</string> <string name="public_chat">Mazungumzo ya Umma</string> - <string name="public_chat_explainer">Mazungumzo ya umma yanaweza kuonekana na kila mtu kwenye Nostr na mtu yeyote - anaweza kushiriki. Ni mazuri kwa jamii wazi kuhusu mada maalum. - Udhibiti unaweza kufanywa kwa kufuta machapisho kwenye relay zake</string> - <string name="public_chat_relays_explainer">Ingiza kati ya relay 1–3 zinazohifadhi kikundi hiki. - Wateja wa Nostr hutumia mipangilio hii kujua mahali pa kupakua ujumbe na kutuma ujumbe wako.</string> + <string name="public_chat_explainer">Mazungumzo ya umma yanaweza kuonekana na kila mtu kwenye Nostr na mtu yeyote anaweza kushiriki. Ni mazuri kwa jamii wazi kuhusu mada maalum. Udhibiti unaweza kufanywa kwa kufuta machapisho kwenye relay zake</string> + <string name="public_chat_relays_explainer">Ingiza kati ya relay 1–3 zinazohifadhi kikundi hiki. Wateja wa Nostr hutumia mipangilio hii kujua mahali pa kupakua ujumbe na kutuma ujumbe wako.</string> <string name="public_chat_title">Metadata ya Mazungumzo ya Umma</string> <string name="public_chats">Mazungumzo ya Umma</string> <string name="public_home_section">Relay za Sanduku la Nje/Nyumbani za Umma</string> @@ -2714,10 +2694,7 @@ <string name="read_only_user">Mtumiaji wa kusoma tu</string> <string name="record_a_message_description">Bonyeza na ushikilie kurekodi ujumbe</string> <string name="relay_chat">Mazungumzo ya Relay</string> - <string name="relay_chat_explainer">Mazungumzo ya relay ni vikundi vya mazungumzo vinavyodhibitiwa na relay yao ya nyumbani. - Yanaweza kuonekana na kila mtu kwenye Nostr na mtu yeyote anaweza kushiriki. - Ni mazuri kwa jamii wazi kuhusu mada maalum. Baadhi ya vikundi hivi ni vya muda mfupi - na hivyo ujumbe wa mazungumzo huisha baada ya muda</string> + <string name="relay_chat_explainer">Mazungumzo ya relay ni vikundi vya mazungumzo vinavyodhibitiwa na relay yao ya nyumbani. Yanaweza kuonekana na kila mtu kwenye Nostr na mtu yeyote anaweza kushiriki. Ni mazuri kwa jamii wazi kuhusu mada maalum. Baadhi ya vikundi hivi ni vya muda mfupi na hivyo ujumbe wa mazungumzo huisha baada ya muda</string> <string name="relay_chat_title">Mazungumzo ya Relay</string> <string name="relay_management_tab_events">Matukio</string> <string name="relay_management_tab_kinds">Aina</string> diff --git a/commonsUI/src/commonMain/composeResources/values-ta-rIN/strings.xml b/commonsUI/src/commonMain/composeResources/values-ta-rIN/strings.xml index 3b46dde816..106be142e3 100644 --- a/commonsUI/src/commonMain/composeResources/values-ta-rIN/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-ta-rIN/strings.xml @@ -224,7 +224,7 @@ <string name="copy_user_id">பயனர் IDயை நகலெடுக்கவும்</string> <string name="unblock_user">பயனரைத் தடைநீக்கு</string> <string name="user_is_blocked_hidden">இந்த பயனர் தடுக்கப்பட்டு/முடக்கப்பட்டுள்ளார். அவர்களின் இடுகைகள் மறைக்கப்பட்டுள்ளன.</string> - <string name="npub_hex_username">npub, hex, பயனர்பெயர் </string> + <string name="npub_hex_username">npub, hex, பயனர்பெயர்</string> <string name="clear">அழி</string> <string name="app_logo">பயன்பாட்டு இலச்சினை</string> <string name="nsec_npub_hex_private_key">nsec / npub / hex தனிப்பட்ட சாவி</string> @@ -272,17 +272,10 @@ <string name="mark_all_known_as_read">தெரிந்தவற்றைப் படித்ததாகக் குறி</string> <string name="mark_all_new_as_read">புதியனவற்றைப் படித்ததாகக் குறி</string> <string name="mark_all_as_read">அனைத்தையும் படித்ததாகக் குறி</string> - <string name="account_backup_tips2_md"> ## திறவுகோல் காப்புப்பிரதி மற்றும் பாதுகாப்பு குறிப்புகள் - \n\nஉங்கள் கணக்கு ஒரு இரகசிய திறவுகோலால் பாதுகாக்கப்படுகிறது. இந்த திறவுகோல் **nsec1** என்று தொடங்கும் நீண்ட எழுத்துத் தொடராகும். இந்த இரகசிய திறவுகோலை யாராவது பெற்றால் அவர்கள் உங்கள் அடையாளத்தை பயன்படுத்தி இடுகையிடலாம் மற்றும் மாற்றலாம். - \n\n- உங்கள் இரகசிய திறவுகோலை நீங்கள் நம்பாத எந்த வலைத்தளத்திலும் அல்லது மென்பொருளிலும் வைக்க **வேண்டாம்**. - \n- Amethyst டெவலப்பர்கள் உங்கள் இரகசிய திறவுகோலை **ஒருபோதும்** கேட்கமாட்டார்கள். - \n- கணக்கு மீட்டெடுப்புக்காக உங்கள் இரகசிய திறவுகோலை பாதுகாப்பாக காப்புப்பிரதி எடுங்கள். கடவுச்சொல் மேலாளரை பயன்படுத்த பரிந்துரைக்கிறோம். - </string> - <string name="account_backup_tips3_md"> கூடுதல் பாதுகாப்புக்காக, உங்கள் திறவுகோலை கடவுச்சொல்லால் குறியாக்கலாம். இந்த திறவுகோல் **ncryptsec1** என்று தொடங்கும் மற்றும் உங்கள் கடவுச்சொல்லின்றி பயன்படுத்த முடியாது. - \n\nகடவுச்சொல்லை மறந்தால், உங்கள் திறவுகோலை மீட்டெடுக்க முடியாது. - </string> + <string name="account_backup_tips2_md">## திறவுகோல் காப்புப்பிரதி மற்றும் பாதுகாப்பு குறிப்புகள் \n\nஉங்கள் கணக்கு ஒரு இரகசிய திறவுகோலால் பாதுகாக்கப்படுகிறது. இந்த திறவுகோல் **nsec1** என்று தொடங்கும் நீண்ட எழுத்துத் தொடராகும். இந்த இரகசிய திறவுகோலை யாராவது பெற்றால் அவர்கள் உங்கள் அடையாளத்தை பயன்படுத்தி இடுகையிடலாம் மற்றும் மாற்றலாம். \n\n- உங்கள் இரகசிய திறவுகோலை நீங்கள் நம்பாத எந்த வலைத்தளத்திலும் அல்லது மென்பொருளிலும் வைக்க **வேண்டாம்**. \n- Amethyst டெவலப்பர்கள் உங்கள் இரகசிய திறவுகோலை **ஒருபோதும்** கேட்கமாட்டார்கள். \n- கணக்கு மீட்டெடுப்புக்காக உங்கள் இரகசிய திறவுகோலை பாதுகாப்பாக காப்புப்பிரதி எடுங்கள். கடவுச்சொல் மேலாளரை பயன்படுத்த பரிந்துரைக்கிறோம்.</string> + <string name="account_backup_tips3_md">கூடுதல் பாதுகாப்புக்காக, உங்கள் திறவுகோலை கடவுச்சொல்லால் குறியாக்கலாம். இந்த திறவுகோல் **ncryptsec1** என்று தொடங்கும் மற்றும் உங்கள் கடவுச்சொல்லின்றி பயன்படுத்த முடியாது. \n\nகடவுச்சொல்லை மறந்தால், உங்கள் திறவுகோலை மீட்டெடுக்க முடியாது.</string> <string name="encrypt_and_copy_my_secret_key">என் இரகசிய விசையை மறைகுறியாக்கி நகல் எடுக்கவும்</string> - <string name="badge_award_image_for"> %1$s க்கான பேட்ஜ் விருது படம்</string> + <string name="badge_award_image_for">%1$s க்கான பேட்ஜ் விருது படம்</string> <string name="badge_award_image">"பேட்ஜ் விருது படம்</string> <string name="new_badge_award_notif">நீங்கள் ஒரு புதிய பேட்ஜ் விருதைப் பெற்றீர்கள்</string> <string name="award_granted_to">பேட்ஜ் விருது வழங்கப்பட்டது. பெறுநர்:</string> @@ -304,7 +297,7 @@ <string name="quick_action_block_dialog_btn">தடைசெய்</string> <string name="quick_action_delete_dialog_btn">அழி</string> <string name="quick_action_block">தடைசெய்</string> - <string name="quick_action_report">புகார் </string> + <string name="quick_action_report">புகார்</string> <string name="quick_action_dont_show_again_button">மீண்டும் காட்டாதே</string> <string name="report_dialog_spam">எரிதம் அல்லது மோசடிகள்</string> <string name="report_dialog_profanity">அவதூறு அல்லது வெறுக்கத்தக்க நடத்தை</string> @@ -871,7 +864,7 @@ <string name="followed_tags">பின்பற்றப்படும் சிட்டைகள்</string> <string name="discover_follows_explainer">இவை நீங்கள் மற்றவர்களுக்கு பரிந்துரைக்கும் பயனர்களின் பட்டியல்கள். பொது பயனர்கள் மட்டுமே அனுமதிக்கப்படுவர்.</string> <string name="community_approved_posts">அங்கீகரிக்கப்பட்ட குறிப்பு</string> - <string name="groups_no_descriptor">இந்த அரட்டைக் குழுவின் விளக்கமும் விதிகளும் இன்னும் சேர்க்கபடவில்லை. உரிமையாளரை அணுகி அவற்றை சேர்க்க கோரவும் </string> + <string name="groups_no_descriptor">இந்த அரட்டைக் குழுவின் விளக்கமும் விதிகளும் இன்னும் சேர்க்கபடவில்லை. உரிமையாளரை அணுகி அவற்றை சேர்க்க கோரவும்</string> <string name="community_no_descriptor">இந்த சமூகத்தின் விளக்கமும் விதிகளும் இன்னும் சேர்க்கபடவில்லை. உரிமையாளரை அணுகி அவற்றை சேர்க்க கோரவும்</string> <string name="user_preferences">பயனர் விருப்பங்கள்</string> <string name="settings_search_placeholder">அமைப்புகளை தேடு</string> @@ -965,9 +958,7 @@ <string name="push_server_none_explainer">புஷ் அறிவிப்புகளை முடக்கும்</string> <string name="push_server_uses_app_explainer">%1$s ஆப்பை பயன்படுத்துகிறது</string> <string name="push_server_install_app">புஷ் அறிவிப்பு அமைவு</string> - <string name="push_server_install_app_description"> Push அறிவிப்புகளை பெற, [Unified Push](https://unifiedpush.org/) ஆதரிக்கும் எந்த பயன்பாட்டையும் நிறுவுங்கள், எடுத்துக்காட்டாக [Ntfy](https://ntfy.sh/). - நிறுவிய பின், அமைப்புகளில் நீங்கள் பயன்படுத்த விரும்பும் பயன்பாட்டை தேர்ந்தெடுங்கள். - </string> + <string name="push_server_install_app_description">Push அறிவிப்புகளை பெற, [Unified Push](https://unifiedpush.org/) ஆதரிக்கும் எந்த பயன்பாட்டையும் நிறுவுங்கள், எடுத்துக்காட்டாக [Ntfy](https://ntfy.sh/). நிறுவிய பின், அமைப்புகளில் நீங்கள் பயன்படுத்த விரும்பும் பயன்பாட்டை தேர்ந்தெடுங்கள்.</string> <string name="thread_title">நூல்</string> <string name="send_the_seller_a_message">விற்பனையாளருக்கு ஒரு செய்தி அனுப்பவும்</string> <string name="hi_there_is_this_still_available">வணக்கம், இது இன்னும் கிடைக்கிறதா?</string> @@ -2016,15 +2007,7 @@ <string name="could_not_fetch_invoice_from_details">%1$s இலிருந்து இன்வாய்ஸை பெற முடியவில்லை: %2$s</string> <string name="could_not_open_the_compressed_file">சுருக்கப்பட்ட கோப்பை திறக்க முடியவில்லை</string> <string name="couldnt_find_nwc_wallets">வாலட் கிடைக்கவில்லை</string> - <string name="couldnt_find_nwc_wallets_description">Nostr Wallet Connect (NWC)-ஐ ஆதரிக்கும் எந்த பணப்பை பயன்பாடுகளையும் Amethyst கண்டுபிடிக்கவில்லை. -\n\nநீங்கள் செய்யக்கூடியவை: -\n\t1. NWC ஆதரவை சரிபாருங்கள்: உங்கள் விருப்பமான பணப்பை பயன்பாடு Nostr Wallet Connect-உடன் இணக்கமானதா என இரட்டிப்பு சரிபாருங்கள். -\n\t2. இணைப்பை உருவாக்குங்கள்: உங்கள் பணப்பை பயன்பாட்டிற்கு சென்று புதிய NWC இணைப்பை உருவாக்குங்கள். -\n\t3. Amethyst-உடன் இணைக்குங்கள்: உங்கள் பணப்பை பொதுவாக மூன்று விருப்பங்களை வழங்கும்: -\n\t\t3.1 தானியங்கி இணைப்பு: Amethyst-ஐ தானாக திறந்து இணைக்க ஒரு பொத்தான். அவ்வாறிருந்தால், அந்த பொத்தானை அழுத்தி Amethyst-ஐ திறந்து புதிய திரையில் சேமி அழுத்துங்கள். -\n\t\t3.2 கைமுறை இணைப்பு: நீங்கள் நகலெடுக்கக்கூடிய Connection URI, இந்த திரையில் ஒட்டு ஐகானை அழுத்தி நேரடியாக உள்ளிடலாம். -\n\t\t3.3 QR குறியீடு: பணப்பை ஒரு QR குறியீட்டை உருவாக்கும், இந்த திரையில் QR குறியீடு பொத்தானிலிருந்து படிக்கலாம். - </string> + <string name="couldnt_find_nwc_wallets_description">Nostr Wallet Connect (NWC)-ஐ ஆதரிக்கும் எந்த பணப்பை பயன்பாடுகளையும் Amethyst கண்டுபிடிக்கவில்லை. \n\nநீங்கள் செய்யக்கூடியவை: \n\t1. NWC ஆதரவை சரிபாருங்கள்: உங்கள் விருப்பமான பணப்பை பயன்பாடு Nostr Wallet Connect-உடன் இணக்கமானதா என இரட்டிப்பு சரிபாருங்கள். \n\t2. இணைப்பை உருவாக்குங்கள்: உங்கள் பணப்பை பயன்பாட்டிற்கு சென்று புதிய NWC இணைப்பை உருவாக்குங்கள். \n\t3. Amethyst-உடன் இணைக்குங்கள்: உங்கள் பணப்பை பொதுவாக மூன்று விருப்பங்களை வழங்கும்: \n\t\t3.1 தானியங்கி இணைப்பு: Amethyst-ஐ தானாக திறந்து இணைக்க ஒரு பொத்தான். அவ்வாறிருந்தால், அந்த பொத்தானை அழுத்தி Amethyst-ஐ திறந்து புதிய திரையில் சேமி அழுத்துங்கள். \n\t\t3.2 கைமுறை இணைப்பு: நீங்கள் நகலெடுக்கக்கூடிய Connection URI, இந்த திரையில் ஒட்டு ஐகானை அழுத்தி நேரடியாக உள்ளிடலாம். \n\t\t3.3 QR குறியீடு: பணப்பை ஒரு QR குறியீட்டை உருவாக்கும், இந்த திரையில் QR குறியீடு பொத்தானிலிருந்து படிக்கலாம்.</string> <string name="create">உருவாக்கு</string> <string name="d">நா</string> <string name="danger_zone">ஆபத்து மண்டலம்</string> @@ -2416,7 +2399,7 @@ <string name="metadata_strip_failed_upload_cancelled">மீடியாவிலிருந்து தனிப்பட்ட மெட்டாடேட்டாவை நீக்கத் தோல்வியடைந்தது. பதிவேற்றம் ரத்துசெய்யப்பட்டது.</string> <string name="migrate_bookmarks_success">புக்மார்க்குகள் வெற்றிகரமாக இடம்பெயர்க்கப்பட்டன</string> <string name="missing_lud16">Lightning அமைப்பு இல்லை</string> - <string name="more_options">மேலும் </string> + <string name="more_options">மேலும்</string> <string name="move_bookmark_to_private_label">தனிப்பட்டதிற்கு நகர்த்து</string> <string name="move_bookmark_to_public_label">பொதுவிற்கு நகர்த்து</string> <string name="music_playlist_cover_upload_cta">அட்டை படத்தை பதிவேற்று</string> @@ -2648,7 +2631,7 @@ <item quantity="one">இந்த இடுகையில் %1$d hashtag-ஐ விட அதிகம் உள்ளது</item> <item quantity="other">இந்த இடுகையில் %1$d hashtags-ஐ விட அதிகம் உள்ளது</item> </plurals> - <string name="preview_card_image_for"> %1$s க்கான முன்னோட்ட அட்டைப் படம்</string> + <string name="preview_card_image_for">%1$s க்கான முன்னோட்ட அட்டைப் படம்</string> <string name="privacy_options">தனியுரிமை விருப்பங்கள்</string> <string name="privacy_policy">தனியுரிமைக் கொள்கை</string> <string name="private_conversation_notification">தனிப்பட்ட செய்தியை மறைக்குறியாக்க இயலவில்லை \n \n %1$s மற்றும் %2$s க்கு இடையே தனிப்பட்ட/ குறியாக்கப்பட்ட உரையாடலில் நீங்கள் மேற்கோள் காட்டப்பட்டீர்கள்.</string> @@ -2673,11 +2656,8 @@ <string name="propose_an_edit">திருத்தத்தை முன்மொழிக</string> <string name="proxy_section_explainer">filter.nostr.wine போன்ற ஒருங்கிணைப்பு relays, பயன்பாடு உங்கள் ஊட்டங்களை பதிவிறக்க பயன்படுத்த வேண்டும். இது அவுட்பாக்ஸ் மாதிரியை மாற்றுகிறது, பயன்பாட்டை உங்கள் பட்டியல்களில் உள்ள relays மட்டுமே இணைக்கும்.</string> <string name="public_chat">பொது அரட்டை</string> - <string name="public_chat_explainer">பொது அரட்டைகள் Nostr-ல் அனைவருக்கும் தெரியும் மற்றும் யாரும் - அவற்றில் பங்கேற்கலாம். அவை குறிப்பிட்ட தலைப்புகளை சுற்றிய திறந்த சமூகங்களுக்கு மிகவும் ஏற்றவை. - அதன் relays-ல் இடுகைகளை நீக்குவதன் மூலம் மிதவாண்மையை கட்டுப்படுத்தலாம்</string> - <string name="public_chat_relays_explainer">இந்த குழுவை ஹோஸ்ட் செய்யும் 1–3 relays-ஐ உள்ளிடவும். - Nostr கிளையன்கள் செய்திகளை எங்கிருந்து பதிவிறக்கம் செய்வது மற்றும் உங்கள் செய்திகளை எங்கே அனுப்புவது என்பதை அறிய இந்த அமைப்பை பயன்படுத்துகின்றன.</string> + <string name="public_chat_explainer">பொது அரட்டைகள் Nostr-ல் அனைவருக்கும் தெரியும் மற்றும் யாரும் அவற்றில் பங்கேற்கலாம். அவை குறிப்பிட்ட தலைப்புகளை சுற்றிய திறந்த சமூகங்களுக்கு மிகவும் ஏற்றவை. அதன் relays-ல் இடுகைகளை நீக்குவதன் மூலம் மிதவாண்மையை கட்டுப்படுத்தலாம்</string> + <string name="public_chat_relays_explainer">இந்த குழுவை ஹோஸ்ட் செய்யும் 1–3 relays-ஐ உள்ளிடவும். Nostr கிளையன்கள் செய்திகளை எங்கிருந்து பதிவிறக்கம் செய்வது மற்றும் உங்கள் செய்திகளை எங்கே அனுப்புவது என்பதை அறிய இந்த அமைப்பை பயன்படுத்துகின்றன.</string> <string name="public_chat_relays_title">Relay கள்</string> <string name="public_chat_title">பொது அரட்டை மெட்டாடேட்டா</string> <string name="public_chats">பொது அரட்டைகள்</string> @@ -2702,10 +2682,7 @@ <string name="read_only_user">படிக்க மட்டும் பயனர்</string> <string name="record_a_message_description">செய்தி பதிவு செய்ய கிளிக் செய்து பிடிக்கவும்</string> <string name="relay_chat">Relay அரட்டை</string> - <string name="relay_chat_explainer">Relay அரட்டைகள் என்பவை அவற்றின் முகப்பு relay-ஆல் கட்டுப்படுத்தப்படும் அரட்டை குழுக்கள். - அவை Nostr-ல் அனைவருக்கும் தெரியும் மற்றும் யாரும் அவற்றில் பங்கேற்கலாம். - அவை குறிப்பிட்ட தலைப்புகளை சுற்றிய திறந்த சமூகங்களுக்கு மிகவும் ஏற்றவை. இந்த குழுக்களில் சில தற்காலிகமானவை - எனவே அரட்டை செய்திகள் காலப்போக்கில் மறைந்துவிடும்</string> + <string name="relay_chat_explainer">Relay அரட்டைகள் என்பவை அவற்றின் முகப்பு relay-ஆல் கட்டுப்படுத்தப்படும் அரட்டை குழுக்கள். அவை Nostr-ல் அனைவருக்கும் தெரியும் மற்றும் யாரும் அவற்றில் பங்கேற்கலாம். அவை குறிப்பிட்ட தலைப்புகளை சுற்றிய திறந்த சமூகங்களுக்கு மிகவும் ஏற்றவை. இந்த குழுக்களில் சில தற்காலிகமானவை எனவே அரட்டை செய்திகள் காலப்போக்கில் மறைந்துவிடும்</string> <string name="relay_chat_title">Relay அரட்டைகள்</string> <string name="relay_management_tab_events">நிகழ்வுகள்</string> <string name="relay_management_tab_kinds">வகைகள்</string> diff --git a/commonsUI/src/commonMain/composeResources/values-ta/strings.xml b/commonsUI/src/commonMain/composeResources/values-ta/strings.xml index 18a9e71315..210f73485f 100644 --- a/commonsUI/src/commonMain/composeResources/values-ta/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-ta/strings.xml @@ -211,7 +211,7 @@ <string name="copy_user_id">பயனர் IDயை நகலெடுக்கவும்</string> <string name="unblock_user">பயனரைத் தடைநீக்கு</string> <string name="user_is_blocked_hidden">இந்த பயனர் தடுக்கப்பட்டு/முடக்கப்பட்டுள்ளார். அவர்களின் இடுகைகள் மறைக்கப்பட்டுள்ளன.</string> - <string name="npub_hex_username">npub, hex, பயனர்பெயர் </string> + <string name="npub_hex_username">npub, hex, பயனர்பெயர்</string> <string name="clear">அழி</string> <string name="app_logo">பயன்பாட்டு இலச்சினை</string> <string name="nsec_npub_hex_private_key">nsec / npub / hex தனிப்பட்ட சாவி</string> @@ -259,19 +259,10 @@ <string name="mark_all_known_as_read">தெரிந்தவற்றைப் படித்ததாகக் குறி</string> <string name="mark_all_new_as_read">புதியனவற்றைப் படித்ததாகக் குறி</string> <string name="mark_all_as_read">அனைத்தையும் படித்ததாகக் குறி</string> - <string name="account_backup_tips2_md"> - ## திறவுகோல் காப்புப்பிரதி மற்றும் பாதுகாப்பு குறிப்புகள் - \n\nஉங்கள் கணக்கு ஒரு இரகசிய திறவுகோலால் பாதுகாக்கப்படுகிறது. இந்த திறவுகோல் **nsec1** என்று தொடங்கும் நீண்ட எழுத்துத் தொடராகும். இந்த இரகசிய திறவுகோலை யாராவது பெற்றால் அவர்கள் உங்கள் அடையாளத்தை பயன்படுத்தி இடுகையிடலாம் மற்றும் மாற்றலாம். - \n\n- உங்கள் இரகசிய திறவுகோலை நீங்கள் நம்பாத எந்த வலைத்தளத்திலும் அல்லது மென்பொருளிலும் வைக்க **வேண்டாம்**. - \n- Amethyst டெவலப்பர்கள் உங்கள் இரகசிய திறவுகோலை **ஒருபோதும்** கேட்கமாட்டார்கள். - \n- கணக்கு மீட்டெடுப்புக்காக உங்கள் இரகசிய திறவுகோலை பாதுகாப்பாக காப்புப்பிரதி எடுங்கள். கடவுச்சொல் மேலாளரை பயன்படுத்த பரிந்துரைக்கிறோம். - </string> - <string name="account_backup_tips3_md"> - கூடுதல் பாதுகாப்புக்காக, உங்கள் திறவுகோலை கடவுச்சொல்லால் குறியாக்கலாம். இந்த திறவுகோல் **ncryptsec1** என்று தொடங்கும் மற்றும் உங்கள் கடவுச்சொல்லின்றி பயன்படுத்த முடியாது. - \n\nகடவுச்சொல்லை மறந்தால், உங்கள் திறவுகோலை மீட்டெடுக்க முடியாது. - </string> + <string name="account_backup_tips2_md">## திறவுகோல் காப்புப்பிரதி மற்றும் பாதுகாப்பு குறிப்புகள் \n\nஉங்கள் கணக்கு ஒரு இரகசிய திறவுகோலால் பாதுகாக்கப்படுகிறது. இந்த திறவுகோல் **nsec1** என்று தொடங்கும் நீண்ட எழுத்துத் தொடராகும். இந்த இரகசிய திறவுகோலை யாராவது பெற்றால் அவர்கள் உங்கள் அடையாளத்தை பயன்படுத்தி இடுகையிடலாம் மற்றும் மாற்றலாம். \n\n- உங்கள் இரகசிய திறவுகோலை நீங்கள் நம்பாத எந்த வலைத்தளத்திலும் அல்லது மென்பொருளிலும் வைக்க **வேண்டாம்**. \n- Amethyst டெவலப்பர்கள் உங்கள் இரகசிய திறவுகோலை **ஒருபோதும்** கேட்கமாட்டார்கள். \n- கணக்கு மீட்டெடுப்புக்காக உங்கள் இரகசிய திறவுகோலை பாதுகாப்பாக காப்புப்பிரதி எடுங்கள். கடவுச்சொல் மேலாளரை பயன்படுத்த பரிந்துரைக்கிறோம்.</string> + <string name="account_backup_tips3_md">கூடுதல் பாதுகாப்புக்காக, உங்கள் திறவுகோலை கடவுச்சொல்லால் குறியாக்கலாம். இந்த திறவுகோல் **ncryptsec1** என்று தொடங்கும் மற்றும் உங்கள் கடவுச்சொல்லின்றி பயன்படுத்த முடியாது. \n\nகடவுச்சொல்லை மறந்தால், உங்கள் திறவுகோலை மீட்டெடுக்க முடியாது.</string> <string name="encrypt_and_copy_my_secret_key">என் இரகசிய விசையை மறைகுறியாக்கி நகல் எடுக்கவும்</string> - <string name="badge_award_image_for"> %1$s க்கான பேட்ஜ் விருது படம்</string> + <string name="badge_award_image_for">%1$s க்கான பேட்ஜ் விருது படம்</string> <string name="badge_award_image">"பேட்ஜ் விருது படம்</string> <string name="new_badge_award_notif">நீங்கள் ஒரு புதிய பேட்ஜ் விருதைப் பெற்றீர்கள்</string> <string name="award_granted_to">பேட்ஜ் விருது வழங்கப்பட்டது. பெறுநர்:</string> @@ -293,7 +284,7 @@ <string name="quick_action_block_dialog_btn">தடைசெய்</string> <string name="quick_action_delete_dialog_btn">அழி</string> <string name="quick_action_block">தடைசெய்</string> - <string name="quick_action_report">புகார் </string> + <string name="quick_action_report">புகார்</string> <string name="quick_action_dont_show_again_button">மீண்டும் காட்டாதே</string> <string name="report_dialog_spam">எரிதம் அல்லது மோசடிகள்</string> <string name="report_dialog_profanity">அவதூறு அல்லது வெறுக்கத்தக்க நடத்தை</string> @@ -867,7 +858,7 @@ <string name="followed_tags">பின்பற்றப்படும் சிட்டைகள்</string> <string name="discover_follows_explainer">இவை நீங்கள் மற்றவர்களுக்கு பரிந்துரைக்கும் பயனர்களின் பட்டியல்கள். பொது பயனர்கள் மட்டுமே அனுமதிக்கப்படுவர்.</string> <string name="community_approved_posts">அங்கீகரிக்கப்பட்ட குறிப்பு</string> - <string name="groups_no_descriptor">இந்த அரட்டைக் குழுவின் விளக்கமும் விதிகளும் இன்னும் சேர்க்கபடவில்லை. உரிமையாளரை அணுகி அவற்றை சேர்க்க கோரவும் </string> + <string name="groups_no_descriptor">இந்த அரட்டைக் குழுவின் விளக்கமும் விதிகளும் இன்னும் சேர்க்கபடவில்லை. உரிமையாளரை அணுகி அவற்றை சேர்க்க கோரவும்</string> <string name="community_no_descriptor">இந்த சமூகத்தின் விளக்கமும் விதிகளும் இன்னும் சேர்க்கபடவில்லை. உரிமையாளரை அணுகி அவற்றை சேர்க்க கோரவும்</string> <string name="user_preferences">பயனர் விருப்பங்கள்</string> <string name="settings_search_placeholder">அமைப்புகளை தேடு</string> @@ -962,10 +953,7 @@ <string name="push_server_none_explainer">புஷ் அறிவிப்புகளை முடக்கும்</string> <string name="push_server_uses_app_explainer">%1$s ஆப்பை பயன்படுத்துகிறது</string> <string name="push_server_install_app">புஷ் அறிவிப்பு அமைவு</string> - <string name="push_server_install_app_description"> - Push அறிவிப்புகளை பெற, [Unified Push](https://unifiedpush.org/) ஆதரிக்கும் எந்த பயன்பாட்டையும் நிறுவுங்கள், எடுத்துக்காட்டாக [Ntfy](https://ntfy.sh/). - நிறுவிய பின், அமைப்புகளில் நீங்கள் பயன்படுத்த விரும்பும் பயன்பாட்டை தேர்ந்தெடுங்கள். - </string> + <string name="push_server_install_app_description">Push அறிவிப்புகளை பெற, [Unified Push](https://unifiedpush.org/) ஆதரிக்கும் எந்த பயன்பாட்டையும் நிறுவுங்கள், எடுத்துக்காட்டாக [Ntfy](https://ntfy.sh/). நிறுவிய பின், அமைப்புகளில் நீங்கள் பயன்படுத்த விரும்பும் பயன்பாட்டை தேர்ந்தெடுங்கள்.</string> <string name="thread_title">நூல்</string> <string name="send_the_seller_a_message">விற்பனையாளருக்கு ஒரு செய்தி அனுப்பவும்</string> <string name="hi_there_is_this_still_available">வணக்கம், இது இன்னும் கிடைக்கிறதா?</string> @@ -2058,15 +2046,7 @@ <string name="could_not_fetch_invoice_from_details">%1$s இலிருந்து இன்வாய்ஸை பெற முடியவில்லை: %2$s</string> <string name="could_not_open_the_compressed_file">சுருக்கப்பட்ட கோப்பை திறக்க முடியவில்லை</string> <string name="couldnt_find_nwc_wallets">வாலட் கிடைக்கவில்லை</string> - <string name="couldnt_find_nwc_wallets_description">Nostr Wallet Connect (NWC)-ஐ ஆதரிக்கும் எந்த பணப்பை பயன்பாடுகளையும் Amethyst கண்டுபிடிக்கவில்லை. -\n\nநீங்கள் செய்யக்கூடியவை: -\n\t1. NWC ஆதரவை சரிபாருங்கள்: உங்கள் விருப்பமான பணப்பை பயன்பாடு Nostr Wallet Connect-உடன் இணக்கமானதா என இரட்டிப்பு சரிபாருங்கள். -\n\t2. இணைப்பை உருவாக்குங்கள்: உங்கள் பணப்பை பயன்பாட்டிற்கு சென்று புதிய NWC இணைப்பை உருவாக்குங்கள். -\n\t3. Amethyst-உடன் இணைக்குங்கள்: உங்கள் பணப்பை பொதுவாக மூன்று விருப்பங்களை வழங்கும்: -\n\t\t3.1 தானியங்கி இணைப்பு: Amethyst-ஐ தானாக திறந்து இணைக்க ஒரு பொத்தான். அவ்வாறிருந்தால், அந்த பொத்தானை அழுத்தி Amethyst-ஐ திறந்து புதிய திரையில் சேமி அழுத்துங்கள். -\n\t\t3.2 கைமுறை இணைப்பு: நீங்கள் நகலெடுக்கக்கூடிய Connection URI, இந்த திரையில் ஒட்டு ஐகானை அழுத்தி நேரடியாக உள்ளிடலாம். -\n\t\t3.3 QR குறியீடு: பணப்பை ஒரு QR குறியீட்டை உருவாக்கும், இந்த திரையில் QR குறியீடு பொத்தானிலிருந்து படிக்கலாம். - </string> + <string name="couldnt_find_nwc_wallets_description">Nostr Wallet Connect (NWC)-ஐ ஆதரிக்கும் எந்த பணப்பை பயன்பாடுகளையும் Amethyst கண்டுபிடிக்கவில்லை. \n\nநீங்கள் செய்யக்கூடியவை: \n\t1. NWC ஆதரவை சரிபாருங்கள்: உங்கள் விருப்பமான பணப்பை பயன்பாடு Nostr Wallet Connect-உடன் இணக்கமானதா என இரட்டிப்பு சரிபாருங்கள். \n\t2. இணைப்பை உருவாக்குங்கள்: உங்கள் பணப்பை பயன்பாட்டிற்கு சென்று புதிய NWC இணைப்பை உருவாக்குங்கள். \n\t3. Amethyst-உடன் இணைக்குங்கள்: உங்கள் பணப்பை பொதுவாக மூன்று விருப்பங்களை வழங்கும்: \n\t\t3.1 தானியங்கி இணைப்பு: Amethyst-ஐ தானாக திறந்து இணைக்க ஒரு பொத்தான். அவ்வாறிருந்தால், அந்த பொத்தானை அழுத்தி Amethyst-ஐ திறந்து புதிய திரையில் சேமி அழுத்துங்கள். \n\t\t3.2 கைமுறை இணைப்பு: நீங்கள் நகலெடுக்கக்கூடிய Connection URI, இந்த திரையில் ஒட்டு ஐகானை அழுத்தி நேரடியாக உள்ளிடலாம். \n\t\t3.3 QR குறியீடு: பணப்பை ஒரு QR குறியீட்டை உருவாக்கும், இந்த திரையில் QR குறியீடு பொத்தானிலிருந்து படிக்கலாம்.</string> <string name="create">உருவாக்கு</string> <string name="d">நா</string> <string name="danger_zone">ஆபத்து மண்டலம்</string> @@ -2486,7 +2466,7 @@ <string name="metadata_strip_failed_upload_cancelled">மீடியாவிலிருந்து தனிப்பட்ட மெட்டாடேட்டாவை நீக்கத் தோல்வியடைந்தது. பதிவேற்றம் ரத்துசெய்யப்பட்டது.</string> <string name="migrate_bookmarks_success">புக்மார்க்குகள் வெற்றிகரமாக இடம்பெயர்க்கப்பட்டன</string> <string name="missing_lud16">Lightning அமைப்பு இல்லை</string> - <string name="more_options">மேலும் </string> + <string name="more_options">மேலும்</string> <string name="move_bookmark_to_private_label">தனிப்பட்டதிற்கு நகர்த்து</string> <string name="move_bookmark_to_public_label">பொதுவிற்கு நகர்த்து</string> <string name="music_playlist_cover_upload_cta">அட்டை படத்தை பதிவேற்று</string> @@ -2728,7 +2708,7 @@ <item quantity="one">இந்த இடுகையில் %1$d hashtag-ஐ விட அதிகம் உள்ளது</item> <item quantity="other">இந்த இடுகையில் %1$d hashtags-ஐ விட அதிகம் உள்ளது</item> </plurals> - <string name="preview_card_image_for"> %1$s க்கான முன்னோட்ட அட்டைப் படம்</string> + <string name="preview_card_image_for">%1$s க்கான முன்னோட்ட அட்டைப் படம்</string> <string name="privacy_options">தனியுரிமை விருப்பங்கள்</string> <string name="privacy_policy">தனியுரிமைக் கொள்கை</string> <string name="private_conversation_notification">தனிப்பட்ட செய்தியை மறைக்குறியாக்க இயலவில்லை \n \n %1$s மற்றும் %2$s க்கு இடையே தனிப்பட்ட/ குறியாக்கப்பட்ட உரையாடலில் நீங்கள் மேற்கோள் காட்டப்பட்டீர்கள்.</string> @@ -2754,11 +2734,8 @@ <string name="proxy_section">Proxy Relays</string> <string name="proxy_section_explainer">filter.nostr.wine போன்ற ஒருங்கிணைப்பு relays, பயன்பாடு உங்கள் ஊட்டங்களை பதிவிறக்க பயன்படுத்த வேண்டும். இது அவுட்பாக்ஸ் மாதிரியை மாற்றுகிறது, பயன்பாட்டை உங்கள் பட்டியல்களில் உள்ள relays மட்டுமே இணைக்கும்.</string> <string name="public_chat">பொது அரட்டை</string> - <string name="public_chat_explainer">பொது அரட்டைகள் Nostr-ல் அனைவருக்கும் தெரியும் மற்றும் யாரும் - அவற்றில் பங்கேற்கலாம். அவை குறிப்பிட்ட தலைப்புகளை சுற்றிய திறந்த சமூகங்களுக்கு மிகவும் ஏற்றவை. - அதன் relays-ல் இடுகைகளை நீக்குவதன் மூலம் மிதவாண்மையை கட்டுப்படுத்தலாம்</string> - <string name="public_chat_relays_explainer">இந்த குழுவை ஹோஸ்ட் செய்யும் 1–3 relays-ஐ உள்ளிடவும். - Nostr கிளையன்கள் செய்திகளை எங்கிருந்து பதிவிறக்கம் செய்வது மற்றும் உங்கள் செய்திகளை எங்கே அனுப்புவது என்பதை அறிய இந்த அமைப்பை பயன்படுத்துகின்றன.</string> + <string name="public_chat_explainer">பொது அரட்டைகள் Nostr-ல் அனைவருக்கும் தெரியும் மற்றும் யாரும் அவற்றில் பங்கேற்கலாம். அவை குறிப்பிட்ட தலைப்புகளை சுற்றிய திறந்த சமூகங்களுக்கு மிகவும் ஏற்றவை. அதன் relays-ல் இடுகைகளை நீக்குவதன் மூலம் மிதவாண்மையை கட்டுப்படுத்தலாம்</string> + <string name="public_chat_relays_explainer">இந்த குழுவை ஹோஸ்ட் செய்யும் 1–3 relays-ஐ உள்ளிடவும். Nostr கிளையன்கள் செய்திகளை எங்கிருந்து பதிவிறக்கம் செய்வது மற்றும் உங்கள் செய்திகளை எங்கே அனுப்புவது என்பதை அறிய இந்த அமைப்பை பயன்படுத்துகின்றன.</string> <string name="public_chat_relays_title">Relay கள்</string> <string name="public_chat_title">பொது அரட்டை மெட்டாடேட்டா</string> <string name="public_chats">பொது அரட்டைகள்</string> @@ -2783,10 +2760,7 @@ <string name="read_only_user">படிக்க மட்டும் பயனர்</string> <string name="record_a_message_description">செய்தி பதிவு செய்ய கிளிக் செய்து பிடிக்கவும்</string> <string name="relay_chat">Relay அரட்டை</string> - <string name="relay_chat_explainer">Relay அரட்டைகள் என்பவை அவற்றின் முகப்பு relay-ஆல் கட்டுப்படுத்தப்படும் அரட்டை குழுக்கள். - அவை Nostr-ல் அனைவருக்கும் தெரியும் மற்றும் யாரும் அவற்றில் பங்கேற்கலாம். - அவை குறிப்பிட்ட தலைப்புகளை சுற்றிய திறந்த சமூகங்களுக்கு மிகவும் ஏற்றவை. இந்த குழுக்களில் சில தற்காலிகமானவை - எனவே அரட்டை செய்திகள் காலப்போக்கில் மறைந்துவிடும்</string> + <string name="relay_chat_explainer">Relay அரட்டைகள் என்பவை அவற்றின் முகப்பு relay-ஆல் கட்டுப்படுத்தப்படும் அரட்டை குழுக்கள். அவை Nostr-ல் அனைவருக்கும் தெரியும் மற்றும் யாரும் அவற்றில் பங்கேற்கலாம். அவை குறிப்பிட்ட தலைப்புகளை சுற்றிய திறந்த சமூகங்களுக்கு மிகவும் ஏற்றவை. இந்த குழுக்களில் சில தற்காலிகமானவை எனவே அரட்டை செய்திகள் காலப்போக்கில் மறைந்துவிடும்</string> <string name="relay_chat_title">Relay அரட்டைகள்</string> <string name="relay_management_tab_events">நிகழ்வுகள்</string> <string name="relay_management_tab_ips">IPs</string> diff --git a/commonsUI/src/commonMain/composeResources/values-th-rTH/strings.xml b/commonsUI/src/commonMain/composeResources/values-th-rTH/strings.xml index 79bd7785d6..ae6a8e1778 100644 --- a/commonsUI/src/commonMain/composeResources/values-th-rTH/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-th-rTH/strings.xml @@ -158,7 +158,7 @@ <string name="send_payment_min_onchain">การ zap แบบ on-chain ต้องใช้อย่างน้อย %1$s sats</string> <string name="send_payment_cashu_insufficient">ยอดคงเหลือใน mint ที่ผู้รับยอมรับไม่เพียงพอ เติมเงินในกระเป๋า Cashu ก่อน</string> <string name="send_payment_onchain_fee">ค่าธรรมเนียมเครือข่าย</string> - <string name="lightning_tips">ส่งทิปให้เบา ๆ </string> + <string name="lightning_tips">ส่งทิปให้เบา ๆ</string> <string name="note_to_receiver">โน้ตถึงผู้รับ</string> <string name="thank_you_so_much">ขอบคุณมาก!</string> <string name="amount_in_sats">จำนวนในหน่วย sat</string> @@ -269,14 +269,8 @@ <string name="mark_all_known_as_read">ทำเครื่องหมายอ่านแล้วในแชทที่รู้จักทั้งหมด</string> <string name="mark_all_new_as_read">ทำเครื่องหมายว่าอ่านแล้วทั้งหมดสำหรับข้อความใหม่</string> <string name="mark_all_as_read">ทพเครื่องหมายว่าอ่านแล้วทั้งหมด</string> - <string name="account_backup_tips2_md"> ## เคล็ดลับการสำรองข้อมูลและความปลอดภัยที่สำคัญ - \n\nบัญชีของคุณได้รับการรักษาความปลอดภัยด้วยคีย์ลับ คีย์เป็นรหัสชุดตัวอักษรยาวขึ้นต้นด้วย **nsec1** ใครก็ตามที่สามารถเข้าถึงรหัสชุดนี้ได้ จะสามารถโพสต์และเปลี่ยนแปลงข้อมูลประจำตัวของคุณได้ - - \n\n- **ห้าม** ใส่รหัสชุดนี้ในเว็บไซต์หรือซอฟต์แวร์ที่คุณไม่ไว้ใจ - \n- ทีมพัฒนา Amethyst **จะไม่มีวัน** ขอรหัสชุดนี้ของคุณ - \n- **ควร** สำรองข้อมูลรหัสชุดนี้ไว้เพื่อกู้คืนบัญชีของคุณ เราแนะนำให้ใช้โปรแกรมจัดการรหัสผ่าน</string> - <string name="account_backup_tips3_md"> สำหรับการรักษาความปลอดภัยเพิ่มเติม คุณสามารถเข้ารหัสคีย์ของคุณด้วยรหัสผ่าน คีย์นี้ขึ้นต้นด้วย **ncryptsec1** และไม่สามารถใช้งานได้หากไม่มีรหัสผ่านของคุณ - \n\n**แต่** หากคุณสูญหายรหัสผ่าน คุณจะไม่สามารถกู้คืนคีย์ของคุณได้ </string> + <string name="account_backup_tips2_md">## เคล็ดลับการสำรองข้อมูลและความปลอดภัยที่สำคัญ \n\nบัญชีของคุณได้รับการรักษาความปลอดภัยด้วยคีย์ลับ คีย์เป็นรหัสชุดตัวอักษรยาวขึ้นต้นด้วย **nsec1** ใครก็ตามที่สามารถเข้าถึงรหัสชุดนี้ได้ จะสามารถโพสต์และเปลี่ยนแปลงข้อมูลประจำตัวของคุณได้ \n\n- **ห้าม** ใส่รหัสชุดนี้ในเว็บไซต์หรือซอฟต์แวร์ที่คุณไม่ไว้ใจ \n- ทีมพัฒนา Amethyst **จะไม่มีวัน** ขอรหัสชุดนี้ของคุณ \n- **ควร** สำรองข้อมูลรหัสชุดนี้ไว้เพื่อกู้คืนบัญชีของคุณ เราแนะนำให้ใช้โปรแกรมจัดการรหัสผ่าน</string> + <string name="account_backup_tips3_md">สำหรับการรักษาความปลอดภัยเพิ่มเติม คุณสามารถเข้ารหัสคีย์ของคุณด้วยรหัสผ่าน คีย์นี้ขึ้นต้นด้วย **ncryptsec1** และไม่สามารถใช้งานได้หากไม่มีรหัสผ่านของคุณ \n\n**แต่** หากคุณสูญหายรหัสผ่าน คุณจะไม่สามารถกู้คืนคีย์ของคุณได้</string> <string name="encrypt_and_copy_my_secret_key">เข้ารหัสและคัดลอกคีย์ความปลอดภัยของฉัน</string> <string name="badge_award_image_for">ภาพเหรียญตราสําหรับ %1$s</string> <string name="badge_award_image">"รูปภาพรางวัลป้าย</string> @@ -1831,7 +1825,7 @@ <string name="back">ย้อนกลับ</string> <string name="backup_keys">สำรองข้อมูลรหัสลับ</string> <string name="badges">แบดจ์</string> - <string name="biometric_authentication_failed"> เกิดข้อผิดพลาดในการตรวจสอบ</string> + <string name="biometric_authentication_failed">เกิดข้อผิดพลาดในการตรวจสอบ</string> <string name="biometric_authentication_failed_explainer">เกิดข้อผิดพลาดจากการยืนยันตนด้วยชีวภาพ</string> <string name="biometric_authentication_failed_explainer_with_error">เกิดข้อผิดพลาดจากการยืนยันตนด้วยชีวภาพ: Error: %1$s</string> <string name="block_only">บล๊อก</string> @@ -1894,7 +1888,7 @@ <string name="cashu">โทเคน Cashu</string> <string name="cashu_checking_mint">กำลังตรวจสอบ mint…</string> <string name="cashu_failed_redemption">ไม่สามารถใช้งาน Cashu</string> - <string name="cashu_failed_redemption_explainer_error_msg">ผู้ให้บริการ Mint เกิดข้อผิดพลาด : %1$s </string> + <string name="cashu_failed_redemption_explainer_error_msg">ผู้ให้บริการ Mint เกิดข้อผิดพลาด : %1$s</string> <string name="cashu_history_incoming">รับแล้ว</string> <string name="cashu_history_outgoing">ส่งแล้ว</string> <string name="cashu_mint_matches_header">Mints ที่ตรงกัน</string> @@ -1986,15 +1980,7 @@ <string name="could_not_fetch_invoice_from_details">ไม่สามารถดึงใบแจ้งหนี้จาก %1$s: %2$s</string> <string name="could_not_open_the_compressed_file">ไม่สามารถเปิดดูข้อมูลที่ถูกบีบอัดได้</string> <string name="couldnt_find_nwc_wallets">ไม่พบกระเป๋าเงิน</string> - <string name="couldnt_find_nwc_wallets_description">Amethyst ไม่พบแอปกระเป๋าเงินที่รองรับ Nostr Wallet Connect (NWC) -\n\nสิ่งที่คุณทำได้: -\n\t1. ตรวจสอบการรองรับ NWC: ตรวจสอบอีกครั้งว่าแอปกระเป๋าเงินที่คุณต้องการใช้รองรับ Nostr Wallet Connect หรือไม่ -\n\t2. สร้างการเชื่อมต่อ: ไปที่แอปกระเป๋าเงินของคุณและสร้างการเชื่อมต่อ NWC ใหม่ -\n\t3. เชื่อมกับ Amethyst: กระเป๋าเงินของคุณมักจะให้ตัวเลือกสามแบบ: -\n\t\t3.1 ลิงก์อัตโนมัติ: ปุ่มเพื่อเปิดและเชื่อมต่อกับ Amethyst โดยอัตโนมัติ หากมี ให้กดปุ่มนั้น เปิด Amethyst และกดบันทึกที่หน้าจอใหม่ -\n\t\t3.2 ลิงก์ด้วยตนเอง: URI การเชื่อมต่อที่คุณสามารถคัดลอกและกดไอคอนวางบนหน้าจอนี้เพื่อป้อนโดยตรง -\n\t\t3.3 QR Code: กระเป๋าเงินสร้าง QR Code ที่คุณสามารถอ่านได้จากปุ่ม QR code ในหน้าจอนี้ - </string> + <string name="couldnt_find_nwc_wallets_description">Amethyst ไม่พบแอปกระเป๋าเงินที่รองรับ Nostr Wallet Connect (NWC) \n\nสิ่งที่คุณทำได้: \n\t1. ตรวจสอบการรองรับ NWC: ตรวจสอบอีกครั้งว่าแอปกระเป๋าเงินที่คุณต้องการใช้รองรับ Nostr Wallet Connect หรือไม่ \n\t2. สร้างการเชื่อมต่อ: ไปที่แอปกระเป๋าเงินของคุณและสร้างการเชื่อมต่อ NWC ใหม่ \n\t3. เชื่อมกับ Amethyst: กระเป๋าเงินของคุณมักจะให้ตัวเลือกสามแบบ: \n\t\t3.1 ลิงก์อัตโนมัติ: ปุ่มเพื่อเปิดและเชื่อมต่อกับ Amethyst โดยอัตโนมัติ หากมี ให้กดปุ่มนั้น เปิด Amethyst และกดบันทึกที่หน้าจอใหม่ \n\t\t3.2 ลิงก์ด้วยตนเอง: URI การเชื่อมต่อที่คุณสามารถคัดลอกและกดไอคอนวางบนหน้าจอนี้เพื่อป้อนโดยตรง \n\t\t3.3 QR Code: กระเป๋าเงินสร้าง QR Code ที่คุณสามารถอ่านได้จากปุ่ม QR code ในหน้าจอนี้</string> <string name="create">สร้าง</string> <string name="d">วัน</string> <string name="danger_zone">เขตอันตราย</string> diff --git a/commonsUI/src/commonMain/composeResources/values-th/strings.xml b/commonsUI/src/commonMain/composeResources/values-th/strings.xml index 60a0ddb5c5..a4d606f00b 100644 --- a/commonsUI/src/commonMain/composeResources/values-th/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-th/strings.xml @@ -144,7 +144,7 @@ <string name="send_payment_min_onchain">การ zap แบบ on-chain ต้องใช้อย่างน้อย %1$s sats</string> <string name="send_payment_cashu_insufficient">ยอดคงเหลือใน mint ที่ผู้รับยอมรับไม่เพียงพอ เติมเงินในกระเป๋า Cashu ก่อน</string> <string name="send_payment_onchain_fee">ค่าธรรมเนียมเครือข่าย</string> - <string name="lightning_tips">ส่งทิปให้เบา ๆ </string> + <string name="lightning_tips">ส่งทิปให้เบา ๆ</string> <string name="note_to_receiver">โน้ตถึงผู้รับ</string> <string name="thank_you_so_much">ขอบคุณมาก!</string> <string name="amount_in_sats">จำนวนในหน่วย sat</string> @@ -257,14 +257,8 @@ <string name="mark_all_known_as_read">ทำเครื่องหมายอ่านแล้วในแชทที่รู้จักทั้งหมด</string> <string name="mark_all_new_as_read">ทำเครื่องหมายว่าอ่านแล้วทั้งหมดสำหรับข้อความใหม่</string> <string name="mark_all_as_read">ทพเครื่องหมายว่าอ่านแล้วทั้งหมด</string> - <string name="account_backup_tips2_md"> ## เคล็ดลับการสำรองข้อมูลและความปลอดภัยที่สำคัญ - \n\nบัญชีของคุณได้รับการรักษาความปลอดภัยด้วยคีย์ลับ คีย์เป็นรหัสชุดตัวอักษรยาวขึ้นต้นด้วย **nsec1** ใครก็ตามที่สามารถเข้าถึงรหัสชุดนี้ได้ จะสามารถโพสต์และเปลี่ยนแปลงข้อมูลประจำตัวของคุณได้ - - \n\n- **ห้าม** ใส่รหัสชุดนี้ในเว็บไซต์หรือซอฟต์แวร์ที่คุณไม่ไว้ใจ - \n- ทีมพัฒนา Amethyst **จะไม่มีวัน** ขอรหัสชุดนี้ของคุณ - \n- **ควร** สำรองข้อมูลรหัสชุดนี้ไว้เพื่อกู้คืนบัญชีของคุณ เราแนะนำให้ใช้โปรแกรมจัดการรหัสผ่าน</string> - <string name="account_backup_tips3_md"> สำหรับการรักษาความปลอดภัยเพิ่มเติม คุณสามารถเข้ารหัสคีย์ของคุณด้วยรหัสผ่าน คีย์นี้ขึ้นต้นด้วย **ncryptsec1** และไม่สามารถใช้งานได้หากไม่มีรหัสผ่านของคุณ - \n\n**แต่** หากคุณสูญหายรหัสผ่าน คุณจะไม่สามารถกู้คืนคีย์ของคุณได้ </string> + <string name="account_backup_tips2_md">## เคล็ดลับการสำรองข้อมูลและความปลอดภัยที่สำคัญ \n\nบัญชีของคุณได้รับการรักษาความปลอดภัยด้วยคีย์ลับ คีย์เป็นรหัสชุดตัวอักษรยาวขึ้นต้นด้วย **nsec1** ใครก็ตามที่สามารถเข้าถึงรหัสชุดนี้ได้ จะสามารถโพสต์และเปลี่ยนแปลงข้อมูลประจำตัวของคุณได้ \n\n- **ห้าม** ใส่รหัสชุดนี้ในเว็บไซต์หรือซอฟต์แวร์ที่คุณไม่ไว้ใจ \n- ทีมพัฒนา Amethyst **จะไม่มีวัน** ขอรหัสชุดนี้ของคุณ \n- **ควร** สำรองข้อมูลรหัสชุดนี้ไว้เพื่อกู้คืนบัญชีของคุณ เราแนะนำให้ใช้โปรแกรมจัดการรหัสผ่าน</string> + <string name="account_backup_tips3_md">สำหรับการรักษาความปลอดภัยเพิ่มเติม คุณสามารถเข้ารหัสคีย์ของคุณด้วยรหัสผ่าน คีย์นี้ขึ้นต้นด้วย **ncryptsec1** และไม่สามารถใช้งานได้หากไม่มีรหัสผ่านของคุณ \n\n**แต่** หากคุณสูญหายรหัสผ่าน คุณจะไม่สามารถกู้คืนคีย์ของคุณได้</string> <string name="encrypt_and_copy_my_secret_key">เข้ารหัสและคัดลอกคีย์ความปลอดภัยของฉัน</string> <string name="badge_award_image_for">ภาพเหรียญตราสําหรับ %1$s</string> <string name="badge_award_image">"รูปภาพรางวัลป้าย</string> @@ -1883,7 +1877,7 @@ <string name="back">ย้อนกลับ</string> <string name="backup_keys">สำรองข้อมูลรหัสลับ</string> <string name="badges">แบดจ์</string> - <string name="biometric_authentication_failed"> เกิดข้อผิดพลาดในการตรวจสอบ</string> + <string name="biometric_authentication_failed">เกิดข้อผิดพลาดในการตรวจสอบ</string> <string name="biometric_authentication_failed_explainer">เกิดข้อผิดพลาดจากการยืนยันตนด้วยชีวภาพ</string> <string name="biometric_authentication_failed_explainer_with_error">เกิดข้อผิดพลาดจากการยืนยันตนด้วยชีวภาพ: Error: %1$s</string> <string name="block_only">บล๊อก</string> @@ -1950,7 +1944,7 @@ <string name="cashu">โทเคน Cashu</string> <string name="cashu_checking_mint">กำลังตรวจสอบ mint…</string> <string name="cashu_failed_redemption">ไม่สามารถใช้งาน Cashu</string> - <string name="cashu_failed_redemption_explainer_error_msg">ผู้ให้บริการ Mint เกิดข้อผิดพลาด : %1$s </string> + <string name="cashu_failed_redemption_explainer_error_msg">ผู้ให้บริการ Mint เกิดข้อผิดพลาด : %1$s</string> <string name="cashu_history_incoming">รับแล้ว</string> <string name="cashu_history_outgoing">ส่งแล้ว</string> <string name="cashu_mint_matches_header">Mints ที่ตรงกัน</string> @@ -2042,15 +2036,7 @@ <string name="could_not_fetch_invoice_from_details">ไม่สามารถดึงใบแจ้งหนี้จาก %1$s: %2$s</string> <string name="could_not_open_the_compressed_file">ไม่สามารถเปิดดูข้อมูลที่ถูกบีบอัดได้</string> <string name="couldnt_find_nwc_wallets">ไม่พบกระเป๋าเงิน</string> - <string name="couldnt_find_nwc_wallets_description">Amethyst ไม่พบแอปกระเป๋าเงินที่รองรับ Nostr Wallet Connect (NWC) -\n\nสิ่งที่คุณทำได้: -\n\t1. ตรวจสอบการรองรับ NWC: ตรวจสอบอีกครั้งว่าแอปกระเป๋าเงินที่คุณต้องการใช้รองรับ Nostr Wallet Connect หรือไม่ -\n\t2. สร้างการเชื่อมต่อ: ไปที่แอปกระเป๋าเงินของคุณและสร้างการเชื่อมต่อ NWC ใหม่ -\n\t3. เชื่อมกับ Amethyst: กระเป๋าเงินของคุณมักจะให้ตัวเลือกสามแบบ: -\n\t\t3.1 ลิงก์อัตโนมัติ: ปุ่มเพื่อเปิดและเชื่อมต่อกับ Amethyst โดยอัตโนมัติ หากมี ให้กดปุ่มนั้น เปิด Amethyst และกดบันทึกที่หน้าจอใหม่ -\n\t\t3.2 ลิงก์ด้วยตนเอง: URI การเชื่อมต่อที่คุณสามารถคัดลอกและกดไอคอนวางบนหน้าจอนี้เพื่อป้อนโดยตรง -\n\t\t3.3 QR Code: กระเป๋าเงินสร้าง QR Code ที่คุณสามารถอ่านได้จากปุ่ม QR code ในหน้าจอนี้ - </string> + <string name="couldnt_find_nwc_wallets_description">Amethyst ไม่พบแอปกระเป๋าเงินที่รองรับ Nostr Wallet Connect (NWC) \n\nสิ่งที่คุณทำได้: \n\t1. ตรวจสอบการรองรับ NWC: ตรวจสอบอีกครั้งว่าแอปกระเป๋าเงินที่คุณต้องการใช้รองรับ Nostr Wallet Connect หรือไม่ \n\t2. สร้างการเชื่อมต่อ: ไปที่แอปกระเป๋าเงินของคุณและสร้างการเชื่อมต่อ NWC ใหม่ \n\t3. เชื่อมกับ Amethyst: กระเป๋าเงินของคุณมักจะให้ตัวเลือกสามแบบ: \n\t\t3.1 ลิงก์อัตโนมัติ: ปุ่มเพื่อเปิดและเชื่อมต่อกับ Amethyst โดยอัตโนมัติ หากมี ให้กดปุ่มนั้น เปิด Amethyst และกดบันทึกที่หน้าจอใหม่ \n\t\t3.2 ลิงก์ด้วยตนเอง: URI การเชื่อมต่อที่คุณสามารถคัดลอกและกดไอคอนวางบนหน้าจอนี้เพื่อป้อนโดยตรง \n\t\t3.3 QR Code: กระเป๋าเงินสร้าง QR Code ที่คุณสามารถอ่านได้จากปุ่ม QR code ในหน้าจอนี้</string> <string name="create">สร้าง</string> <string name="d">วัน</string> <string name="danger_zone">เขตอันตราย</string> diff --git a/commonsUI/src/commonMain/composeResources/values-tr-rTR/strings.xml b/commonsUI/src/commonMain/composeResources/values-tr-rTR/strings.xml index ac074b789c..59fd97ae80 100644 --- a/commonsUI/src/commonMain/composeResources/values-tr-rTR/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-tr-rTR/strings.xml @@ -71,7 +71,7 @@ <string name="scan_qr">QR Tara</string> <string name="show_anyway">Yine De Göster</string> <string name="post_was_hidden">Bu gönderi, gizlediğiniz kullanıcıları veya kelimeleri içerdiği için gizlendi</string> - <string name="post_was_flagged_as_inappropriate_by">Gönderi uygun değil olarak işaretlendi tarafından: </string> + <string name="post_was_flagged_as_inappropriate_by">Gönderi uygun değil olarak işaretlendi tarafından:</string> <string name="post_not_found">Gönderi bulunamadı</string> <string name="channel_image">Kanal Resmi</string> <string name="referenced_event_not_found">Referanslı etkinlik bulunamadı</string> @@ -272,15 +272,8 @@ <string name="mark_all_known_as_read">Tüm bilinenleri okundu olarak işaretle</string> <string name="mark_all_new_as_read">Tüm yenileri okundu olarak işaretle</string> <string name="mark_all_as_read">Tümünü okundu olarak işaretle</string> - <string name="account_backup_tips2_md"> ## Anahtar Yedekleme ve Güvenlik İpuçları - \n\nHesabınız bir gizli anahtarla korunmaktadır. Anahtar, **nsec1** ile başlayan uzun bir karakter dizisidir. Bu gizli anahtara erişimi olan herkes kimliğiniz adına gönderi yapabilir ve kimliğinizi değiştirebilir. - \n\n- Gizli anahtarınızı güvenmediğiniz herhangi bir web sitesine veya yazılıma **koymayın**. - \n- Amethyst geliştiricileri gizli anahtarınızı **asla** istemez. - \n- Hesap kurtarma için gizli anahtarınızın güvenli bir yedeğini **alın**. Bir parola yöneticisi kullanmanızı öneririz. - </string> - <string name="account_backup_tips3_md"> Ek güvenlik için anahtarınızı bir parola ile şifreleyebilirsiniz. Bu anahtar **ncryptsec1** ile başlar ve parolanız olmadan kullanılamaz. - \n\nParolanızı kaybederseniz anahtarınızı kurtaramazsınız. - </string> + <string name="account_backup_tips2_md">## Anahtar Yedekleme ve Güvenlik İpuçları \n\nHesabınız bir gizli anahtarla korunmaktadır. Anahtar, **nsec1** ile başlayan uzun bir karakter dizisidir. Bu gizli anahtara erişimi olan herkes kimliğiniz adına gönderi yapabilir ve kimliğinizi değiştirebilir. \n\n- Gizli anahtarınızı güvenmediğiniz herhangi bir web sitesine veya yazılıma **koymayın**. \n- Amethyst geliştiricileri gizli anahtarınızı **asla** istemez. \n- Hesap kurtarma için gizli anahtarınızın güvenli bir yedeğini **alın**. Bir parola yöneticisi kullanmanızı öneririz.</string> + <string name="account_backup_tips3_md">Ek güvenlik için anahtarınızı bir parola ile şifreleyebilirsiniz. Bu anahtar **ncryptsec1** ile başlar ve parolanız olmadan kullanılamaz. \n\nParolanızı kaybederseniz anahtarınızı kurtaramazsınız.</string> <string name="encrypt_and_copy_my_secret_key">Gizli anahtarımı şifrele ve kopyala</string> <string name="badge_award_image_for">%1$s için rozet ödülü görseli</string> <string name="badge_award_image">"Rozet ödülü görseli</string> @@ -966,9 +959,7 @@ <string name="push_server_none_explainer">Push Bildirimlerini Devre Dışı Bırakır</string> <string name="push_server_uses_app_explainer">%1$s uygulamasını kullanır</string> <string name="push_server_install_app">Push Bildirim Kurulumu</string> - <string name="push_server_install_app_description"> Push bildirimleri almak için [Ntfy](https://ntfy.sh/) gibi [Unified Push](https://unifiedpush.org/) destekleyen herhangi bir uygulama yükleyin. - Yükledikten sonra, Ayarlar'da kullanmak istediğiniz uygulamayı seçin. - </string> + <string name="push_server_install_app_description">Push bildirimleri almak için [Ntfy](https://ntfy.sh/) gibi [Unified Push](https://unifiedpush.org/) destekleyen herhangi bir uygulama yükleyin. Yükledikten sonra, Ayarlar'da kullanmak istediğiniz uygulamayı seçin.</string> <string name="thread_title">Konu</string> <string name="send_the_seller_a_message">Satıcıya mesaj gönderin</string> <string name="hi_there_is_this_still_available">Merhaba, bu hâlâ mevcut mu?</string> @@ -2024,15 +2015,7 @@ <string name="could_not_fetch_invoice_from_details">%1$s'den fatura alınamadı: %2$s</string> <string name="could_not_open_the_compressed_file">Sıkıştırılmış dosya açılamadı</string> <string name="couldnt_find_nwc_wallets">Cüzdan bulunamadı</string> - <string name="couldnt_find_nwc_wallets_description">Amethyst, Nostr Wallet Connect (NWC) destekleyen herhangi bir cüzdan uygulaması bulamadı. -\n\nYapabilecekleriniz: -\n\t1. NWC Desteğini Doğrulayın: Tercih ettiğiniz cüzdan uygulamasının Nostr Wallet Connect ile uyumlu olup olmadığını iki kez kontrol edin. -\n\t2. Bağlantı Oluşturun: Cüzdan uygulamanıza gidin ve yeni bir NWC bağlantısı oluşturun. -\n\t3. Amethyst'e Bağlayın: Cüzdanınız genellikle size üç seçenek sunar: -\n\t\t3.1 Otomatik Bağlantı: Amethyst'i otomatik olarak açıp bağlamak için bir düğme. Öyleyse o düğmeye tıklayın, Amethyst'i açın ve yeni ekranda kaydet'e basın. -\n\t\t3.2 Manuel Bağlantı: Doğrudan girmek için bu ekrandaki yapıştır simgesine tıklayabileceğiniz bir Bağlantı URI'si kopyalayın. -\n\t\t3.3 QR Kodu: Cüzdan, bu ekrandaki QR kodu düğmesiyle okuyabileceğiniz bir QR kodu oluşturur. - </string> + <string name="couldnt_find_nwc_wallets_description">Amethyst, Nostr Wallet Connect (NWC) destekleyen herhangi bir cüzdan uygulaması bulamadı. \n\nYapabilecekleriniz: \n\t1. NWC Desteğini Doğrulayın: Tercih ettiğiniz cüzdan uygulamasının Nostr Wallet Connect ile uyumlu olup olmadığını iki kez kontrol edin. \n\t2. Bağlantı Oluşturun: Cüzdan uygulamanıza gidin ve yeni bir NWC bağlantısı oluşturun. \n\t3. Amethyst'e Bağlayın: Cüzdanınız genellikle size üç seçenek sunar: \n\t\t3.1 Otomatik Bağlantı: Amethyst'i otomatik olarak açıp bağlamak için bir düğme. Öyleyse o düğmeye tıklayın, Amethyst'i açın ve yeni ekranda kaydet'e basın. \n\t\t3.2 Manuel Bağlantı: Doğrudan girmek için bu ekrandaki yapıştır simgesine tıklayabileceğiniz bir Bağlantı URI'si kopyalayın. \n\t\t3.3 QR Kodu: Cüzdan, bu ekrandaki QR kodu düğmesiyle okuyabileceğiniz bir QR kodu oluşturur.</string> <string name="create">Oluştur</string> <string name="d">g</string> <string name="danger_zone">Tehlike Bölgesi</string> @@ -2677,11 +2660,8 @@ <string name="proxy_section">Proxy Relay'leri</string> <string name="proxy_section_explainer">Uygulamanın akışlarınızı indirmek için kullanması gereken filter.nostr.wine gibi toplayıcı relay'ler. Bu, giden kutusu modelinin yerini alır ve uygulamanın yalnızca listelerinizdeki relay'lere bağlanmasını sağlar.</string> <string name="public_chat">Herkese Açık Chat</string> - <string name="public_chat_explainer">Genel sohbetler Nostr'daki herkese görünürdür ve herkes - katılabilir. Belirli konular etrafında açık topluluklar için idealdirler. - Moderasyon, relay'lerindeki gönderilerin silinmesiyle kontrol edilebilir</string> - <string name="public_chat_relays_explainer">Bu grubu barındıran 1–3 relay ekleyin. - Nostr istemcileri mesajları nereden indireceğini ve mesajlarınızı nereye göndereceğini öğrenmek için bu ayarı kullanır.</string> + <string name="public_chat_explainer">Genel sohbetler Nostr'daki herkese görünürdür ve herkes katılabilir. Belirli konular etrafında açık topluluklar için idealdirler. Moderasyon, relay'lerindeki gönderilerin silinmesiyle kontrol edilebilir</string> + <string name="public_chat_relays_explainer">Bu grubu barındıran 1–3 relay ekleyin. Nostr istemcileri mesajları nereden indireceğini ve mesajlarınızı nereye göndereceğini öğrenmek için bu ayarı kullanır.</string> <string name="public_chat_relays_title">relay'ler</string> <string name="public_chat_title">Genel Sohbet Meta Verisi</string> <string name="public_chats">Genel Sohbetler</string> @@ -2706,10 +2686,7 @@ <string name="read_only_user">Salt okunur kullanıcı</string> <string name="record_a_message_description">Sesli mesaj kaydetmek için tıklayıp basılı tutun</string> <string name="relay_chat">relay Sohbeti</string> - <string name="relay_chat_explainer">relay sohbetleri, ana relay'leri tarafından kontrol edilen sohbet gruplarıdır. - Nostr'daki herkese görünürdür ve herkes katılabilir. - Belirli konular etrafında açık topluluklar için idealdirler. Bu grupların bir kısmı geçici niteliktedir - ve bu nedenle sohbet mesajları zamanla silinir</string> + <string name="relay_chat_explainer">relay sohbetleri, ana relay'leri tarafından kontrol edilen sohbet gruplarıdır. Nostr'daki herkese görünürdür ve herkes katılabilir. Belirli konular etrafında açık topluluklar için idealdirler. Bu grupların bir kısmı geçici niteliktedir ve bu nedenle sohbet mesajları zamanla silinir</string> <string name="relay_chat_title">relay Sohbetleri</string> <string name="relay_management_tab_events">Etkinlikler</string> <string name="relay_management_tab_ips">IP'ler</string> diff --git a/commonsUI/src/commonMain/composeResources/values-tr/strings.xml b/commonsUI/src/commonMain/composeResources/values-tr/strings.xml index 989465fdb4..7716fa9b4b 100644 --- a/commonsUI/src/commonMain/composeResources/values-tr/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-tr/strings.xml @@ -51,7 +51,7 @@ <string name="scan_qr">QR Tara</string> <string name="show_anyway">Yine De Göster</string> <string name="post_was_hidden">Bu gönderi, gizlediğiniz kullanıcıları veya kelimeleri içerdiği için gizlendi</string> - <string name="post_was_flagged_as_inappropriate_by">Gönderi uygun değil olarak işaretlendi tarafından: </string> + <string name="post_was_flagged_as_inappropriate_by">Gönderi uygun değil olarak işaretlendi tarafından:</string> <string name="post_not_found">Gönderi bulunamadı</string> <string name="post_not_found_short">👀</string> <string name="channel_image">Kanal Resmi</string> @@ -259,17 +259,8 @@ <string name="mark_all_known_as_read">Tüm bilinenleri okundu olarak işaretle</string> <string name="mark_all_new_as_read">Tüm yenileri okundu olarak işaretle</string> <string name="mark_all_as_read">Tümünü okundu olarak işaretle</string> - <string name="account_backup_tips2_md"> - ## Anahtar Yedekleme ve Güvenlik İpuçları - \n\nHesabınız bir gizli anahtarla korunmaktadır. Anahtar, **nsec1** ile başlayan uzun bir karakter dizisidir. Bu gizli anahtara erişimi olan herkes kimliğiniz adına gönderi yapabilir ve kimliğinizi değiştirebilir. - \n\n- Gizli anahtarınızı güvenmediğiniz herhangi bir web sitesine veya yazılıma **koymayın**. - \n- Amethyst geliştiricileri gizli anahtarınızı **asla** istemez. - \n- Hesap kurtarma için gizli anahtarınızın güvenli bir yedeğini **alın**. Bir parola yöneticisi kullanmanızı öneririz. - </string> - <string name="account_backup_tips3_md"> - Ek güvenlik için anahtarınızı bir parola ile şifreleyebilirsiniz. Bu anahtar **ncryptsec1** ile başlar ve parolanız olmadan kullanılamaz. - \n\nParolanızı kaybederseniz anahtarınızı kurtaramazsınız. - </string> + <string name="account_backup_tips2_md">## Anahtar Yedekleme ve Güvenlik İpuçları \n\nHesabınız bir gizli anahtarla korunmaktadır. Anahtar, **nsec1** ile başlayan uzun bir karakter dizisidir. Bu gizli anahtara erişimi olan herkes kimliğiniz adına gönderi yapabilir ve kimliğinizi değiştirebilir. \n\n- Gizli anahtarınızı güvenmediğiniz herhangi bir web sitesine veya yazılıma **koymayın**. \n- Amethyst geliştiricileri gizli anahtarınızı **asla** istemez. \n- Hesap kurtarma için gizli anahtarınızın güvenli bir yedeğini **alın**. Bir parola yöneticisi kullanmanızı öneririz.</string> + <string name="account_backup_tips3_md">Ek güvenlik için anahtarınızı bir parola ile şifreleyebilirsiniz. Bu anahtar **ncryptsec1** ile başlar ve parolanız olmadan kullanılamaz. \n\nParolanızı kaybederseniz anahtarınızı kurtaramazsınız.</string> <string name="encrypt_and_copy_my_secret_key">Gizli anahtarımı şifrele ve kopyala</string> <string name="badge_award_image_for">%1$s için rozet ödülü görseli</string> <string name="badge_award_image">"Rozet ödülü görseli</string> @@ -962,10 +953,7 @@ <string name="push_server_none_explainer">Push Bildirimlerini Devre Dışı Bırakır</string> <string name="push_server_uses_app_explainer">%1$s uygulamasını kullanır</string> <string name="push_server_install_app">Push Bildirim Kurulumu</string> - <string name="push_server_install_app_description"> - Push bildirimleri almak için [Ntfy](https://ntfy.sh/) gibi [Unified Push](https://unifiedpush.org/) destekleyen herhangi bir uygulama yükleyin. - Yükledikten sonra, Ayarlar'da kullanmak istediğiniz uygulamayı seçin. - </string> + <string name="push_server_install_app_description">Push bildirimleri almak için [Ntfy](https://ntfy.sh/) gibi [Unified Push](https://unifiedpush.org/) destekleyen herhangi bir uygulama yükleyin. Yükledikten sonra, Ayarlar'da kullanmak istediğiniz uygulamayı seçin.</string> <string name="thread_title">Konu</string> <string name="send_the_seller_a_message">Satıcıya mesaj gönderin</string> <string name="hi_there_is_this_still_available">Merhaba, bu hâlâ mevcut mu?</string> @@ -2058,15 +2046,7 @@ <string name="could_not_fetch_invoice_from_details">%1$s'den fatura alınamadı: %2$s</string> <string name="could_not_open_the_compressed_file">Sıkıştırılmış dosya açılamadı</string> <string name="couldnt_find_nwc_wallets">Cüzdan bulunamadı</string> - <string name="couldnt_find_nwc_wallets_description">Amethyst, Nostr Wallet Connect (NWC) destekleyen herhangi bir cüzdan uygulaması bulamadı. -\n\nYapabilecekleriniz: -\n\t1. NWC Desteğini Doğrulayın: Tercih ettiğiniz cüzdan uygulamasının Nostr Wallet Connect ile uyumlu olup olmadığını iki kez kontrol edin. -\n\t2. Bağlantı Oluşturun: Cüzdan uygulamanıza gidin ve yeni bir NWC bağlantısı oluşturun. -\n\t3. Amethyst'e Bağlayın: Cüzdanınız genellikle size üç seçenek sunar: -\n\t\t3.1 Otomatik Bağlantı: Amethyst'i otomatik olarak açıp bağlamak için bir düğme. Öyleyse o düğmeye tıklayın, Amethyst'i açın ve yeni ekranda kaydet'e basın. -\n\t\t3.2 Manuel Bağlantı: Doğrudan girmek için bu ekrandaki yapıştır simgesine tıklayabileceğiniz bir Bağlantı URI'si kopyalayın. -\n\t\t3.3 QR Kodu: Cüzdan, bu ekrandaki QR kodu düğmesiyle okuyabileceğiniz bir QR kodu oluşturur. - </string> + <string name="couldnt_find_nwc_wallets_description">Amethyst, Nostr Wallet Connect (NWC) destekleyen herhangi bir cüzdan uygulaması bulamadı. \n\nYapabilecekleriniz: \n\t1. NWC Desteğini Doğrulayın: Tercih ettiğiniz cüzdan uygulamasının Nostr Wallet Connect ile uyumlu olup olmadığını iki kez kontrol edin. \n\t2. Bağlantı Oluşturun: Cüzdan uygulamanıza gidin ve yeni bir NWC bağlantısı oluşturun. \n\t3. Amethyst'e Bağlayın: Cüzdanınız genellikle size üç seçenek sunar: \n\t\t3.1 Otomatik Bağlantı: Amethyst'i otomatik olarak açıp bağlamak için bir düğme. Öyleyse o düğmeye tıklayın, Amethyst'i açın ve yeni ekranda kaydet'e basın. \n\t\t3.2 Manuel Bağlantı: Doğrudan girmek için bu ekrandaki yapıştır simgesine tıklayabileceğiniz bir Bağlantı URI'si kopyalayın. \n\t\t3.3 QR Kodu: Cüzdan, bu ekrandaki QR kodu düğmesiyle okuyabileceğiniz bir QR kodu oluşturur.</string> <string name="create">Oluştur</string> <string name="d">g</string> <string name="danger_zone">Tehlike Bölgesi</string> @@ -2754,11 +2734,8 @@ <string name="proxy_section">Proxy Relay'leri</string> <string name="proxy_section_explainer">Uygulamanın akışlarınızı indirmek için kullanması gereken filter.nostr.wine gibi toplayıcı relay'ler. Bu, giden kutusu modelinin yerini alır ve uygulamanın yalnızca listelerinizdeki relay'lere bağlanmasını sağlar.</string> <string name="public_chat">Herkese Açık Chat</string> - <string name="public_chat_explainer">Genel sohbetler Nostr'daki herkese görünürdür ve herkes - katılabilir. Belirli konular etrafında açık topluluklar için idealdirler. - Moderasyon, relay'lerindeki gönderilerin silinmesiyle kontrol edilebilir</string> - <string name="public_chat_relays_explainer">Bu grubu barındıran 1–3 relay ekleyin. - Nostr istemcileri mesajları nereden indireceğini ve mesajlarınızı nereye göndereceğini öğrenmek için bu ayarı kullanır.</string> + <string name="public_chat_explainer">Genel sohbetler Nostr'daki herkese görünürdür ve herkes katılabilir. Belirli konular etrafında açık topluluklar için idealdirler. Moderasyon, relay'lerindeki gönderilerin silinmesiyle kontrol edilebilir</string> + <string name="public_chat_relays_explainer">Bu grubu barındıran 1–3 relay ekleyin. Nostr istemcileri mesajları nereden indireceğini ve mesajlarınızı nereye göndereceğini öğrenmek için bu ayarı kullanır.</string> <string name="public_chat_relays_title">relay'ler</string> <string name="public_chat_title">Genel Sohbet Meta Verisi</string> <string name="public_chats">Genel Sohbetler</string> @@ -2783,10 +2760,7 @@ <string name="read_only_user">Salt okunur kullanıcı</string> <string name="record_a_message_description">Sesli mesaj kaydetmek için tıklayıp basılı tutun</string> <string name="relay_chat">relay Sohbeti</string> - <string name="relay_chat_explainer">relay sohbetleri, ana relay'leri tarafından kontrol edilen sohbet gruplarıdır. - Nostr'daki herkese görünürdür ve herkes katılabilir. - Belirli konular etrafında açık topluluklar için idealdirler. Bu grupların bir kısmı geçici niteliktedir - ve bu nedenle sohbet mesajları zamanla silinir</string> + <string name="relay_chat_explainer">relay sohbetleri, ana relay'leri tarafından kontrol edilen sohbet gruplarıdır. Nostr'daki herkese görünürdür ve herkes katılabilir. Belirli konular etrafında açık topluluklar için idealdirler. Bu grupların bir kısmı geçici niteliktedir ve bu nedenle sohbet mesajları zamanla silinir</string> <string name="relay_chat_title">relay Sohbetleri</string> <string name="relay_management_tab_events">Etkinlikler</string> <string name="relay_management_tab_ips">IP'ler</string> diff --git a/commonsUI/src/commonMain/composeResources/values-uk-rUA/strings.xml b/commonsUI/src/commonMain/composeResources/values-uk-rUA/strings.xml index a6e1bee6c5..5e60c8e1da 100644 --- a/commonsUI/src/commonMain/composeResources/values-uk-rUA/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-uk-rUA/strings.xml @@ -225,7 +225,7 @@ <string name="copy_user_id">Скопіювати ID користувача</string> <string name="unblock_user">Розблокувати користувача</string> <string name="user_is_blocked_hidden">Цього користувача заблоковано/заглушено. Їхні публікації приховано.</string> - <string name="npub_hex_username">npub, hex, нікнейм </string> + <string name="npub_hex_username">npub, hex, нікнейм</string> <string name="clear">Очистити</string> <string name="app_logo">Логотип додатка</string> <string name="nsec_npub_hex_private_key">nsec / npub / приватний ключ в hex</string> @@ -273,15 +273,8 @@ <string name="mark_all_known_as_read">Позначити всі відомі прочитаними</string> <string name="mark_all_new_as_read">Позначити всі нові прочитаними</string> <string name="mark_all_as_read">Позначити все прочитаним</string> - <string name="account_backup_tips2_md"> ## Поради щодо резервного копіювання та безпеки ключа - \n\nВаш обліковий запис захищений секретним ключем. Ключ — це довга послідовність символів, що починається з **nsec1**. Будь-хто, хто має доступ до цього секретного ключа, може публікувати та змінювати вашу особу. - \n\n- **Не** вводьте свій секретний ключ на жодному сайті чи в програмі, якій ви не довіряєте. - \n- Розробники Amethyst **ніколи** не запитуватимуть ваш секретний ключ. - \n- **Зробіть** надійне резервне копіювання секретного ключа для відновлення облікового запису. Рекомендуємо використовувати менеджер паролів. - </string> - <string name="account_backup_tips3_md"> Для додаткової безпеки ви можете зашифрувати ключ паролем. Цей ключ починається з **ncryptsec1** і не може бути використаний без вашого пароля. - \n\nЯкщо ви забудете пароль, ви не зможете відновити ключ. - </string> + <string name="account_backup_tips2_md">## Поради щодо резервного копіювання та безпеки ключа \n\nВаш обліковий запис захищений секретним ключем. Ключ — це довга послідовність символів, що починається з **nsec1**. Будь-хто, хто має доступ до цього секретного ключа, може публікувати та змінювати вашу особу. \n\n- **Не** вводьте свій секретний ключ на жодному сайті чи в програмі, якій ви не довіряєте. \n- Розробники Amethyst **ніколи** не запитуватимуть ваш секретний ключ. \n- **Зробіть** надійне резервне копіювання секретного ключа для відновлення облікового запису. Рекомендуємо використовувати менеджер паролів.</string> + <string name="account_backup_tips3_md">Для додаткової безпеки ви можете зашифрувати ключ паролем. Цей ключ починається з **ncryptsec1** і не може бути використаний без вашого пароля. \n\nЯкщо ви забудете пароль, ви не зможете відновити ключ.</string> <string name="encrypt_and_copy_my_secret_key">Зашифрувати та скопіювати мій секретний ключ</string> <string name="badge_award_image_for">Картинка значка-нагороди %1$s</string> <string name="badge_award_image">"Зображення нагороди значком</string> @@ -970,9 +963,7 @@ <string name="push_server_none_explainer">Вимикає push-сповіщення</string> <string name="push_server_uses_app_explainer">Використовує додаток %1$s</string> <string name="push_server_install_app">Налаштування push-сповіщень</string> - <string name="push_server_install_app_description"> Щоб отримувати push-сповіщення, встановіть будь-який додаток з підтримкою [Unified Push](https://unifiedpush.org/), наприклад [Ntfy](https://ntfy.sh/). - Після встановлення виберіть потрібний додаток у налаштуваннях. - </string> + <string name="push_server_install_app_description">Щоб отримувати push-сповіщення, встановіть будь-який додаток з підтримкою [Unified Push](https://unifiedpush.org/), наприклад [Ntfy](https://ntfy.sh/). Після встановлення виберіть потрібний додаток у налаштуваннях.</string> <string name="thread_title">Тема</string> <string name="send_the_seller_a_message">Надіслати продавцю повідомлення</string> <string name="hi_there_is_this_still_available">Привіт, це ще доступно?</string> @@ -2037,15 +2028,7 @@ <string name="could_not_fetch_invoice_from_details">Не вдалося отримати інвойс від %1$s: %2$s</string> <string name="could_not_open_the_compressed_file">Не вдалося відкрити стиснутий файл</string> <string name="couldnt_find_nwc_wallets">Гаманець не знайдено</string> - <string name="couldnt_find_nwc_wallets_description">Amethyst не знайшов жодного гаманця, який підтримує Nostr Wallet Connect (NWC). -\n\nЩо можна зробити: -\n\t1. Перевірте підтримку NWC: переконайтеся, що ваш гаманець сумісний з Nostr Wallet Connect. -\n\t2. Створіть підключення: відкрийте гаманець і створіть нове NWC-підключення. -\n\t3. Прив'яжіть до Amethyst: зазвичай гаманець надає три варіанти: -\n\t\t3.1 Автоматичне підключення: кнопка для автоматичного відкриття та підключення до Amethyst. Якщо так — натисніть її, відкрийте Amethyst і збережіть налаштування на новому екрані. -\n\t\t3.2 Ручне підключення: URI підключення, який можна скопіювати та вставити за допомогою значка вставки на цьому екрані. -\n\t\t3.3 QR-код: гаманець створює QR-код, який можна зчитати за допомогою кнопки QR-коду на цьому екрані. - </string> + <string name="couldnt_find_nwc_wallets_description">Amethyst не знайшов жодного гаманця, який підтримує Nostr Wallet Connect (NWC). \n\nЩо можна зробити: \n\t1. Перевірте підтримку NWC: переконайтеся, що ваш гаманець сумісний з Nostr Wallet Connect. \n\t2. Створіть підключення: відкрийте гаманець і створіть нове NWC-підключення. \n\t3. Прив'яжіть до Amethyst: зазвичай гаманець надає три варіанти: \n\t\t3.1 Автоматичне підключення: кнопка для автоматичного відкриття та підключення до Amethyst. Якщо так — натисніть її, відкрийте Amethyst і збережіть налаштування на новому екрані. \n\t\t3.2 Ручне підключення: URI підключення, який можна скопіювати та вставити за допомогою значка вставки на цьому екрані. \n\t\t3.3 QR-код: гаманець створює QR-код, який можна зчитати за допомогою кнопки QR-коду на цьому екрані.</string> <string name="create">Створити</string> <string name="d">дн</string> <string name="danger_zone">Небезпечна зона</string> @@ -2726,11 +2709,8 @@ <string name="proxy_section">Proxy relay-сервери</string> <string name="proxy_section_explainer">Агрегаторні relay-сервери, які застосунок має використовувати для завантаження ваших стрічок, наприклад filter.nostr.wine. Це замінює модель outbox і змусить застосунок підключатися лише до relay-серверів у ваших списках.</string> <string name="public_chat">Публічний чат</string> - <string name="public_chat_explainer">Публічні чати видимі всім у Nostr, і кожен - може брати в них участь. Вони чудово підходять для відкритих спільнот за певними темами. - Модерацію можна здійснювати, видаляючи публікації на відповідних relay</string> - <string name="public_chat_relays_explainer">Вкажіть від 1 до 3 relay, що розміщують цю групу. - Клієнти Nostr використовують це налаштування, щоб знати, звідки завантажувати повідомлення та куди їх надсилати.</string> + <string name="public_chat_explainer">Публічні чати видимі всім у Nostr, і кожен може брати в них участь. Вони чудово підходять для відкритих спільнот за певними темами. Модерацію можна здійснювати, видаляючи публікації на відповідних relay</string> + <string name="public_chat_relays_explainer">Вкажіть від 1 до 3 relay, що розміщують цю групу. Клієнти Nostr використовують це налаштування, щоб знати, звідки завантажувати повідомлення та куди їх надсилати.</string> <string name="public_chat_relays_title">Relay</string> <string name="public_chat_title">Метадані публічного чату</string> <string name="public_chats">Публічні чати</string> @@ -2755,10 +2735,7 @@ <string name="read_only_user">Користувач лише для читання</string> <string name="record_a_message_description">Натисніть і утримуйте, щоб записати повідомлення</string> <string name="relay_chat">Чат relay</string> - <string name="relay_chat_explainer">Чати relay — це групи чатів, керовані їхнім домашнім relay. - Вони видимі всім у Nostr, і кожен може брати в них участь. - Вони чудово підходять для відкритих спільнот за певними темами. Деякі з цих груп є тимчасовими, - і тому повідомлення чату зникають з часом</string> + <string name="relay_chat_explainer">Чати relay — це групи чатів, керовані їхнім домашнім relay. Вони видимі всім у Nostr, і кожен може брати в них участь. Вони чудово підходять для відкритих спільнот за певними темами. Деякі з цих груп є тимчасовими, і тому повідомлення чату зникають з часом</string> <string name="relay_chat_title">Чати relay</string> <string name="relay_management_tab_events">Події</string> <string name="relay_management_tab_ips">IP-адреси</string> diff --git a/commonsUI/src/commonMain/composeResources/values-uk/strings.xml b/commonsUI/src/commonMain/composeResources/values-uk/strings.xml index 53159e447e..bc7b9935b3 100644 --- a/commonsUI/src/commonMain/composeResources/values-uk/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-uk/strings.xml @@ -215,7 +215,7 @@ <string name="copy_user_id">Скопіювати ID користувача</string> <string name="unblock_user">Розблокувати користувача</string> <string name="user_is_blocked_hidden">Цього користувача заблоковано/заглушено. Їхні публікації приховано.</string> - <string name="npub_hex_username">npub, hex, нікнейм </string> + <string name="npub_hex_username">npub, hex, нікнейм</string> <string name="clear">Очистити</string> <string name="app_logo">Логотип додатка</string> <string name="nsec_npub_hex_private_key">nsec / npub / приватний ключ в hex</string> @@ -263,17 +263,8 @@ <string name="mark_all_known_as_read">Позначити всі відомі прочитаними</string> <string name="mark_all_new_as_read">Позначити всі нові прочитаними</string> <string name="mark_all_as_read">Позначити все прочитаним</string> - <string name="account_backup_tips2_md"> - ## Поради щодо резервного копіювання та безпеки ключа - \n\nВаш обліковий запис захищений секретним ключем. Ключ — це довга послідовність символів, що починається з **nsec1**. Будь-хто, хто має доступ до цього секретного ключа, може публікувати та змінювати вашу особу. - \n\n- **Не** вводьте свій секретний ключ на жодному сайті чи в програмі, якій ви не довіряєте. - \n- Розробники Amethyst **ніколи** не запитуватимуть ваш секретний ключ. - \n- **Зробіть** надійне резервне копіювання секретного ключа для відновлення облікового запису. Рекомендуємо використовувати менеджер паролів. - </string> - <string name="account_backup_tips3_md"> - Для додаткової безпеки ви можете зашифрувати ключ паролем. Цей ключ починається з **ncryptsec1** і не може бути використаний без вашого пароля. - \n\nЯкщо ви забудете пароль, ви не зможете відновити ключ. - </string> + <string name="account_backup_tips2_md">## Поради щодо резервного копіювання та безпеки ключа \n\nВаш обліковий запис захищений секретним ключем. Ключ — це довга послідовність символів, що починається з **nsec1**. Будь-хто, хто має доступ до цього секретного ключа, може публікувати та змінювати вашу особу. \n\n- **Не** вводьте свій секретний ключ на жодному сайті чи в програмі, якій ви не довіряєте. \n- Розробники Amethyst **ніколи** не запитуватимуть ваш секретний ключ. \n- **Зробіть** надійне резервне копіювання секретного ключа для відновлення облікового запису. Рекомендуємо використовувати менеджер паролів.</string> + <string name="account_backup_tips3_md">Для додаткової безпеки ви можете зашифрувати ключ паролем. Цей ключ починається з **ncryptsec1** і не може бути використаний без вашого пароля. \n\nЯкщо ви забудете пароль, ви не зможете відновити ключ.</string> <string name="encrypt_and_copy_my_secret_key">Зашифрувати та скопіювати мій секретний ключ</string> <string name="badge_award_image_for">Картинка значка-нагороди %1$s</string> <string name="badge_award_image">"Зображення нагороди значком</string> @@ -966,10 +957,7 @@ <string name="push_server_none_explainer">Вимикає push-сповіщення</string> <string name="push_server_uses_app_explainer">Використовує додаток %1$s</string> <string name="push_server_install_app">Налаштування push-сповіщень</string> - <string name="push_server_install_app_description"> - Щоб отримувати push-сповіщення, встановіть будь-який додаток з підтримкою [Unified Push](https://unifiedpush.org/), наприклад [Ntfy](https://ntfy.sh/). - Після встановлення виберіть потрібний додаток у налаштуваннях. - </string> + <string name="push_server_install_app_description">Щоб отримувати push-сповіщення, встановіть будь-який додаток з підтримкою [Unified Push](https://unifiedpush.org/), наприклад [Ntfy](https://ntfy.sh/). Після встановлення виберіть потрібний додаток у налаштуваннях.</string> <string name="thread_title">Тема</string> <string name="send_the_seller_a_message">Надіслати продавцю повідомлення</string> <string name="hi_there_is_this_still_available">Привіт, це ще доступно?</string> @@ -2078,15 +2066,7 @@ <string name="could_not_fetch_invoice_from_details">Не вдалося отримати інвойс від %1$s: %2$s</string> <string name="could_not_open_the_compressed_file">Не вдалося відкрити стиснутий файл</string> <string name="couldnt_find_nwc_wallets">Гаманець не знайдено</string> - <string name="couldnt_find_nwc_wallets_description">Amethyst не знайшов жодного гаманця, який підтримує Nostr Wallet Connect (NWC). -\n\nЩо можна зробити: -\n\t1. Перевірте підтримку NWC: переконайтеся, що ваш гаманець сумісний з Nostr Wallet Connect. -\n\t2. Створіть підключення: відкрийте гаманець і створіть нове NWC-підключення. -\n\t3. Прив'яжіть до Amethyst: зазвичай гаманець надає три варіанти: -\n\t\t3.1 Автоматичне підключення: кнопка для автоматичного відкриття та підключення до Amethyst. Якщо так — натисніть її, відкрийте Amethyst і збережіть налаштування на новому екрані. -\n\t\t3.2 Ручне підключення: URI підключення, який можна скопіювати та вставити за допомогою значка вставки на цьому екрані. -\n\t\t3.3 QR-код: гаманець створює QR-код, який можна зчитати за допомогою кнопки QR-коду на цьому екрані. - </string> + <string name="couldnt_find_nwc_wallets_description">Amethyst не знайшов жодного гаманця, який підтримує Nostr Wallet Connect (NWC). \n\nЩо можна зробити: \n\t1. Перевірте підтримку NWC: переконайтеся, що ваш гаманець сумісний з Nostr Wallet Connect. \n\t2. Створіть підключення: відкрийте гаманець і створіть нове NWC-підключення. \n\t3. Прив'яжіть до Amethyst: зазвичай гаманець надає три варіанти: \n\t\t3.1 Автоматичне підключення: кнопка для автоматичного відкриття та підключення до Amethyst. Якщо так — натисніть її, відкрийте Amethyst і збережіть налаштування на новому екрані. \n\t\t3.2 Ручне підключення: URI підключення, який можна скопіювати та вставити за допомогою значка вставки на цьому екрані. \n\t\t3.3 QR-код: гаманець створює QR-код, який можна зчитати за допомогою кнопки QR-коду на цьому екрані.</string> <string name="create">Створити</string> <string name="d">дн</string> <string name="danger_zone">Небезпечна зона</string> @@ -2816,11 +2796,8 @@ <string name="proxy_section">Proxy relay-сервери</string> <string name="proxy_section_explainer">Агрегаторні relay-сервери, які застосунок має використовувати для завантаження ваших стрічок, наприклад filter.nostr.wine. Це замінює модель outbox і змусить застосунок підключатися лише до relay-серверів у ваших списках.</string> <string name="public_chat">Публічний чат</string> - <string name="public_chat_explainer">Публічні чати видимі всім у Nostr, і кожен - може брати в них участь. Вони чудово підходять для відкритих спільнот за певними темами. - Модерацію можна здійснювати, видаляючи публікації на відповідних relay</string> - <string name="public_chat_relays_explainer">Вкажіть від 1 до 3 relay, що розміщують цю групу. - Клієнти Nostr використовують це налаштування, щоб знати, звідки завантажувати повідомлення та куди їх надсилати.</string> + <string name="public_chat_explainer">Публічні чати видимі всім у Nostr, і кожен може брати в них участь. Вони чудово підходять для відкритих спільнот за певними темами. Модерацію можна здійснювати, видаляючи публікації на відповідних relay</string> + <string name="public_chat_relays_explainer">Вкажіть від 1 до 3 relay, що розміщують цю групу. Клієнти Nostr використовують це налаштування, щоб знати, звідки завантажувати повідомлення та куди їх надсилати.</string> <string name="public_chat_relays_title">Relay</string> <string name="public_chat_title">Метадані публічного чату</string> <string name="public_chats">Публічні чати</string> @@ -2845,10 +2822,7 @@ <string name="read_only_user">Користувач лише для читання</string> <string name="record_a_message_description">Натисніть і утримуйте, щоб записати повідомлення</string> <string name="relay_chat">Чат relay</string> - <string name="relay_chat_explainer">Чати relay — це групи чатів, керовані їхнім домашнім relay. - Вони видимі всім у Nostr, і кожен може брати в них участь. - Вони чудово підходять для відкритих спільнот за певними темами. Деякі з цих груп є тимчасовими, - і тому повідомлення чату зникають з часом</string> + <string name="relay_chat_explainer">Чати relay — це групи чатів, керовані їхнім домашнім relay. Вони видимі всім у Nostr, і кожен може брати в них участь. Вони чудово підходять для відкритих спільнот за певними темами. Деякі з цих груп є тимчасовими, і тому повідомлення чату зникають з часом</string> <string name="relay_chat_title">Чати relay</string> <string name="relay_management_tab_events">Події</string> <string name="relay_management_tab_ips">IP-адреси</string> diff --git a/commonsUI/src/commonMain/composeResources/values-uz-rUZ/strings.xml b/commonsUI/src/commonMain/composeResources/values-uz-rUZ/strings.xml index 332e2bae8f..3c8e9daa2a 100644 --- a/commonsUI/src/commonMain/composeResources/values-uz-rUZ/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-uz-rUZ/strings.xml @@ -273,15 +273,8 @@ <string name="mark_all_known_as_read">Barcha ma'lumlarni o'qilgan deb belgilash</string> <string name="mark_all_new_as_read">Barcha yangilarni o'qilgan deb belgilash</string> <string name="mark_all_as_read">Barchasini o'qilgan deb belgilash</string> - <string name="account_backup_tips2_md"> ## Kalit zaxiralash va xavfsizlik maslahatlari - \n\nHisobingiz maxfiy kalit bilan himoyalangan. Kalit **nsec1** bilan boshlanadigan uzun belgilar ketma-ketligidir. Ushbu maxfiy kalitga kirish huquqiga ega bo'lgan har kim sizning nomingizdan post qo'yishi va identifikatoringizni o'zgartirishi mumkin. - \n\n- Maxfiy kalitingizni ishonmagan veb-sayt yoki dasturga **kiritmang**. - \n- Amethyst ishlab chiquvchilari maxfiy kalitingizni hech **qachon** so'ramaydi. - \n- Hisob tiklash uchun maxfiy kalitingizning xavfsiz zaxira nusxasini saqlang. Parol menejeridan foydalanishni tavsiya qilamiz. - </string> - <string name="account_backup_tips3_md"> Qo'shimcha xavfsizlik uchun kalitingizni parol bilan shifrlashingiz mumkin. Bu kalit **ncryptsec1** bilan boshlanadi va parolsiz ishlatib bo'lmaydi. - \n\nParolingizni unutsangiz, kalitingizni tiklay olmaysiz. - </string> + <string name="account_backup_tips2_md">## Kalit zaxiralash va xavfsizlik maslahatlari \n\nHisobingiz maxfiy kalit bilan himoyalangan. Kalit **nsec1** bilan boshlanadigan uzun belgilar ketma-ketligidir. Ushbu maxfiy kalitga kirish huquqiga ega bo'lgan har kim sizning nomingizdan post qo'yishi va identifikatoringizni o'zgartirishi mumkin. \n\n- Maxfiy kalitingizni ishonmagan veb-sayt yoki dasturga **kiritmang**. \n- Amethyst ishlab chiquvchilari maxfiy kalitingizni hech **qachon** so'ramaydi. \n- Hisob tiklash uchun maxfiy kalitingizning xavfsiz zaxira nusxasini saqlang. Parol menejeridan foydalanishni tavsiya qilamiz.</string> + <string name="account_backup_tips3_md">Qo'shimcha xavfsizlik uchun kalitingizni parol bilan shifrlashingiz mumkin. Bu kalit **ncryptsec1** bilan boshlanadi va parolsiz ishlatib bo'lmaydi. \n\nParolingizni unutsangiz, kalitingizni tiklay olmaysiz.</string> <string name="encrypt_and_copy_my_secret_key">Maxfiy kalitni shifrlash va nusxalash</string> <string name="badge_award_image_for">%1$s uchun nishon mukofoti rasmi</string> <string name="badge_award_image">"Nishon mukofoti rasmi</string> @@ -962,9 +955,7 @@ <string name="push_server_none_explainer">Push bildirishnomalarini o'chiradi</string> <string name="push_server_uses_app_explainer">%1$s ilovasidan foydalanadi</string> <string name="push_server_install_app">Push bildirishnoma sozlamasi</string> - <string name="push_server_install_app_description"> Push bildirishnomalarni olish uchun [Ntfy](https://ntfy.sh/) kabi [Unified Push](https://unifiedpush.org/) ni qo'llab-quvvatlaydigan ilovani o'rnating. - O'rnatgandan so'ng, Sozlamalarda foydalanmoqchi bo'lgan ilovani tanlang. - </string> + <string name="push_server_install_app_description">Push bildirishnomalarni olish uchun [Ntfy](https://ntfy.sh/) kabi [Unified Push](https://unifiedpush.org/) ni qo'llab-quvvatlaydigan ilovani o'rnating. O'rnatgandan so'ng, Sozlamalarda foydalanmoqchi bo'lgan ilovani tanlang.</string> <string name="thread_title">Ip</string> <string name="send_the_seller_a_message">Sotuvchiga xabar yuborish</string> <string name="hi_there_is_this_still_available">Salom, bu hali ham mavjudmi?</string> @@ -2012,15 +2003,7 @@ <string name="could_not_fetch_invoice_from_details">%1$s dan hisob-faktura olishda xato: %2$s</string> <string name="could_not_open_the_compressed_file">Siqilgan faylni ochib bo'lmadi</string> <string name="couldnt_find_nwc_wallets">Hamyon topilmadi</string> - <string name="couldnt_find_nwc_wallets_description">Amethyst Nostr Wallet Connect (NWC) ni qo'llab-quvvatlaydigan hech qanday hamyon ilovasini topa olmadi. -\n\nNima qilishingiz mumkin: -\n\t1. NWC qo'llab-quvvatlashini tekshirish: Tanlagan hamyon ilovangiz Nostr Wallet Connect bilan mos kelishini ikki marta tekshiring. -\n\t2. Ulanish yaratish: Hamyon ilovangizga o'ting va yangi NWC ulanishini yarating. -\n\t3. Amethyst ga bog'lash: Hamyoningiz odatda uchta variant taqdim etadi: -\n\t\t3.1 Avtomatik bog'lash: Amethyst ni avtomatik ochish va ulash tugmasi. Agar shunday bo'lsa, ushbu tugmani bosing, Amethyst ni oching va yangi ekranda saqlash tugmasini bosing. -\n\t\t3.2 Qo'lda bog'lash: Nusxa olishingiz va to'g'ridan-to'g'ri kiritish uchun ushbu ekrandagi joylashtirish belgisini bosishingiz mumkin bo'lgan Ulanish URI si. -\n\t\t3.3 QR kod: Hamyon ushbu ekrandagi QR kod tugmasidan o'qishingiz mumkin bo'lgan QR kod yaratadi. - </string> + <string name="couldnt_find_nwc_wallets_description">Amethyst Nostr Wallet Connect (NWC) ni qo'llab-quvvatlaydigan hech qanday hamyon ilovasini topa olmadi. \n\nNima qilishingiz mumkin: \n\t1. NWC qo'llab-quvvatlashini tekshirish: Tanlagan hamyon ilovangiz Nostr Wallet Connect bilan mos kelishini ikki marta tekshiring. \n\t2. Ulanish yaratish: Hamyon ilovangizga o'ting va yangi NWC ulanishini yarating. \n\t3. Amethyst ga bog'lash: Hamyoningiz odatda uchta variant taqdim etadi: \n\t\t3.1 Avtomatik bog'lash: Amethyst ni avtomatik ochish va ulash tugmasi. Agar shunday bo'lsa, ushbu tugmani bosing, Amethyst ni oching va yangi ekranda saqlash tugmasini bosing. \n\t\t3.2 Qo'lda bog'lash: Nusxa olishingiz va to'g'ridan-to'g'ri kiritish uchun ushbu ekrandagi joylashtirish belgisini bosishingiz mumkin bo'lgan Ulanish URI si. \n\t\t3.3 QR kod: Hamyon ushbu ekrandagi QR kod tugmasidan o'qishingiz mumkin bo'lgan QR kod yaratadi.</string> <string name="create">Yaratish</string> <string name="d">k</string> <string name="danger_zone">Xavfli zona</string> @@ -2686,11 +2669,8 @@ <string name="proxy_section">Proksi Relay lar</string> <string name="proxy_section_explainer">Ilovaning tasmalangizni yuklab olish uchun foydalanishi kerak bo'lgan agregator relay lar, masalan filter.nostr.wine. Bu chiqib ketish modelini almashtiradi va ilovani faqat ro'yxatlaringizdagi relay larga ulanishga majbur qiladi.</string> <string name="public_chat">Ommaviy chat</string> - <string name="public_chat_explainer">Ommaviy chatlar Nostr dagi hamma uchun ko'rinadi va har kim - ularda ishtirok eta oladi. Ular muayyan mavzular bo'yicha ochiq jamoalar uchun juda mos keladi. - Moderatsiya relay'larida postlarni o'chirish orqali boshqarilishi mumkin</string> - <string name="public_chat_relays_explainer">Ushbu guruhni joylashtiradigan 1-3 ta relay kiriting. - Nostr klientlari xabarlarni qayerdan yuklab olishini va xabarlaringizni qayerga yuborishini bilish uchun ushbu sozlamadan foydalanadi.</string> + <string name="public_chat_explainer">Ommaviy chatlar Nostr dagi hamma uchun ko'rinadi va har kim ularda ishtirok eta oladi. Ular muayyan mavzular bo'yicha ochiq jamoalar uchun juda mos keladi. Moderatsiya relay'larida postlarni o'chirish orqali boshqarilishi mumkin</string> + <string name="public_chat_relays_explainer">Ushbu guruhni joylashtiradigan 1-3 ta relay kiriting. Nostr klientlari xabarlarni qayerdan yuklab olishini va xabarlaringizni qayerga yuborishini bilish uchun ushbu sozlamadan foydalanadi.</string> <string name="public_chat_relays_title">Relay lar</string> <string name="public_chat_title">Ommaviy chat metama'lumotlari</string> <string name="public_chats">Ommaviy chatlar</string> @@ -2715,10 +2695,7 @@ <string name="read_only_user">Faqat o'qish foydalanuvchisi</string> <string name="record_a_message_description">Xabar yozib olish uchun bosib ushlab turing</string> <string name="relay_chat">Relay chati</string> - <string name="relay_chat_explainer">Relay chatlari o'z uy relay'lari tomonidan boshqariladigan chat guruhlaridir. - Ular Nostr dagi hamma uchun ko'rinadi va har kim ularda ishtirok eta oladi. - Ular muayyan mavzular bo'yicha ochiq jamoalar uchun juda mos keladi. Bu guruhlarning bir qismi vaqtinchalik bo'lib, - shuning uchun chat xabarlari vaqt o'tishi bilan yo'qoladi</string> + <string name="relay_chat_explainer">Relay chatlari o'z uy relay'lari tomonidan boshqariladigan chat guruhlaridir. Ular Nostr dagi hamma uchun ko'rinadi va har kim ularda ishtirok eta oladi. Ular muayyan mavzular bo'yicha ochiq jamoalar uchun juda mos keladi. Bu guruhlarning bir qismi vaqtinchalik bo'lib, shuning uchun chat xabarlari vaqt o'tishi bilan yo'qoladi</string> <string name="relay_chat_title">Relay chatlari</string> <string name="relay_management_tab_events">Hodisalar</string> <string name="relay_management_tab_ips">IP lar</string> diff --git a/commonsUI/src/commonMain/composeResources/values-vi-rVN/strings.xml b/commonsUI/src/commonMain/composeResources/values-vi-rVN/strings.xml index a875b48f0e..e39e556680 100644 --- a/commonsUI/src/commonMain/composeResources/values-vi-rVN/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-vi-rVN/strings.xml @@ -267,15 +267,8 @@ <string name="mark_all_known_as_read">Đánh dấu tất cả đã biết là đã đọc</string> <string name="mark_all_new_as_read">Đánh dấu tất cả mới là đã đọc</string> <string name="mark_all_as_read">Đánh dấu tất cả là đã đọc</string> - <string name="account_backup_tips2_md"> ## Mẹo sao lưu khóa và bảo mật - \n\nTài khoản của bạn được bảo mật bằng một khóa bí mật. Khóa là một chuỗi ký tự dài bắt đầu bằng **nsec1**. Bất kỳ ai có quyền truy cập vào khóa bí mật này đều có thể đăng bài và thay đổi danh tính của bạn. - \n\n- **Không** đưa khóa bí mật của bạn vào bất kỳ trang web hoặc phần mềm nào mà bạn không tin tưởng. - \n- Các nhà phát triển Amethyst sẽ **không bao giờ** yêu cầu khóa bí mật của bạn. - \n- **Hãy** giữ bản sao lưu an toàn của khóa bí mật để khôi phục tài khoản. Chúng tôi khuyến nghị sử dụng trình quản lý mật khẩu. - </string> - <string name="account_backup_tips3_md"> Để bảo mật thêm, bạn có thể mã hóa khóa của mình bằng mật khẩu. Khóa này bắt đầu bằng **ncryptsec1** và không thể sử dụng nếu không có mật khẩu của bạn. - \n\nNếu bạn mất mật khẩu, bạn sẽ không thể khôi phục khóa của mình. - </string> + <string name="account_backup_tips2_md">## Mẹo sao lưu khóa và bảo mật \n\nTài khoản của bạn được bảo mật bằng một khóa bí mật. Khóa là một chuỗi ký tự dài bắt đầu bằng **nsec1**. Bất kỳ ai có quyền truy cập vào khóa bí mật này đều có thể đăng bài và thay đổi danh tính của bạn. \n\n- **Không** đưa khóa bí mật của bạn vào bất kỳ trang web hoặc phần mềm nào mà bạn không tin tưởng. \n- Các nhà phát triển Amethyst sẽ **không bao giờ** yêu cầu khóa bí mật của bạn. \n- **Hãy** giữ bản sao lưu an toàn của khóa bí mật để khôi phục tài khoản. Chúng tôi khuyến nghị sử dụng trình quản lý mật khẩu.</string> + <string name="account_backup_tips3_md">Để bảo mật thêm, bạn có thể mã hóa khóa của mình bằng mật khẩu. Khóa này bắt đầu bằng **ncryptsec1** và không thể sử dụng nếu không có mật khẩu của bạn. \n\nNếu bạn mất mật khẩu, bạn sẽ không thể khôi phục khóa của mình.</string> <string name="encrypt_and_copy_my_secret_key">Mã hóa và sao chép khóa bí mật của tôi</string> <string name="badge_award_image_for">Hình ảnh huy hiệu được trao cho %1$s</string> <string name="badge_award_image">"Hình ảnh huy hiệu được trao</string> @@ -954,9 +947,7 @@ <string name="push_server_none_explainer">Tắt thông báo Push</string> <string name="push_server_uses_app_explainer">Sử dụng ứng dụng %1$s</string> <string name="push_server_install_app">Cài đặt thông báo Push</string> - <string name="push_server_install_app_description"> Để nhận thông báo push, hãy cài đặt ứng dụng hỗ trợ [Unified Push](https://unifiedpush.org/), chẳng hạn như [Ntfy](https://ntfy.sh/). - Sau khi cài đặt, hãy chọn ứng dụng bạn muốn sử dụng trong Cài đặt. - </string> + <string name="push_server_install_app_description">Để nhận thông báo push, hãy cài đặt ứng dụng hỗ trợ [Unified Push](https://unifiedpush.org/), chẳng hạn như [Ntfy](https://ntfy.sh/). Sau khi cài đặt, hãy chọn ứng dụng bạn muốn sử dụng trong Cài đặt.</string> <string name="thread_title">Luồng</string> <string name="send_the_seller_a_message">Gửi tin nhắn cho người bán</string> <string name="hi_there_is_this_still_available">Xin chào, mặt hàng này còn không?</string> @@ -2000,15 +1991,7 @@ <string name="could_not_fetch_invoice_from_details">Không thể tải hóa đơn từ %1$s: %2$s</string> <string name="could_not_open_the_compressed_file">Không thể mở tệp nén</string> <string name="couldnt_find_nwc_wallets">Không tìm thấy ví</string> - <string name="couldnt_find_nwc_wallets_description">Amethyst không tìm thấy ứng dụng ví nào hỗ trợ Nostr Wallet Connect (NWC). -\n\nBạn có thể thực hiện: -\n\t1. Xác minh hỗ trợ NWC: Kiểm tra lại xem ứng dụng ví của bạn có tương thích với Nostr Wallet Connect không. -\n\t2. Tạo kết nối: Vào ứng dụng ví của bạn và tạo kết nối NWC mới. -\n\t3. Liên kết với Amethyst: Ví của bạn thường cung cấp ba tùy chọn: -\n\t\t3.1 Liên kết tự động: Nút để tự động mở và kết nối với Amethyst. Nếu có, nhấn nút đó, mở Amethyst và nhấn lưu trên màn hình mới. -\n\t\t3.2 Liên kết thủ công: URI kết nối mà bạn có thể sao chép và nhấn biểu tượng dán trên màn hình này để nhập trực tiếp. -\n\t\t3.3 Mã QR: Ví tạo mã QR mà bạn có thể đọc bằng nút mã QR trên màn hình này. - </string> + <string name="couldnt_find_nwc_wallets_description">Amethyst không tìm thấy ứng dụng ví nào hỗ trợ Nostr Wallet Connect (NWC). \n\nBạn có thể thực hiện: \n\t1. Xác minh hỗ trợ NWC: Kiểm tra lại xem ứng dụng ví của bạn có tương thích với Nostr Wallet Connect không. \n\t2. Tạo kết nối: Vào ứng dụng ví của bạn và tạo kết nối NWC mới. \n\t3. Liên kết với Amethyst: Ví của bạn thường cung cấp ba tùy chọn: \n\t\t3.1 Liên kết tự động: Nút để tự động mở và kết nối với Amethyst. Nếu có, nhấn nút đó, mở Amethyst và nhấn lưu trên màn hình mới. \n\t\t3.2 Liên kết thủ công: URI kết nối mà bạn có thể sao chép và nhấn biểu tượng dán trên màn hình này để nhập trực tiếp. \n\t\t3.3 Mã QR: Ví tạo mã QR mà bạn có thể đọc bằng nút mã QR trên màn hình này.</string> <string name="create">Tạo</string> <string name="d">n</string> <string name="danger_zone">Vùng nguy hiểm</string> @@ -2642,11 +2625,8 @@ <string name="proxy_section">Relay Proxy</string> <string name="proxy_section_explainer">Các relay tổng hợp mà ứng dụng phải sử dụng để tải feed của bạn, như filter.nostr.wine. Tùy chọn này thay thế mô hình outbox và sẽ khiến ứng dụng chỉ kết nối đến các relay trong danh sách của bạn.</string> <string name="public_chat">Trò chuyện công khai</string> - <string name="public_chat_explainer">Trò chuyện công khai hiển thị với mọi người trên Nostr và bất kỳ ai - cũng có thể tham gia. Chúng rất phù hợp cho các cộng đồng mở xung quanh các chủ đề cụ thể. - Kiểm duyệt có thể được kiểm soát bằng cách xóa bài đăng trên các relay của nó</string> - <string name="public_chat_relays_explainer">Nhập từ 1–3 relay lưu trữ nhóm này. - Các ứng dụng Nostr sử dụng cài đặt này để biết nơi tải xuống tin nhắn và gửi tin nhắn của bạn.</string> + <string name="public_chat_explainer">Trò chuyện công khai hiển thị với mọi người trên Nostr và bất kỳ ai cũng có thể tham gia. Chúng rất phù hợp cho các cộng đồng mở xung quanh các chủ đề cụ thể. Kiểm duyệt có thể được kiểm soát bằng cách xóa bài đăng trên các relay của nó</string> + <string name="public_chat_relays_explainer">Nhập từ 1–3 relay lưu trữ nhóm này. Các ứng dụng Nostr sử dụng cài đặt này để biết nơi tải xuống tin nhắn và gửi tin nhắn của bạn.</string> <string name="public_chat_relays_title">Relay</string> <string name="public_chat_title">Siêu dữ liệu trò chuyện công khai</string> <string name="public_chats">Trò chuyện công khai</string> @@ -2670,10 +2650,7 @@ <string name="read_from_relay_description">Lượng dữ liệu tính bằng byte đã nhận được từ relay này, bao gồm các bộ lọc và sự kiện</string> <string name="read_only_user">Người dùng chỉ đọc</string> <string name="record_a_message_description">Nhấn và giữ để ghi âm tin nhắn</string> - <string name="relay_chat_explainer">Relay chat là các nhóm trò chuyện được kiểm soát bởi relay chủ quản. - Chúng hiển thị với mọi người trên Nostr và bất kỳ ai cũng có thể tham gia. - Chúng rất phù hợp cho các cộng đồng mở xung quanh các chủ đề cụ thể. Một số nhóm này là tạm thời - và do đó tin nhắn trò chuyện sẽ biến mất theo thời gian</string> + <string name="relay_chat_explainer">Relay chat là các nhóm trò chuyện được kiểm soát bởi relay chủ quản. Chúng hiển thị với mọi người trên Nostr và bất kỳ ai cũng có thể tham gia. Chúng rất phù hợp cho các cộng đồng mở xung quanh các chủ đề cụ thể. Một số nhóm này là tạm thời và do đó tin nhắn trò chuyện sẽ biến mất theo thời gian</string> <string name="relay_chat_title">Relay Chat</string> <string name="relay_management_tab_events">Sự kiện</string> <string name="relay_management_tab_ips">IP</string> diff --git a/commonsUI/src/commonMain/composeResources/values-zh-rCN/strings.xml b/commonsUI/src/commonMain/composeResources/values-zh-rCN/strings.xml index d16f98dff4..36527eeeaf 100644 --- a/commonsUI/src/commonMain/composeResources/values-zh-rCN/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-zh-rCN/strings.xml @@ -389,15 +389,8 @@ <string name="mark_all_known_as_read">将所有已知内容标记为已读</string> <string name="mark_all_new_as_read">将所有新内容标记为已读</string> <string name="mark_all_as_read">将所有内容标记为已读</string> - <string name="account_backup_tips2_md">## 备份与安全提示 - \n\n你的帐户由一个私钥保护。私钥是以**nsec1**开头的随机字符串。任何拥有你的私钥的人都可以使用你的身份发布内容。 - \n\n- **不要**将你的私钥添加到任何你不信任的网站或软件,亦不要在网上公开。 - \n- Amethyst 开发人员**永远不会**要求你提供私钥。 - \n- **请**保留你的私钥的安全备份,以备帐户恢复。我们建议使用密码管理器。 - </string> - <string name="account_backup_tips3_md"> 为了额外的安全性,你可以用密码加密你的密钥。 此密钥以 **ncryptsec1** 开头,没有密码就不能使用。 - \n\n如果你丢失密码, 你将无法恢复你的密钥。 - </string> + <string name="account_backup_tips2_md">## 备份与安全提示 \n\n你的帐户由一个私钥保护。私钥是以**nsec1**开头的随机字符串。任何拥有你的私钥的人都可以使用你的身份发布内容。 \n\n- **不要**将你的私钥添加到任何你不信任的网站或软件,亦不要在网上公开。 \n- Amethyst 开发人员**永远不会**要求你提供私钥。 \n- **请**保留你的私钥的安全备份,以备帐户恢复。我们建议使用密码管理器。</string> + <string name="account_backup_tips3_md">为了额外的安全性,你可以用密码加密你的密钥。 此密钥以 **ncryptsec1** 开头,没有密码就不能使用。 \n\n如果你丢失密码, 你将无法恢复你的密钥。</string> <string name="encrypt_and_copy_my_secret_key">加密并复制我的密钥。</string> <string name="badge_award_image_for">颁发给 %1$s 的徽章图片</string> <string name="badge_award_image">"徽章图片</string> @@ -478,7 +471,7 @@ <string name="new_community_rules_kinds_hint">轻触以允许一种类型。长按一种类型来设置每个事件的大小或每日帖子的限制。</string> <string name="new_community_rules_kind_custom_label">自定义类型</string> <string name="new_community_rules_kind_add">添加类型</string> - <string name="new_community_rules_limits_title"> %1$d 类型的限制</string> + <string name="new_community_rules_limits_title">%1$d 类型的限制</string> <string name="new_community_rules_limits_max_bytes">最大事件大小(字节)</string> <string name="new_community_rules_limits_max_per_day">每位作者每天最多发帖数</string> <string name="new_community_rules_limits_unlimited">留空为无限制</string> @@ -1356,9 +1349,7 @@ <string name="push_server_none_explainer">关闭推送通知</string> <string name="push_server_uses_app_explainer">使用应用 %1$s</string> <string name="push_server_install_app">推送通知设置</string> - <string name="push_server_install_app_description"> 若要接收推送通知,请安装任何支持 [Unified Push](https://unifiedpus)的应用, 例如 [Ntfy](https://ntfy.sh/)。 - 安装后,请在设置中选择你想要使用的应用程序。 - </string> + <string name="push_server_install_app_description">若要接收推送通知,请安装任何支持 [Unified Push](https://unifiedpus)的应用, 例如 [Ntfy](https://ntfy.sh/)。 安装后,请在设置中选择你想要使用的应用程序。</string> <string name="thread_title">主题帖</string> <string name="send_the_seller_a_message">向卖方发送消息</string> <string name="hi_there_is_this_still_available">你好,这个还能买到吗?</string> @@ -2640,15 +2631,7 @@ <string name="could_not_fetch_invoice_from_details">无法从 %1$s 获取发票:%2$s</string> <string name="could_not_open_the_compressed_file">无法打开压缩文件</string> <string name="couldnt_find_nwc_wallets">未找到钱包</string> - <string name="couldnt_find_nwc_wallets_description">Amethyst 找不到任何支持 Nostr Wallet Connect (NWC) 的钱包应用. -\n\n你可以做的事情: -\n\t1. 验证 NWC 支持:仔细确认偏好的钱包应用和 Nostr Wallet Connect 兼容。 -\n\t2. 创建连接:前往钱包应用并创建新的 NWC 连接。 -\n\t3.链接到 Amethyst: 你的钱包通常会给你三个选项: -\n\t\t3.1 自动链接:自动打开并连接到 Amethyst 的按钮。如果是这样, 单击那个按钮,打开 Amethyst 并在新屏上按保存。 -\n\t\t3.2 手动链接: 你可以复制的连接 URI,单击这个屏幕上的粘贴图标直接输入它。 -\n\t\t3.3 二维码: 钱包会创建一个你可以从这个屏幕中的二维码按钮那里读取的二维码。 - </string> + <string name="couldnt_find_nwc_wallets_description">Amethyst 找不到任何支持 Nostr Wallet Connect (NWC) 的钱包应用. \n\n你可以做的事情: \n\t1. 验证 NWC 支持:仔细确认偏好的钱包应用和 Nostr Wallet Connect 兼容。 \n\t2. 创建连接:前往钱包应用并创建新的 NWC 连接。 \n\t3.链接到 Amethyst: 你的钱包通常会给你三个选项: \n\t\t3.1 自动链接:自动打开并连接到 Amethyst 的按钮。如果是这样, 单击那个按钮,打开 Amethyst 并在新屏上按保存。 \n\t\t3.2 手动链接: 你可以复制的连接 URI,单击这个屏幕上的粘贴图标直接输入它。 \n\t\t3.3 二维码: 钱包会创建一个你可以从这个屏幕中的二维码按钮那里读取的二维码。</string> <string name="create">创建</string> <string name="d">天</string> <string name="danger_zone">危险区</string> @@ -3552,11 +3535,8 @@ <string name="proxy_section">代理中继</string> <string name="proxy_section_explainer">聚合器中继是应用获取资讯源时使用的中继(例如:filter.nostr.wine),设置这个列表将覆盖信箱模型的行为,使得应用只会连接到这个列表中的中继以获取内容。</string> <string name="public_chat">公共聊天</string> - <string name="public_chat_explainer">公开聊天对所有 Nostr 用户都是可见的,任何人都可以: - 参与这些聊天。这对特定话题建立的开放式社区来说很重要 - 通过删除中继上的帖子实现内容审核控制</string> - <string name="public_chat_relays_explainer">使用 1~3 个中继托管这个群组。 - 让 Nostr 客户端知道应该使用这些中继配置来发送和下载消息。</string> + <string name="public_chat_explainer">公开聊天对所有 Nostr 用户都是可见的,任何人都可以: 参与这些聊天。这对特定话题建立的开放式社区来说很重要 通过删除中继上的帖子实现内容审核控制</string> + <string name="public_chat_relays_explainer">使用 1~3 个中继托管这个群组。 让 Nostr 客户端知道应该使用这些中继配置来发送和下载消息。</string> <string name="public_chat_relays_title">中继器</string> <string name="public_chat_title">公开聊天元数据</string> <string name="public_chats">公共聊天</string> @@ -3589,10 +3569,7 @@ <string name="relay_auth_search_keywords">auth authentication relay sign verify nip-42 identity</string> <string name="relay_auth_settings_title">中继身份认证</string> <string name="relay_chat">中继聊天</string> - <string name="relay_chat_explainer">中继聊天是由聊天所属中继控制的聊天群组。 - Nostr 上的每个人都可以看到这些群组,任何人都可以加入群组。 - 这非常适合围绕特定主题而建立的开放社区。其中一些群组是临时的, - 因此聊天信息会随着时间的推移而消失。</string> + <string name="relay_chat_explainer">中继聊天是由聊天所属中继控制的聊天群组。 Nostr 上的每个人都可以看到这些群组,任何人都可以加入群组。 这非常适合围绕特定主题而建立的开放社区。其中一些群组是临时的, 因此聊天信息会随着时间的推移而消失。</string> <string name="relay_chat_title">中继聊天</string> <string name="relay_group_create_title">建群</string> <string name="relay_group_edit_title">编辑群</string> diff --git a/commonsUI/src/commonMain/composeResources/values-zh-rHK/strings.xml b/commonsUI/src/commonMain/composeResources/values-zh-rHK/strings.xml index 9eb6ba5bc8..4ee4682e0a 100644 --- a/commonsUI/src/commonMain/composeResources/values-zh-rHK/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-zh-rHK/strings.xml @@ -228,7 +228,7 @@ <string name="copy_user_id">複製用户ID</string> <string name="unblock_user">隱藏該用户</string> <string name="user_is_blocked_hidden">此用户被屏蔽/静音。他们的帖子被隐藏。</string> - <string name="npub_hex_username">npub,hex,用户名 </string> + <string name="npub_hex_username">npub,hex,用户名</string> <string name="clear">清除</string> <string name="app_logo">應用標誌</string> <string name="nsec_npub_hex_private_key">nsec/ npub/ hex 私鑰</string> @@ -276,15 +276,8 @@ <string name="mark_all_known_as_read">將所有已知內容標記為已讀</string> <string name="mark_all_new_as_read">將所有新內容標記為已讀</string> <string name="mark_all_as_read">將所有內容標記為已讀</string> - <string name="account_backup_tips2_md">## 备份与安全提示 - \n\n你的帐户由一个私钥保护。私钥是以**nsec1**开头的随机字符串。任何拥有你的私钥的人都可以使用你的身份发布内容。 - \n\n- **不要**将你的私钥添加到任何你不信任的网站或软件,亦不要在网上公开。 - \n- Amethyst 开发人员**永远不会**要求你提供私钥。 - \n- **请**保留你的私钥的安全备份,以备帐户恢复。我们建议使用密码管理器。 - </string> - <string name="account_backup_tips3_md"> 为了额外的安全性,你可以用密码加密你的密钥。 此密钥以 **ncryptsec1** 开头,没有密码就不能使用。 - \n\n如果你丢失密码, 你将无法恢复你的密钥。 - </string> + <string name="account_backup_tips2_md">## 备份与安全提示 \n\n你的帐户由一个私钥保护。私钥是以**nsec1**开头的随机字符串。任何拥有你的私钥的人都可以使用你的身份发布内容。 \n\n- **不要**将你的私钥添加到任何你不信任的网站或软件,亦不要在网上公开。 \n- Amethyst 开发人员**永远不会**要求你提供私钥。 \n- **请**保留你的私钥的安全备份,以备帐户恢复。我们建议使用密码管理器。</string> + <string name="account_backup_tips3_md">为了额外的安全性,你可以用密码加密你的密钥。 此密钥以 **ncryptsec1** 开头,没有密码就不能使用。 \n\n如果你丢失密码, 你将无法恢复你的密钥。</string> <string name="encrypt_and_copy_my_secret_key">加密并复制我的密钥。</string> <string name="badge_award_image_for">頒發給 %1$s 的徽章圖片</string> <string name="badge_award_image">"徽章图片</string> @@ -365,7 +358,7 @@ <string name="new_community_rules_kinds_hint">轻触以允许一种类型。长按一种类型来设置每个事件的大小或每日帖子的限制。</string> <string name="new_community_rules_kind_custom_label">自定义类型</string> <string name="new_community_rules_kind_add">添加类型</string> - <string name="new_community_rules_limits_title"> %1$d 类型的限制</string> + <string name="new_community_rules_limits_title">%1$d 类型的限制</string> <string name="new_community_rules_limits_max_bytes">最大事件大小(字节)</string> <string name="new_community_rules_limits_max_per_day">每位作者每天最多发帖数</string> <string name="new_community_rules_limits_unlimited">留空为无限制</string> @@ -976,9 +969,7 @@ <string name="push_server_none_explainer">关闭推送通知</string> <string name="push_server_uses_app_explainer">使用应用 %1$s</string> <string name="push_server_install_app">推送通知设置</string> - <string name="push_server_install_app_description"> 若要接收推送通知,请安装任何支持 [Unified Push](https://unifiedpus)的应用, 例如 [Ntfy](https://ntfy.sh/)。 - 安装后,请在设置中选择你想要使用的应用程序。 - </string> + <string name="push_server_install_app_description">若要接收推送通知,请安装任何支持 [Unified Push](https://unifiedpus)的应用, 例如 [Ntfy](https://ntfy.sh/)。 安装后,请在设置中选择你想要使用的应用程序。</string> <string name="thread_title">主题帖</string> <string name="send_the_seller_a_message">向卖方发送消息</string> <string name="hi_there_is_this_still_available">你好,这个还能买到吗?</string> @@ -2037,15 +2028,7 @@ <string name="could_not_fetch_invoice_from_details">无法从 %1$s 获取发票:%2$s</string> <string name="could_not_open_the_compressed_file">无法打开压缩文件</string> <string name="couldnt_find_nwc_wallets">未找到钱包</string> - <string name="couldnt_find_nwc_wallets_description">Amethyst 找不到任何支持 Nostr Wallet Connect (NWC) 的钱包应用. -\n\n你可以做的事情: -\n\t1. 验证 NWC 支持:仔细确认偏好的钱包应用和 Nostr Wallet Connect 兼容。 -\n\t2. 创建连接:前往钱包应用并创建新的 NWC 连接。 -\n\t3.链接到 Amethyst: 你的钱包通常会给你三个选项: -\n\t\t3.1 自动链接:自动打开并连接到 Amethyst 的按钮。如果是这样, 单击那个按钮,打开 Amethyst 并在新屏上按保存。 -\n\t\t3.2 手动链接: 你可以复制的连接 URI,单击这个屏幕上的粘贴图标直接输入它。 -\n\t\t3.3 二维码: 钱包会创建一个你可以从这个屏幕中的二维码按钮那里读取的二维码。 - </string> + <string name="couldnt_find_nwc_wallets_description">Amethyst 找不到任何支持 Nostr Wallet Connect (NWC) 的钱包应用. \n\n你可以做的事情: \n\t1. 验证 NWC 支持:仔细确认偏好的钱包应用和 Nostr Wallet Connect 兼容。 \n\t2. 创建连接:前往钱包应用并创建新的 NWC 连接。 \n\t3.链接到 Amethyst: 你的钱包通常会给你三个选项: \n\t\t3.1 自动链接:自动打开并连接到 Amethyst 的按钮。如果是这样, 单击那个按钮,打开 Amethyst 并在新屏上按保存。 \n\t\t3.2 手动链接: 你可以复制的连接 URI,单击这个屏幕上的粘贴图标直接输入它。 \n\t\t3.3 二维码: 钱包会创建一个你可以从这个屏幕中的二维码按钮那里读取的二维码。</string> <string name="create">創建</string> <string name="d">天</string> <string name="danger_zone">危险区</string> @@ -2700,11 +2683,8 @@ <string name="proxy_section">代理中继</string> <string name="proxy_section_explainer">聚合器中继是应用获取资讯源时使用的中继(例如:filter.nostr.wine),设置这个列表将覆盖信箱模型的行为,使得应用只会连接到这个列表中的中继以获取内容。</string> <string name="public_chat">公共聊天</string> - <string name="public_chat_explainer">公开聊天对所有 Nostr 用户都是可见的,任何人都可以: - 参与这些聊天。这对特定话题建立的开放式社区来说很重要 - 通过删除中继上的帖子实现内容审核控制</string> - <string name="public_chat_relays_explainer">使用 1~3 个中继托管这个群组。 - 让 Nostr 客户端知道应该使用这些中继配置来发送和下载消息。</string> + <string name="public_chat_explainer">公开聊天对所有 Nostr 用户都是可见的,任何人都可以: 参与这些聊天。这对特定话题建立的开放式社区来说很重要 通过删除中继上的帖子实现内容审核控制</string> + <string name="public_chat_relays_explainer">使用 1~3 个中继托管这个群组。 让 Nostr 客户端知道应该使用这些中继配置来发送和下载消息。</string> <string name="public_chat_relays_title">中继器</string> <string name="public_chat_title">公开聊天元数据</string> <string name="public_chats">公共聊天</string> @@ -2729,10 +2709,7 @@ <string name="read_only_user">仅读用户</string> <string name="record_a_message_description">点按以录制消息</string> <string name="relay_chat">中继聊天</string> - <string name="relay_chat_explainer">中继聊天是由聊天所属中继控制的聊天群组。 - Nostr 上的每个人都可以看到这些群组,任何人都可以加入群组。 - 这非常适合围绕特定主题而建立的开放社区。其中一些群组是临时的, - 因此聊天信息会随着时间的推移而消失。</string> + <string name="relay_chat_explainer">中继聊天是由聊天所属中继控制的聊天群组。 Nostr 上的每个人都可以看到这些群组,任何人都可以加入群组。 这非常适合围绕特定主题而建立的开放社区。其中一些群组是临时的, 因此聊天信息会随着时间的推移而消失。</string> <string name="relay_chat_title">中继聊天</string> <string name="relay_management_tab_events">事件</string> <string name="relay_management_tab_ips">IP地址</string> diff --git a/commonsUI/src/commonMain/composeResources/values-zh-rSG/strings.xml b/commonsUI/src/commonMain/composeResources/values-zh-rSG/strings.xml index 4649ff00d5..0d0a9f7f9e 100644 --- a/commonsUI/src/commonMain/composeResources/values-zh-rSG/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-zh-rSG/strings.xml @@ -276,15 +276,8 @@ <string name="mark_all_known_as_read">将所有已知内容标记为已读</string> <string name="mark_all_new_as_read">将所有新内容标记为已读</string> <string name="mark_all_as_read">将所有内容标记为已读</string> - <string name="account_backup_tips2_md">## 备份与安全提示 - \n\n你的帐户由一个私钥保护。私钥是以**nsec1**开头的随机字符串。任何拥有你的私钥的人都可以使用你的身份发布内容。 - \n\n- **不要**将你的私钥添加到任何你不信任的网站或软件,亦不要在网上公开。 - \n- Amethyst 开发人员**永远不会**要求你提供私钥。 - \n- **请**保留你的私钥的安全备份,以备帐户恢复。我们建议使用密码管理器。 - </string> - <string name="account_backup_tips3_md"> 为了额外的安全性,你可以用密码加密你的密钥。 此密钥以 **ncryptsec1** 开头,没有密码就不能使用。 - \n\n如果你丢失密码, 你将无法恢复你的密钥。 - </string> + <string name="account_backup_tips2_md">## 备份与安全提示 \n\n你的帐户由一个私钥保护。私钥是以**nsec1**开头的随机字符串。任何拥有你的私钥的人都可以使用你的身份发布内容。 \n\n- **不要**将你的私钥添加到任何你不信任的网站或软件,亦不要在网上公开。 \n- Amethyst 开发人员**永远不会**要求你提供私钥。 \n- **请**保留你的私钥的安全备份,以备帐户恢复。我们建议使用密码管理器。</string> + <string name="account_backup_tips3_md">为了额外的安全性,你可以用密码加密你的密钥。 此密钥以 **ncryptsec1** 开头,没有密码就不能使用。 \n\n如果你丢失密码, 你将无法恢复你的密钥。</string> <string name="encrypt_and_copy_my_secret_key">加密并复制我的密钥。</string> <string name="badge_award_image_for">颁发给 %1$s 的徽章图片</string> <string name="badge_award_image">"徽章图片</string> @@ -365,7 +358,7 @@ <string name="new_community_rules_kinds_hint">轻触以允许一种类型。长按一种类型来设置每个事件的大小或每日帖子的限制。</string> <string name="new_community_rules_kind_custom_label">自定义类型</string> <string name="new_community_rules_kind_add">添加类型</string> - <string name="new_community_rules_limits_title"> %1$d 类型的限制</string> + <string name="new_community_rules_limits_title">%1$d 类型的限制</string> <string name="new_community_rules_limits_max_bytes">最大事件大小(字节)</string> <string name="new_community_rules_limits_max_per_day">每位作者每天最多发帖数</string> <string name="new_community_rules_limits_unlimited">留空为无限制</string> @@ -976,9 +969,7 @@ <string name="push_server_none_explainer">关闭推送通知</string> <string name="push_server_uses_app_explainer">使用应用 %1$s</string> <string name="push_server_install_app">推送通知设置</string> - <string name="push_server_install_app_description"> 若要接收推送通知,请安装任何支持 [Unified Push](https://unifiedpus)的应用, 例如 [Ntfy](https://ntfy.sh/)。 - 安装后,请在设置中选择你想要使用的应用程序。 - </string> + <string name="push_server_install_app_description">若要接收推送通知,请安装任何支持 [Unified Push](https://unifiedpus)的应用, 例如 [Ntfy](https://ntfy.sh/)。 安装后,请在设置中选择你想要使用的应用程序。</string> <string name="thread_title">主题帖</string> <string name="send_the_seller_a_message">向卖方发送消息</string> <string name="hi_there_is_this_still_available">你好,这个还能买到吗?</string> @@ -2037,15 +2028,7 @@ <string name="could_not_fetch_invoice_from_details">无法从 %1$s 获取发票:%2$s</string> <string name="could_not_open_the_compressed_file">无法打开压缩文件</string> <string name="couldnt_find_nwc_wallets">未找到钱包</string> - <string name="couldnt_find_nwc_wallets_description">Amethyst 找不到任何支持 Nostr Wallet Connect (NWC) 的钱包应用. -\n\n你可以做的事情: -\n\t1. 验证 NWC 支持:仔细确认偏好的钱包应用和 Nostr Wallet Connect 兼容。 -\n\t2. 创建连接:前往钱包应用并创建新的 NWC 连接。 -\n\t3.链接到 Amethyst: 你的钱包通常会给你三个选项: -\n\t\t3.1 自动链接:自动打开并连接到 Amethyst 的按钮。如果是这样, 单击那个按钮,打开 Amethyst 并在新屏上按保存。 -\n\t\t3.2 手动链接: 你可以复制的连接 URI,单击这个屏幕上的粘贴图标直接输入它。 -\n\t\t3.3 二维码: 钱包会创建一个你可以从这个屏幕中的二维码按钮那里读取的二维码。 - </string> + <string name="couldnt_find_nwc_wallets_description">Amethyst 找不到任何支持 Nostr Wallet Connect (NWC) 的钱包应用. \n\n你可以做的事情: \n\t1. 验证 NWC 支持:仔细确认偏好的钱包应用和 Nostr Wallet Connect 兼容。 \n\t2. 创建连接:前往钱包应用并创建新的 NWC 连接。 \n\t3.链接到 Amethyst: 你的钱包通常会给你三个选项: \n\t\t3.1 自动链接:自动打开并连接到 Amethyst 的按钮。如果是这样, 单击那个按钮,打开 Amethyst 并在新屏上按保存。 \n\t\t3.2 手动链接: 你可以复制的连接 URI,单击这个屏幕上的粘贴图标直接输入它。 \n\t\t3.3 二维码: 钱包会创建一个你可以从这个屏幕中的二维码按钮那里读取的二维码。</string> <string name="create">创建</string> <string name="d">天</string> <string name="danger_zone">危险区</string> @@ -2700,11 +2683,8 @@ <string name="proxy_section">代理中继</string> <string name="proxy_section_explainer">聚合器中继是应用获取资讯源时使用的中继(例如:filter.nostr.wine),设置这个列表将覆盖信箱模型的行为,使得应用只会连接到这个列表中的中继以获取内容。</string> <string name="public_chat">公共聊天</string> - <string name="public_chat_explainer">公开聊天对所有 Nostr 用户都是可见的,任何人都可以: - 参与这些聊天。这对特定话题建立的开放式社区来说很重要 - 通过删除中继上的帖子实现内容审核控制</string> - <string name="public_chat_relays_explainer">使用 1~3 个中继托管这个群组。 - 让 Nostr 客户端知道应该使用这些中继配置来发送和下载消息。</string> + <string name="public_chat_explainer">公开聊天对所有 Nostr 用户都是可见的,任何人都可以: 参与这些聊天。这对特定话题建立的开放式社区来说很重要 通过删除中继上的帖子实现内容审核控制</string> + <string name="public_chat_relays_explainer">使用 1~3 个中继托管这个群组。 让 Nostr 客户端知道应该使用这些中继配置来发送和下载消息。</string> <string name="public_chat_relays_title">中继器</string> <string name="public_chat_title">公开聊天元数据</string> <string name="public_chats">公共聊天</string> @@ -2729,10 +2709,7 @@ <string name="read_only_user">仅读用户</string> <string name="record_a_message_description">点按以录制消息</string> <string name="relay_chat">中继聊天</string> - <string name="relay_chat_explainer">中继聊天是由聊天所属中继控制的聊天群组。 - Nostr 上的每个人都可以看到这些群组,任何人都可以加入群组。 - 这非常适合围绕特定主题而建立的开放社区。其中一些群组是临时的, - 因此聊天信息会随着时间的推移而消失。</string> + <string name="relay_chat_explainer">中继聊天是由聊天所属中继控制的聊天群组。 Nostr 上的每个人都可以看到这些群组,任何人都可以加入群组。 这非常适合围绕特定主题而建立的开放社区。其中一些群组是临时的, 因此聊天信息会随着时间的推移而消失。</string> <string name="relay_chat_title">中继聊天</string> <string name="relay_management_tab_events">事件</string> <string name="relay_management_tab_ips">IP地址</string> diff --git a/commonsUI/src/commonMain/composeResources/values-zh-rTW/strings.xml b/commonsUI/src/commonMain/composeResources/values-zh-rTW/strings.xml index 6bd229d72c..65c10efd5d 100644 --- a/commonsUI/src/commonMain/composeResources/values-zh-rTW/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-zh-rTW/strings.xml @@ -223,7 +223,7 @@ <string name="copy_user_id">複製用户ID</string> <string name="unblock_user">隱藏該用户</string> <string name="user_is_blocked_hidden">此使用者已被封鎖/靜音。其貼文已隱藏。</string> - <string name="npub_hex_username">npub,hex,用户名 </string> + <string name="npub_hex_username">npub,hex,用户名</string> <string name="clear">清除</string> <string name="app_logo">應用標誌</string> <string name="nsec_npub_hex_private_key">nsec/ npub/ hex 私鑰</string> @@ -271,15 +271,8 @@ <string name="mark_all_known_as_read">將所有已知內容標記為已讀</string> <string name="mark_all_new_as_read">將所有新內容標記為已讀</string> <string name="mark_all_as_read">將所有內容標記為已讀</string> - <string name="account_backup_tips2_md">## 備份與安全提示 - \n\n你的帳戶由一個私鑰保護。私鑰是以 **nsec1** 開頭的隨機字符串。任何擁有你的私鑰的人都可以使用你的身份發佈內容。 - \n\n- **不要**將你的私鑰添加到任何你不信任的網站或軟件,亦不要在網上公開。 - \n- Amethyst 開發人員**永遠不會**要求你提供私鑰。 - \n- **請**保留你的私鑰的安全備份,以備帳戶恢復。我們建議使用密碼管理器。 - </string> - <string name="account_backup_tips3_md"> 爲了額外的安全性,你可以用密碼加密你的密鑰。此密鑰以 **ncryptsec1** 開頭,沒有密碼就不能使用。 - \n\n如果你丟失密碼,你將無法恢復你的密鑰。 - </string> + <string name="account_backup_tips2_md">## 備份與安全提示 \n\n你的帳戶由一個私鑰保護。私鑰是以 **nsec1** 開頭的隨機字符串。任何擁有你的私鑰的人都可以使用你的身份發佈內容。 \n\n- **不要**將你的私鑰添加到任何你不信任的網站或軟件,亦不要在網上公開。 \n- Amethyst 開發人員**永遠不會**要求你提供私鑰。 \n- **請**保留你的私鑰的安全備份,以備帳戶恢復。我們建議使用密碼管理器。</string> + <string name="account_backup_tips3_md">爲了額外的安全性,你可以用密碼加密你的密鑰。此密鑰以 **ncryptsec1** 開頭,沒有密碼就不能使用。 \n\n如果你丟失密碼,你將無法恢復你的密鑰。</string> <string name="encrypt_and_copy_my_secret_key">加密並複製我的密鑰</string> <string name="badge_award_image_for">頒發給 %1$s 的徽章圖片</string> <string name="badge_award_image">"徽章獎勵圖片</string> @@ -968,9 +961,7 @@ <string name="push_server_none_explainer">關閉推送通知</string> <string name="push_server_uses_app_explainer">使用應用 %1$s</string> <string name="push_server_install_app">推送通知設置</string> - <string name="push_server_install_app_description"> 若要接收推送通知,請安裝任何支持 [Unified Push](https://unifiedpush.org/)的應用,例如 [Ntfy](https://ntfy.sh/)。 - 安裝後,請在設備中選擇你想要使用的應用程式。 - </string> + <string name="push_server_install_app_description">若要接收推送通知,請安裝任何支持 [Unified Push](https://unifiedpush.org/)的應用,例如 [Ntfy](https://ntfy.sh/)。 安裝後,請在設備中選擇你想要使用的應用程式。</string> <string name="thread_title">主題帖</string> <string name="send_the_seller_a_message">向賣方發送消息</string> <string name="hi_there_is_this_still_available">你好,這個還能買到嗎?</string> @@ -2022,7 +2013,7 @@ <string name="could_not_fetch_invoice_from_details">無法從 %1$s 取得發票:%2$s</string> <string name="could_not_open_the_compressed_file">無法打開壓縮文件</string> <string name="couldnt_find_nwc_wallets">找不到錢包</string> - <string name="couldnt_find_nwc_wallets_description">Amethyst 找不到任何支援 Nostr Wallet Connect (NWC) 的錢包應用程式。\n\n您可以這樣做:\n\t1. 確認 NWC 支援:請再次確認您偏好的錢包應用程式是否與 Nostr Wallet Connect 相容。\n\t2. 建立連線:前往您的錢包應用程式並建立新的 NWC 連線。\n\t3. 連結至 Amethyst:您的錢包通常會提供三種選項:\n\t\t3.1 自動連結:點擊按鈕自動開啟並連接到 Amethyst。若有此選項,請點擊該按鈕,開啟 Amethyst 並在新畫面上點擊儲存。\n\t\t3.2 手動連結:複製連線 URI,然後點擊此畫面上的貼上圖示直接輸入。\n\t\t3.3 QR 碼:錢包會產生 QR 碼,您可透過此畫面上的 QR 碼按鈕掃描。\n </string> + <string name="couldnt_find_nwc_wallets_description">Amethyst 找不到任何支援 Nostr Wallet Connect (NWC) 的錢包應用程式。\n\n您可以這樣做:\n\t1. 確認 NWC 支援:請再次確認您偏好的錢包應用程式是否與 Nostr Wallet Connect 相容。\n\t2. 建立連線:前往您的錢包應用程式並建立新的 NWC 連線。\n\t3. 連結至 Amethyst:您的錢包通常會提供三種選項:\n\t\t3.1 自動連結:點擊按鈕自動開啟並連接到 Amethyst。若有此選項,請點擊該按鈕,開啟 Amethyst 並在新畫面上點擊儲存。\n\t\t3.2 手動連結:複製連線 URI,然後點擊此畫面上的貼上圖示直接輸入。\n\t\t3.3 QR 碼:錢包會產生 QR 碼,您可透過此畫面上的 QR 碼按鈕掃描。\n</string> <string name="create">創建</string> <string name="d">天</string> <string name="danger_zone">危險區域</string> @@ -2664,11 +2655,8 @@ <string name="proxy_section">代理 Relay</string> <string name="proxy_section_explainer">應用程式必須用來下載您的動態的聚合器 relay,例如 filter.nostr.wine。這將取代發件匣模式,使應用程式僅連線至您清單中的 relay。</string> <string name="public_chat">公共聊天</string> - <string name="public_chat_explainer">公開聊天室對所有 Nostr 使用者可見, - 任何人皆可參與。非常適合圍繞特定主題的開放社群。 - 可透過在其 relay 上刪除貼文來進行內容管理</string> - <string name="public_chat_relays_explainer">請輸入 1–3 個託管此群組的 relay。 - Nostr 客戶端使用此設定來確認下載訊息及傳送訊息的位置。</string> + <string name="public_chat_explainer">公開聊天室對所有 Nostr 使用者可見, 任何人皆可參與。非常適合圍繞特定主題的開放社群。 可透過在其 relay 上刪除貼文來進行內容管理</string> + <string name="public_chat_relays_explainer">請輸入 1–3 個託管此群組的 relay。 Nostr 客戶端使用此設定來確認下載訊息及傳送訊息的位置。</string> <string name="public_chat_relays_title">Relay</string> <string name="public_chat_title">公開聊天室元資料</string> <string name="public_chats">公開聊天室</string> @@ -2693,10 +2681,7 @@ <string name="read_only_user">只讀用戶</string> <string name="record_a_message_description">按住以錄製訊息</string> <string name="relay_chat">Relay 聊天</string> - <string name="relay_chat_explainer">Relay 聊天室是由其所屬 relay 管控的聊天群組。 - 所有 Nostr 使用者均可看見,任何人皆可參與。 - 非常適合圍繞特定主題的開放社群。部分群組為臨時性質, - 因此聊天訊息會隨時間消失</string> + <string name="relay_chat_explainer">Relay 聊天室是由其所屬 relay 管控的聊天群組。 所有 Nostr 使用者均可看見,任何人皆可參與。 非常適合圍繞特定主題的開放社群。部分群組為臨時性質, 因此聊天訊息會隨時間消失</string> <string name="relay_chat_title">Relay 聊天室</string> <string name="relay_management_tab_events">事件</string> <string name="relay_management_tab_ips">IP</string> diff --git a/commonsUI/src/commonMain/composeResources/values-zh/strings.xml b/commonsUI/src/commonMain/composeResources/values-zh/strings.xml index 643d37c01f..17be4c460b 100644 --- a/commonsUI/src/commonMain/composeResources/values-zh/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-zh/strings.xml @@ -337,7 +337,7 @@ <string name="new_community_rules_kinds_hint">轻触以允许一种类型。长按一种类型来设置每个事件的大小或每日帖子的限制。</string> <string name="new_community_rules_kind_custom_label">自定义类型</string> <string name="new_community_rules_kind_add">添加类型</string> - <string name="new_community_rules_limits_title"> %1$d 类型的限制</string> + <string name="new_community_rules_limits_title">%1$d 类型的限制</string> <string name="new_community_rules_limits_max_bytes">最大事件大小(字节)</string> <string name="new_community_rules_limits_max_per_day">每位作者每天最多发帖数</string> <string name="new_community_rules_limits_unlimited">留空为无限制</string> @@ -949,9 +949,7 @@ <string name="push_server_none_explainer">关闭推送通知</string> <string name="push_server_uses_app_explainer">使用应用 %1$s</string> <string name="push_server_install_app">推送通知设置</string> - <string name="push_server_install_app_description"> 若要接收推送通知,请安装任何支持 [Unified Push](https://unifiedpus)的应用, 例如 [Ntfy](https://ntfy.sh/)。 - 安装后,请在设置中选择你想要使用的应用程序。 - </string> + <string name="push_server_install_app_description">若要接收推送通知,请安装任何支持 [Unified Push](https://unifiedpus)的应用, 例如 [Ntfy](https://ntfy.sh/)。 安装后,请在设置中选择你想要使用的应用程序。</string> <string name="thread_title">主题帖</string> <string name="send_the_seller_a_message">向卖方发送消息</string> <string name="hi_there_is_this_still_available">你好,这个还能买到吗?</string> @@ -1801,15 +1799,8 @@ <string name="ots_explorer_api_label">Explorer API 基础URL</string> <string name="dvm_pay_invoice_from_dvm">支付来自DVM的发票</string> <string name="dvm_pay_amount_to_dvm">支付 %1$s sats 到 DVM</string> - <string xmlns:ns0="http://schemas.android.com/tools" name="account_backup_tips2_md" ns0:ignore="Typos">## 备份与安全提示 - \n\n你的帐户由一个私钥保护。私钥是以**nsec1**开头的随机字符串。任何拥有你的私钥的人都可以使用你的身份发布内容。 - \n\n- **不要**将你的私钥添加到任何你不信任的网站或软件,亦不要在网上公开。 - \n- Amethyst 开发人员**永远不会**要求你提供私钥。 - \n- **请**保留你的私钥的安全备份,以备帐户恢复。我们建议使用密码管理器。 - </string> - <string xmlns:ns0="http://schemas.android.com/tools" name="account_backup_tips3_md" ns0:ignore="Typos"> 为了额外的安全性,你可以用密码加密你的密钥。 此密钥以 **ncryptsec1** 开头,没有密码就不能使用。 - \n\n如果你丢失密码, 你将无法恢复你的密钥。 - </string> + <string xmlns:ns0="http://schemas.android.com/tools" name="account_backup_tips2_md" ns0:ignore="Typos">## 备份与安全提示 \n\n你的帐户由一个私钥保护。私钥是以**nsec1**开头的随机字符串。任何拥有你的私钥的人都可以使用你的身份发布内容。 \n\n- **不要**将你的私钥添加到任何你不信任的网站或软件,亦不要在网上公开。 \n- Amethyst 开发人员**永远不会**要求你提供私钥。 \n- **请**保留你的私钥的安全备份,以备帐户恢复。我们建议使用密码管理器。</string> + <string xmlns:ns0="http://schemas.android.com/tools" name="account_backup_tips3_md" ns0:ignore="Typos">为了额外的安全性,你可以用密码加密你的密钥。 此密钥以 **ncryptsec1** 开头,没有密码就不能使用。 \n\n如果你丢失密码, 你将无法恢复你的密钥。</string> <string name="use_direct_url">使用直接链接</string> <string name="nsite_title">静态网站: %1$s</string> <string name="napplet_card_title">nApple: %1$s</string> @@ -2054,15 +2045,7 @@ <string name="could_not_fetch_invoice_from_details">无法从 %1$s 获取发票:%2$s</string> <string name="could_not_open_the_compressed_file">无法打开压缩文件</string> <string name="couldnt_find_nwc_wallets">未找到钱包</string> - <string name="couldnt_find_nwc_wallets_description">Amethyst 找不到任何支持 Nostr Wallet Connect (NWC) 的钱包应用. -\n\n你可以做的事情: -\n\t1. 验证 NWC 支持:仔细确认偏好的钱包应用和 Nostr Wallet Connect 兼容。 -\n\t2. 创建连接:前往钱包应用并创建新的 NWC 连接。 -\n\t3.链接到 Amethyst: 你的钱包通常会给你三个选项: -\n\t\t3.1 自动链接:自动打开并连接到 Amethyst 的按钮。如果是这样, 单击那个按钮,打开 Amethyst 并在新屏上按保存。 -\n\t\t3.2 手动链接: 你可以复制的连接 URI,单击这个屏幕上的粘贴图标直接输入它。 -\n\t\t3.3 二维码: 钱包会创建一个你可以从这个屏幕中的二维码按钮那里读取的二维码。 - </string> + <string name="couldnt_find_nwc_wallets_description">Amethyst 找不到任何支持 Nostr Wallet Connect (NWC) 的钱包应用. \n\n你可以做的事情: \n\t1. 验证 NWC 支持:仔细确认偏好的钱包应用和 Nostr Wallet Connect 兼容。 \n\t2. 创建连接:前往钱包应用并创建新的 NWC 连接。 \n\t3.链接到 Amethyst: 你的钱包通常会给你三个选项: \n\t\t3.1 自动链接:自动打开并连接到 Amethyst 的按钮。如果是这样, 单击那个按钮,打开 Amethyst 并在新屏上按保存。 \n\t\t3.2 手动链接: 你可以复制的连接 URI,单击这个屏幕上的粘贴图标直接输入它。 \n\t\t3.3 二维码: 钱包会创建一个你可以从这个屏幕中的二维码按钮那里读取的二维码。</string> <string name="create">创建</string> <string name="d">天</string> <string name="danger_zone">危险区</string> @@ -2745,11 +2728,8 @@ <string name="proxy_section">代理中继</string> <string name="proxy_section_explainer">聚合器中继是应用获取资讯源时使用的中继(例如:filter.nostr.wine),设置这个列表将覆盖信箱模型的行为,使得应用只会连接到这个列表中的中继以获取内容。</string> <string name="public_chat">公共聊天</string> - <string name="public_chat_explainer">公开聊天对所有 Nostr 用户都是可见的,任何人都可以: - 参与这些聊天。这对特定话题建立的开放式社区来说很重要 - 通过删除中继上的帖子实现内容审核控制</string> - <string name="public_chat_relays_explainer">使用 1~3 个中继托管这个群组。 - 让 Nostr 客户端知道应该使用这些中继配置来发送和下载消息。</string> + <string name="public_chat_explainer">公开聊天对所有 Nostr 用户都是可见的,任何人都可以: 参与这些聊天。这对特定话题建立的开放式社区来说很重要 通过删除中继上的帖子实现内容审核控制</string> + <string name="public_chat_relays_explainer">使用 1~3 个中继托管这个群组。 让 Nostr 客户端知道应该使用这些中继配置来发送和下载消息。</string> <string name="public_chat_relays_title">中继器</string> <string name="public_chat_title">公开聊天元数据</string> <string name="public_chats">公共聊天</string> @@ -2776,10 +2756,7 @@ <string name="read_only_user">仅读用户</string> <string name="record_a_message_description">点按以录制消息</string> <string name="relay_chat">中继聊天</string> - <string name="relay_chat_explainer">中继聊天是由聊天所属中继控制的聊天群组。 - Nostr 上的每个人都可以看到这些群组,任何人都可以加入群组。 - 这非常适合围绕特定主题而建立的开放社区。其中一些群组是临时的, - 因此聊天信息会随着时间的推移而消失。</string> + <string name="relay_chat_explainer">中继聊天是由聊天所属中继控制的聊天群组。 Nostr 上的每个人都可以看到这些群组,任何人都可以加入群组。 这非常适合围绕特定主题而建立的开放社区。其中一些群组是临时的, 因此聊天信息会随着时间的推移而消失。</string> <string name="relay_chat_title">中继聊天</string> <string name="relay_management_tab_events">事件</string> <string name="relay_management_tab_ips">IP地址</string> diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index f8a7c986c4..8422630d31 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -660,17 +660,8 @@ <string name="mark_all_known_as_read">Mark all Known as read</string> <string name="mark_all_new_as_read">Mark all New as read</string> <string name="mark_all_as_read">Mark all as read</string> - <string name="account_backup_tips2_md"> - ## Key Backup and Safety Tips - \n\nYour account is secured by a secret key. The key is a long sequence of characters starting with **nsec1**. Anyone who has access to this secret key can post and change your identity. - \n\n- Do **not** put your secret key in any website or software you do not trust. - \n- Amethyst developers will **never** ask for your secret key. - \n- **Do** keep a secure backup of your secret key for account recovery. We recommend using a password manager. - </string> - <string name="account_backup_tips3_md"> - For additional security, you can encrypt your key with a password. This key starts with **ncryptsec1** and cannot be used without your password. - \n\nIf you lose your password, you will not be able to recover your key. - </string> + <string name="account_backup_tips2_md">## Key Backup and Safety Tips \n\nYour account is secured by a secret key. The key is a long sequence of characters starting with **nsec1**. Anyone who has access to this secret key can post and change your identity. \n\n- Do **not** put your secret key in any website or software you do not trust. \n- Amethyst developers will **never** ask for your secret key. \n- **Do** keep a secure backup of your secret key for account recovery. We recommend using a password manager.</string> + <string name="account_backup_tips3_md">For additional security, you can encrypt your key with a password. This key starts with **ncryptsec1** and cannot be used without your password. \n\nIf you lose your password, you will not be able to recover your key.</string> <string name="backup_keys_nudge_title">Back up your keys</string> <string name="backup_keys_nudge_body">Your secret key is the only way to access this account. If you lose it, it can never be recovered. Save it somewhere safe now.</string> <string name="backup_keys_nudge_backup_now">Back up now</string> @@ -1853,10 +1844,7 @@ <string name="push_server_none_explainer">Disables Push Notifications</string> <string name="push_server_uses_app_explainer">Uses app %1$s</string> <string name="push_server_install_app">Push Notification Setup</string> - <string name="push_server_install_app_description"> - To receive push notifications, install any app that supports [Unified Push](https://unifiedpush.org/), such as [Ntfy](https://ntfy.sh/). - After installing, select the app you want to use in the Settings. - </string> + <string name="push_server_install_app_description">To receive push notifications, install any app that supports [Unified Push](https://unifiedpush.org/), such as [Ntfy](https://ntfy.sh/). After installing, select the app you want to use in the Settings.</string> <string name="thread_title">Thread</string> <string name="send_the_seller_a_message">Send the seller a message</string> <string name="hi_there_is_this_still_available">Hi there, is this still available?</string> @@ -3629,15 +3617,7 @@ <string name="could_not_fetch_invoice_from_details">Could not fetch invoice from %1$s: %2$s</string> <string name="could_not_open_the_compressed_file">Could not open the compressed file</string> <string name="couldnt_find_nwc_wallets">Wallet not found</string> - <string name="couldnt_find_nwc_wallets_description">Amethyst couldn't find any wallet apps that support Nostr Wallet Connect (NWC). -\n\nWhat you can do: -\n\t1. Verify NWC Support: Double-check that your preferred wallet app is compatible with Nostr Wallet Connect. -\n\t2. Create Connection: Go to your wallet app and create a new NWC connection. -\n\t3. Link to Amethyst: Your wallet will usually give you three options: -\n\t\t3.1 Automatic Link: A button to automatically open and connect to Amethyst. If so, click that button, open Amethyst and hit save on the new screen. -\n\t\t3.2 Manual Link: A Connection URI you can copy and click the paste icon on this screen to directly input it. -\n\t\t3.3 QR Code: The wallet creates a QR code which you can read from the QR code button in this screen. - </string> + <string name="couldnt_find_nwc_wallets_description">Amethyst couldn't find any wallet apps that support Nostr Wallet Connect (NWC). \n\nWhat you can do: \n\t1. Verify NWC Support: Double-check that your preferred wallet app is compatible with Nostr Wallet Connect. \n\t2. Create Connection: Go to your wallet app and create a new NWC connection. \n\t3. Link to Amethyst: Your wallet will usually give you three options: \n\t\t3.1 Automatic Link: A button to automatically open and connect to Amethyst. If so, click that button, open Amethyst and hit save on the new screen. \n\t\t3.2 Manual Link: A Connection URI you can copy and click the paste icon on this screen to directly input it. \n\t\t3.3 QR Code: The wallet creates a QR code which you can read from the QR code button in this screen.</string> <string name="create">Create</string> <string name="d">d</string> <string name="danger_zone">Danger Zone</string> @@ -4773,11 +4753,8 @@ <string name="proxy_section">Proxy Relays</string> <string name="proxy_section_explainer">Aggregator relays the app must use to download your feeds from, like filter.nostr.wine. This replaces the outbox model and will make the app connect to only the relays in your lists.</string> <string name="public_chat">Public Chat</string> - <string name="public_chat_explainer">Public chats are visible to everyone on Nostr and anyone - can participate on them. They are great for open communities around specific topics. - Moderation can be controlled by deleting posts on its relays</string> - <string name="public_chat_relays_explainer">Insert between 1–3 relays that host this group. - Nostr clients use this setting to know where to download messages from and send your messages to.</string> + <string name="public_chat_explainer">Public chats are visible to everyone on Nostr and anyone can participate on them. They are great for open communities around specific topics. Moderation can be controlled by deleting posts on its relays</string> + <string name="public_chat_relays_explainer">Insert between 1–3 relays that host this group. Nostr clients use this setting to know where to download messages from and send your messages to.</string> <string name="public_chat_relays_title">Relays</string> <string name="public_chat_title">Public Chat Metadata</string> <string name="public_chats">Public Chats</string> @@ -4844,10 +4821,7 @@ <string name="relay_auth_search_keywords">auth authentication login relay sign verify nip-42 identity</string> <string name="relay_auth_settings_title">Relay login</string> <string name="relay_chat">Relay Chat</string> - <string name="relay_chat_explainer">Relay chats are chat groups controlled by their home relay. - They are visible to everyone on Nostr and anyone can participate on them. - They are great for open communities around specific topics. Some of these groups are ephemeral - and thus chat messages disappear over time</string> + <string name="relay_chat_explainer">Relay chats are chat groups controlled by their home relay. They are visible to everyone on Nostr and anyone can participate on them. They are great for open communities around specific topics. Some of these groups are ephemeral and thus chat messages disappear over time</string> <string name="relay_chat_title">Relay Chats</string> <string name="relay_explain_account_data">Your own profile, settings and drafts, on your home relays.</string> <string name="relay_explain_community_chats">The relays each community publishes its planes to.</string> diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ClickableLinks.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ClickableLinks.kt index 8a48da7f7d..c50279a930 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ClickableLinks.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ClickableLinks.kt @@ -87,7 +87,8 @@ fun ClickableEmail( textDecoration = if (underline) TextDecoration.Underline else null, modifier = modifier.pointerHoverIcon(PointerIcon.Hand).clickable { - runCatching { uriHandler.openUri("mailto:$display") } + // '%' is legal in an address but starts an escape in a mailto: URI (RFC 6068). + runCatching { uriHandler.openUri("mailto:" + display.replace("%", "%25")) } }, ) } diff --git a/desktopApp/plans/2026-06-21-napplet-desktop-host.md b/desktopApp/plans/2026-06-21-napplet-desktop-host.md index b7b53f4ce8..03be3b0bc8 100644 --- a/desktopApp/plans/2026-06-21-napplet-desktop-host.md +++ b/desktopApp/plans/2026-06-21-napplet-desktop-host.md @@ -19,7 +19,7 @@ doc is the map: what already works for free, what desktop must build, and the de `NappletIdentity`, `NappletRequest`/`NappletResponse`, the permissions `Ledger`/`Store`/`GrantState`, and the gateway **interfaces** (`NappletRelayGateway`, `NappletStorage`, `NappletWalletGateway`, `NappletResourceGateway`, `NappletUploadGateway`, `NappletIdentityGateway`, `NappletConsentPrompt`). -- **`NappletProtocolJson`** (`commons/jvmAndroid`) — the wire codec. Desktop's host marshals through +- **`NappletProtocolJson`** (`commons/commonMain`) — the wire codec. Desktop's host marshals through the same object, so request/result/push shapes can never drift between platforms. **The web contract (`commons/commonMain/composeResources/files/napplet/` + `NappletWebContract`)** — diff --git a/tools/strings-migrate/fix_escapes.py b/tools/strings-migrate/fix_escapes.py index 34a48089f8..65c7a7c566 100755 --- a/tools/strings-migrate/fix_escapes.py +++ b/tools/strings-migrate/fix_escapes.py @@ -19,6 +19,17 @@ It does not handle these Android conventions, so this script applies them itself \\n, \\t, \\uXXXX and \\\\ are deliberately left alone -- Compose already resolves them, and converting them here would double-process. +Whitespace is the other half. aapt collapses every whitespace run in an unquoted +value to one space and trims the ends; Compose keeps the XML text verbatim. A value +wrapped over several indented lines therefore showed on Android as one line, and in +Compose as a leading line break plus eight spaces -- which CommonMark renders as a +code block (`account_backup_tips2_md`). This script applies aapt's rule to runs that +contain a line break or tab, which only XML layout produces (a deliberate newline is +written \\n). In a translation it also trims a leading/trailing space the source +string does not have: Crowdin exports a translator's stray edge space, aapt used to +drop it, Compose would draw it (" miejsca zniknęły" beside "tematy zniknęły"). +Deliberate edge spaces (" and ") exist in the source too, and are kept. + Usage: fix_escapes.py <file-or-dir> [...] (idempotent; safe to re-run) """ import re @@ -35,9 +46,28 @@ TOOLS_ATTR = re.compile(r'\s+tools:[\w.-]+="[^"]*"') TOOLS_NS = re.compile(r'\s+xmlns:tools="[^"]*"') # a backslash escape NOT itself preceded by a backslash ANDROID_ONLY = re.compile(r"(?<!\\)\\(['\"?@])") +# A whitespace run that holds a line break or a tab: XML layout, never content. +LAYOUT_WS = re.compile(r"[ \t]*[\r\n\t][ \t\r\n]*") +STRING_NAME = re.compile(r'<string\b[^>]*\bname="([^"]+)"') -def fix_text(text: str, unwrap_quotes: bool = True) -> str: +def normalize_whitespace(text: str, source: str = None) -> str: + """Apply the aapt whitespace handling that Compose lacks. Idempotent. + + [source] is the default-locale value of the same key when [text] is a translation. + """ + if "<![CDATA[" in text: + return text + text = LAYOUT_WS.sub(lambda m: "" if m.start() == 0 or m.end() == len(text) else " ", text) + if source is not None: + if not source[:1].isspace(): + text = text.lstrip(" ") + if not source[-1:].isspace(): + text = text.rstrip(" ") + return text + + +def fix_text(text: str, unwrap_quotes: bool = True, source: str = None) -> str: """Convert one element's text. NOTE: quote-unwrapping is NOT idempotent and must run exactly once per file, at @@ -47,8 +77,9 @@ def fix_text(text: str, unwrap_quotes: bool = True) -> str: already-migrated files must therefore pass unwrap_quotes=False. """ if unwrap_quotes and len(text) >= 2 and text.startswith('"') and text.endswith('"'): - text = text[1:-1] - return ANDROID_ONLY.sub(r"\1", text) + # Quoted: aapt kept this whitespace exactly, so Compose keeps it too. + return ANDROID_ONLY.sub(r"\1", text[1:-1]) + return normalize_whitespace(ANDROID_ONLY.sub(r"\1", text), source) def strip_android_only_attrs(src: str) -> tuple: @@ -58,13 +89,31 @@ def strip_android_only_attrs(src: str) -> tuple: return out, n + m +def source_values(path: str) -> dict: + """The default-locale values beside a `values-<locale>/strings.xml`, else {}.""" + parent = os.path.dirname(os.path.abspath(path)) + if not os.path.basename(parent).startswith("values-"): + return {} + default = os.path.join(os.path.dirname(parent), "values", os.path.basename(path)) + if not os.path.exists(default): + return {} + out = {} + for m in STRING_EL.finditer(open(default, encoding="utf-8").read()): + name = STRING_NAME.match(m.group(1)) + if name: + out[name.group(1)] = m.group(2) + return out + + def fix_file(path: str, unwrap_quotes: bool = True) -> int: src = open(path, encoding="utf-8").read() + sources = source_values(path) changed = 0 def repl(m): nonlocal changed - fixed = fix_text(m.group(2), unwrap_quotes) + name = STRING_NAME.match(m.group(1)) + fixed = fix_text(m.group(2), unwrap_quotes, sources.get(name.group(1)) if name else None) if fixed != m.group(2): changed += 1 return m.group(1) + fixed + m.group(3)