From 6ff9460243aee013d6f946fb154e91b7b65c81c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 15:28:20 +0000 Subject: [PATCH 01/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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 {