diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 25df64d47d..32fad8469a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -335,10 +335,29 @@ jobs: name: Quartz iOS Test Reports path: quartz/build/reports - test-and-build-android: + # 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: 60 + timeout-minutes: 90 steps: - name: Checkout code uses: actions/checkout@v7 @@ -354,12 +373,61 @@ 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. + # 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 + # 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: 120 + 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' }} + + # 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 @@ -371,11 +439,47 @@ 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, 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. + # + # `--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 \ - :amethyst:lintFdroidBenchmark \ - :amethyst:lintPlayBenchmark \ + -Dkotlin.daemon.jvmargs="-Xmx4g -XX:MaxMetaspaceSize=1g" \ + --max-workers=3 \ :quartz:jvmTest \ :commons:jvmTest \ :commonsUI:jvmTest \ @@ -386,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 + 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..b2eccd2986 --- /dev/null +++ b/amethyst/plans/2026-09-23-encrypted-storage-retirement.md @@ -0,0 +1,140 @@ +# Retiring EncryptedStorage + +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. 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 +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. + +## Where each key went + +`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. + +| group | destination | legacy write | +|---|---|---| +| 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 | +| location-chat identity — seed, nickname | the account's encrypted DataStore, as its own `GeohashIdentitySecrets` group | **kept** | + +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 +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 + +| 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 | + +These are listed in `LegacyAccountKeys.accepted`, which is what lets the +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 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 + 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. **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 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. + +## Order of work + +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. 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 + +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 — 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; +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/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/AccountKeyStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AccountKeyStore.kt new file mode 100644 index 0000000000..5ef8b4af68 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AccountKeyStore.kt @@ -0,0 +1,191 @@ +/* + * 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, 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 + * 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) + } + } + + /** + * 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 { + 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/AccountRoster.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AccountRoster.kt new file mode 100644 index 0000000000..012bd535b9 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AccountRoster.kt @@ -0,0 +1,183 @@ +/* + * 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.AccountRosterStore +import com.vitorpamplona.amethyst.commons.model.preferences.SecretEncryption +import com.vitorpamplona.quartz.utils.Log + +/** + * 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. + * + * The legacy reader stays for good; see [EncryptedStorage] for why a lazy + * migration cannot have its source deleted. + */ +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 { + AccountRoster( + EncryptedRosterStorage( + AccountRosterStore( + // 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/AccountSecretsStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AccountSecretsStore.kt new file mode 100644 index 0000000000..aaf46b8726 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AccountSecretsStore.kt @@ -0,0 +1,176 @@ +/* + * 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.amethyst.commons.model.preferences.GeohashIdentitySecrets +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. + * + * The legacy reader stays for good; see [EncryptedStorage] for why a lazy + * migration cannot have its source deleted. + */ +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) + } + } + + /** + * 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) + + // ── 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 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: 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() + } + + if (stored != null) return stored + + val fromLegacy = legacy() + mirrorGeohashIdentity(npub, fromLegacy) + return fromLegacy + } + + /** + * 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, + 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) + } 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/Amethyst.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt index c3445013ca..5715d4e98b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt @@ -25,9 +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.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,13 +140,13 @@ 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) + 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 4ffb52fa28..3e0c5f6a6e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -27,12 +27,27 @@ 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.browser.BrowserIconRegistry +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 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.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 +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 import com.vitorpamplona.amethyst.commons.relayClient.diagnostics.BootRelayDiagnostics @@ -40,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.nip11RelayInfo.Nip11CachedRetriever import com.vitorpamplona.amethyst.commons.richtext.CachedAsciiDocToMarkdown import com.vitorpamplona.amethyst.commons.richtext.CachedRichTextParser import com.vitorpamplona.amethyst.commons.robohash.CachedRobohash @@ -52,33 +68,28 @@ 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.PoWJobStore import com.vitorpamplona.amethyst.commons.service.pow.PoWPolicy import com.vitorpamplona.amethyst.commons.service.pow.PoWPublishQueue 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.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 -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.nip60Cashu.CashuPreferences 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 import com.vitorpamplona.amethyst.napplet.DataStoreNappletPermissionStore -import com.vitorpamplona.amethyst.service.calendar.CalendarReminderPrefs +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 @@ -93,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 @@ -198,6 +207,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 +239,55 @@ 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() } + // 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() + } + }, + ) + } + + /** 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 = TorSettingsStore.torPreferences(sharedSettingsStore) ?: TorSettings() + TorSettingsStore(prefs, sharedSettingsStore, applicationIOScope) } // Blocking load of UI Preferences to avoid theme/language blinking @@ -252,20 +298,26 @@ 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() } } // 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(sharedSettingsStore, 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 = sharedSettingsStore + OtsSettingsStore(store, runBlocking { OtsSettingsStore.load(store) }) } // App services that should be run as soon as there are subscribers to their @@ -308,12 +360,13 @@ 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(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() @@ -811,8 +864,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 @@ -830,10 +885,26 @@ 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(appContext) } + 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) } + + // 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) @@ -912,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 { @@ -969,11 +1040,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(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. - BuzzAttestationPreferences(appContext, account.scope, account.pubKey, account.buzzAttestation) + BuzzAttestationStore(sharedSettingsStore, account.scope, account.pubKey, account.buzzAttestation) }, ) @@ -1303,7 +1374,7 @@ class AppModules( Filter(kinds = listOf(CalendarDateSlotEvent.KIND, CalendarTimeSlotEvent.KIND)), ).conflate() .collect { - if (CalendarReminderPrefs(appContext).isEnabled() && + if (calendarReminderSettings().load().enabled && CalendarReminderWorker.couldStillFire(CalendarReminderWorker.acceptedRsvpsInCache(), TimeUtils.now()) ) { CalendarReminderWorker.schedule(appContext) 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/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..7a271b02ee --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LegacyPreferenceCleanup.kt @@ -0,0 +1,342 @@ +/* + * 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.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 +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.readLegacyGeohashIdentity +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 + + /** + * 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. */ + 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? + + /** + * 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? +} + +/** + * 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 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 + * + * 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 reasons = mutableListOf() + + try { + 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" + } + + 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" + } + } + + 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. + */ + 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/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/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index a0acb8bad9..f0c6f739d0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -35,11 +35,35 @@ 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 +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.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 +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.preferences.readLegacyGeohashIdentity 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.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 @@ -55,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 @@ -81,10 +106,12 @@ 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 import kotlinx.serialization.Serializable +import okio.Path.Companion.toOkioPath import java.io.File // Release mode (!BuildConfig.DEBUG) always uses encrypted preferences @@ -102,7 +129,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. @@ -249,6 +276,220 @@ object LocalPreferences { private val savedAccountsMutex = Mutex() private val cachedAccounts: MutableMap = mutableMapOf() + /** + * The per-account DataStore: every non-secret setting this account has. + * + * 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( + rootFilesDir = { + Amethyst.instance.appContext.filesDir + .toOkioPath() + }, + migrations = { npub -> + LegacyAccountKeys.tables.map { it.migration { legacySource(npub) } } + }, + ) + } + + private fun followListStore(npub: String) = TopNavFollowListStore(accountStores.getDataStore(npub)) + + private fun latestEventStore(npub: String) = LatestEventCacheStore(accountStores.getDataStore(npub)) + + 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 identityStore(npub: String) = AccountIdentityStore(accountStores.getDataStore(npub)) + + private fun legacySource(npub: String): LegacyPreferenceSource = LegacySharedPreferences(encryptedPreferences(npub)) + + /** + * 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`. + * + * `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. + */ + internal const val LEGACY_WRITES_RETIRED = false + + private val legacyCleanup: LegacyPreferenceCleanup by lazy { + LegacyPreferenceCleanup( + tables = LegacyAccountKeys.tables, + accepted = LegacyAccountKeys.accepted, + files = + object : LegacyAccountFiles { + override fun source(npub: String) = legacySource(npub) + + // 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))) + + 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() } + 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 goes + // with the account's own file rather than being left as an + // orphan holding a seed forever. + val removedGeohashFile = deleteGeohashLegacyFile(npub) + + return removedAccountFile || removedGeohashFile + } + }, + 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) + + override suspend fun geohashIdentity(npub: String) = accountSecretsStore.storedGeohashIdentity(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") + } + + /** + * 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() + + /** + * 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. + * + * 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, 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) { + readLegacyGeohashIdentity(LegacySharedPreferences(encryptedPreferences(geohashLegacyKey(npub)))) + } + } + + /** + * 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 identity: AccountIdentity, + 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, + legacy: SharedPreferences, + ) = AccountStoreData( + 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(), + 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 + // 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. @@ -292,11 +533,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 @@ -307,12 +552,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) } } } @@ -332,7 +579,7 @@ object LocalPreferences { withContext(Dispatchers.IO) { with(encryptedPreferences()) { val newSystemOfAccounts = - getString(PrefKeys.ALL_ACCOUNT_INFO, "[]")?.let { + (accountRoster.allAccountInfoJson(::legacyCurrentAccount, ::legacyAllAccountInfo) ?: "[]").let { JsonMapper.fromJson>(it) } @@ -354,9 +601,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 } @@ -367,16 +622,15 @@ 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) - 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) } } @@ -448,6 +702,19 @@ 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 + // 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) @@ -510,54 +777,13 @@ 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) 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()) { @@ -582,8 +808,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. @@ -594,56 +818,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) - 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 @@ -663,37 +843,187 @@ 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, 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. + 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, + 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, + 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) }, + 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, + 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") } 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) { @@ -722,10 +1052,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) } } @@ -738,7 +1067,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 } @@ -750,103 +1082,166 @@ 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) + + // 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) { + // 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. + // 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) } + + accountSettings } } - 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" } - val privKey = getString(PrefKeys.NOSTR_PRIVKEY, null) - val pubKey = getString(PrefKeys.NOSTR_PUBKEY, null) ?: return@with null - val externalSignerPackageName = getString(PrefKeys.SIGNER_PACKAGE_NAME, null) ?: if (getBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, false)) "com.greenart7c3.nostrsigner" else null + // 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, this) + 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 = identity.externalSignerPackageName ?: if (identity.loginWithExternalSigner) "com.greenart7c3.nostrsigner" else null val keyPair = KeyPair(privKey = privKey?.hexToByteArray(), pubKey = pubKey.hexToByteArray()) + // 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" } - 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 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 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 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 localRelayServers = getStringSet(PrefKeys.LOCAL_RELAY_SERVERS, null) ?: setOf() + 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 = identity.localRelayServers - val followListPrefs = loadFollowListPrefs() + 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) + // 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(), + // 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 + 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) - 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 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] + 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" } @@ -1147,52 +1542,68 @@ 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) } - 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 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 = 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? { @@ -1276,20 +1687,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/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>(emptySet()) - - /** Sanitized host keys that currently have a stored icon. Observe to recompose when an icon arrives. */ - val keys: StateFlow> = _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/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/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/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 406c21e081..75314adbae 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -467,17 +467,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" @@ -769,7 +769,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/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/GeohashChatIdentityState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/GeohashChatIdentityState.kt index 9517a3b62b..e45bc638b7 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,24 @@ 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 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 +63,122 @@ 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. + * + * 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. + * + * **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 } + } + + /** Call under [mutex], for the reason [current] gives. */ + 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 = mutex.withLock { 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 - Amethyst.instance.encryptedStorage(signer.pubKey).edit { putString(PREF_NICKNAME, trimmed) } + scope.launch { + // 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) } + } + } } } - /** 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())) + if (!LocalPreferences.LEGACY_WRITES_RETIRED) { + Amethyst.instance.encryptedStorage(signer.pubKey).edit { putString(PREF_KEY, fresh.toHexKey()) } } + return fresh } companion object { 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 6d958f59b7..0a711f801f 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,20 +26,21 @@ 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 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 -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.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] @@ -230,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() @@ -247,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 @@ -259,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 @@ -271,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, ) @@ -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/model/nip11RelayInfo/LoadRelayInfo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/LoadRelayInfo.kt index 854e7147ea..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,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.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 792daf74fd..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,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.nip11RelayInfo.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/nip60Cashu/CashuPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuPreferences.kt index 02ce5266ac..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 @@ -20,110 +20,68 @@ */ 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.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 /** - * 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_" + + /** 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() /** - * 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 - } + fun forAccount(npub: String): CashuKeysetCounterStore = + stores.getOrCreate(npub) { + DataStoreCashuCounterStore(Amethyst.instance.appStores.getDataStore(fileName(npub))) + } /** - * 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. + * 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. */ - @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 { - val context = Amethyst.instance.appContext - val prefs = context.getSharedPreferences("$FILE_PREFIX$npub", Context.MODE_PRIVATE) - return CashuPreferences(prefs) + 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/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/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/AccountSecretsEncryptedStores.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/AccountSecretsEncryptedStores.kt deleted file mode 100644 index b9318a4713..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/AccountSecretsEncryptedStores.kt +++ /dev/null @@ -1,78 +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.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.quartz.nip47WalletConnect.Nip47WalletConnect -import com.vitorpamplona.quartz.utils.cache.LargeCache -import kotlinx.coroutines.CoroutineScope -import java.io.File - -class AccountSecretsEncryptedStores( - val rootFilesDir: () -> File, - val scope: CoroutineScope, -) { - companion object Companion { - val encryption = KeyStoreEncryption() - val key = stringPreferencesKey("privKey") - val nwc = stringPreferencesKey("nwc") - } - - private val storeCache = LargeCache() - - fun file(npub: String) = File(rootFilesDir(), "datastore/$npub.secrets") - - private fun getDataStore(npub: String): EncryptedDataStore = - storeCache.getOrCreate(npub) { - EncryptedDataStore( - PreferenceDataStoreFactory.create( - 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 = - getDataStore(npub).getProperty( - key = nwc, - parser = Nip47WalletConnect.Nip47URI::parser, - serializer = Nip47WalletConnect.Nip47URI::serializer, - ) - - fun removeAccount(npub: String): Boolean { - val deleted = file(npub).delete() - storeCache.remove(npub) - return deleted - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/EncryptedDataStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/EncryptedDataStore.kt deleted file mode 100644 index 49091d62c7..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/EncryptedDataStore.kt +++ /dev/null @@ -1,108 +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.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 kotlin.io.encoding.Base64 - -class EncryptedDataStore( - private val store: DataStore, - private val encryption: KeyStoreEncryption = KeyStoreEncryption(), - private val scope: CoroutineScope, -) { - private fun decode(str: String): ByteArray = Base64.decode(str) - - 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() - - suspend fun remove(key: Preferences.Key) { - store.edit { prefs -> - prefs.remove(key) - } - } - - suspend fun save( - key: Preferences.Key, - value: String, - ) { - store.edit { prefs -> - prefs[key] = encrypt(value) - } - } - - suspend fun get(key: Preferences.Key): String? = - store.data - .catch { e -> - if (e is IOException) emit(emptyPreferences()) else throw e - }.firstOrNull() - ?.get(key) - ?.let { decrypt(it) } - - fun getProperty( - key: Preferences.Key, - parser: (String) -> T, - serializer: (T) -> String, - ): UpdatablePropertyFlow = - UpdatablePropertyFlow( - flow = - store.data - .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 - } - }, - update = { newValue -> - if (newValue != null) { - val serialized = serializer(newValue) - if (serialized.isNotBlank()) { - save(key, serialized) - } else { - remove(key) - } - } else { - remove(key) - } - }, - scope = scope, - ) -} 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 deleted file mode 100644 index 78239e6911..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt +++ /dev/null @@ -1,322 +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 android.app.UiModeManager -import android.content.Context -import android.os.Build -import androidx.appcompat.app.AppCompatDelegate -import androidx.compose.runtime.Stable -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.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.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.amethyst.commons.model.UiSettingsFlow -import com.vitorpamplona.quartz.utils.Log -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -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 - -val Context.sharedPreferencesDataStore: DataStore by preferencesDataStore(name = "shared_settings") - -@Stable -class UiSharedPreferences( - prefs: UiSettings, - val context: Context, - val scope: CoroutineScope, -) { - // UI Preferences. Makes sure to wait for it to avoid blinking themes and language preferences - val value = UiSettingsFlow.build(prefs) - - val languageUpdate = - value.preferredLanguage - .onEach { language -> applyLanguage(language) } - .flowOn(Dispatchers.IO) - .stateIn( - scope, - SharingStarted.Eagerly, - value.toSettings(), - ) - - val nightModeUpdate = - value.theme - .onEach { theme -> applyNightMode(theme) } - .flowOn(Dispatchers.IO) - .stateIn( - scope, - SharingStarted.Eagerly, - prefs.theme, - ) - - /** - * Mirrors the in-app theme choice into the system's *per-application* night mode, so the - * launch splash agrees with a theme that is pinned against the phone's own light/dark setting. - * - * The system composites the splash from the manifest theme before the process starts, resolving - * it against this app's configuration -- so day/night resource qualifiers alone can only ever - * follow the phone. [UiModeManager.setApplicationNightMode] commits a *persisted per-package - * configuration override* (UiModeManagerService hands it to - * ActivityTaskManagerInternal.PackageConfigurationUpdater), which the system then applies when - * it launches the app. That is what carries a pinned LIGHT/DARK choice into the splash, from - * the next cold start onwards -- the current launch is already painted. - * - * This is deliberately [UiModeManager.setApplicationNightMode] and not - * [UiModeManager.setNightMode]: the latter changes the night mode for every app on the device - * and is gated behind MODIFY_DAY_NIGHT_MODE, which this app does not hold -- that call was a - * silent no-op and was removed. The per-application setter is the documented app-local - * alternative and is not permission-checked; UiModeManagerService only validates the argument. - * - * MODE_NIGHT_AUTO is how [ThemeType.SYSTEM] is expressed: the service maps everything other - * than YES/NO onto `Configuration.UI_MODE_NIGHT_UNDEFINED`, which clears the override and lets - * the app fall back to the device configuration. - * - * Not deduplicated, deliberately. There is no public getter for the per-application override, - * so the only way to skip a repeat call would be to shadow it in our own store -- a cache of - * state we do not own, which goes stale silently and takes the splash with it. Re-sending the - * value on every launch is self-healing instead, and the platform already no-ops the expensive - * half: PackageConfigPersister.updateFromImpl returns early without writing when the mode is - * unchanged, and ActivityRecord.applyAppSpecificConfig gates the activity reconfiguration on - * having actually changed. What remains is one Binder round trip per launch, off the main - * thread. (This is why the deduplication in applyLanguage below does not generalise here: it - * compares against getApplicationLocales(), the authoritative value, not a private copy.) - * - * MainActivity declares `uiMode` in its `configChanges`, so any change that does result is - * delivered to `onConfigurationChanged` rather than recreating the activity. - */ - private suspend fun applyNightMode(theme: ThemeType) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return - - val mode = - when (theme) { - ThemeType.DARK -> UiModeManager.MODE_NIGHT_YES - ThemeType.LIGHT -> UiModeManager.MODE_NIGHT_NO - ThemeType.SYSTEM -> UiModeManager.MODE_NIGHT_AUTO - } - - try { - context.getSystemService()?.setApplicationNightMode(mode) - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - Log.w("UiSharedPreferences", "Could not apply the per-application night mode", e) - } - } - - /** - * Pushes the preferred language into AppCompat, skipping the call when the app already - * runs in that locale. - * - * On API 33+, [AppCompatDelegate.setApplicationLocales] does not deduplicate: every call - * is a blocking Binder round trip into the system's LocaleManagerService, which commits a - * SharedPreferences file (and, on Samsung ROMs, appends to a log file) before returning. - * That was measured at ~220ms on a Galaxy device, charged to the calling thread. Since - * this flow starts eagerly, the app paid it on the main thread on every launch, even when - * the locale had not changed since the previous run -- and StrictMode reported it as a - * DiskReadViolation via the Binder call. - * - * [AppCompatDelegate.getApplicationLocales] is `@AnyThread` and only reads state, so the - * comparison runs off the main thread. Actual changes still hop to the main thread: - * below API 33 AppCompat applies them in process by reconfiguring (and possibly - * recreating) the active activities. - */ - private suspend fun applyLanguage(language: String?) { - val newLocales = LocaleListCompat.forLanguageTags(language) - if (newLocales == AppCompatDelegate.getApplicationLocales()) return - - withContext(Dispatchers.Main) { - AppCompatDelegate.setApplicationLocales(newLocales) - } - } - - @OptIn(FlowPreview::class) - val saving = - value.propertyWatchFlow - .debounce(1000) - .distinctUntilChanged() - .onEach { - save(it, context) - }.flowOn(Dispatchers.IO) - .stateIn( - scope, - SharingStarted.Eagerly, - value.toSettings(), - ) - - 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 - } - } - - 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 - } - } 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}" } - } - } - } -} 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 new file mode 100644 index 0000000000..273445a3fb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UiSharedPreferences.kt @@ -0,0 +1,186 @@ +/* + * 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 android.app.UiModeManager +import android.content.Context +import android.os.Build +import androidx.appcompat.app.AppCompatDelegate +import androidx.compose.runtime.Stable +import androidx.core.content.getSystemService +import androidx.core.os.LocaleListCompat +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +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 +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +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 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, + dataStore: DataStore, + val context: Context, + val scope: CoroutineScope, +) { + 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) + + val languageUpdate = + value.preferredLanguage + .onEach { language -> applyLanguage(language) } + .flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + value.toSettings(), + ) + + val nightModeUpdate = + value.theme + .onEach { theme -> applyNightMode(theme) } + .flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + prefs.theme, + ) + + /** + * Mirrors the in-app theme choice into the system's *per-application* night mode, so the + * launch splash agrees with a theme that is pinned against the phone's own light/dark setting. + * + * The system composites the splash from the manifest theme before the process starts, resolving + * it against this app's configuration -- so day/night resource qualifiers alone can only ever + * follow the phone. [UiModeManager.setApplicationNightMode] commits a *persisted per-package + * configuration override* (UiModeManagerService hands it to + * ActivityTaskManagerInternal.PackageConfigurationUpdater), which the system then applies when + * it launches the app. That is what carries a pinned LIGHT/DARK choice into the splash, from + * the next cold start onwards -- the current launch is already painted. + * + * This is deliberately [UiModeManager.setApplicationNightMode] and not + * [UiModeManager.setNightMode]: the latter changes the night mode for every app on the device + * and is gated behind MODIFY_DAY_NIGHT_MODE, which this app does not hold -- that call was a + * silent no-op and was removed. The per-application setter is the documented app-local + * alternative and is not permission-checked; UiModeManagerService only validates the argument. + * + * MODE_NIGHT_AUTO is how [ThemeType.SYSTEM] is expressed: the service maps everything other + * than YES/NO onto `Configuration.UI_MODE_NIGHT_UNDEFINED`, which clears the override and lets + * the app fall back to the device configuration. + * + * Not deduplicated, deliberately. There is no public getter for the per-application override, + * so the only way to skip a repeat call would be to shadow it in our own store -- a cache of + * state we do not own, which goes stale silently and takes the splash with it. Re-sending the + * value on every launch is self-healing instead, and the platform already no-ops the expensive + * half: PackageConfigPersister.updateFromImpl returns early without writing when the mode is + * unchanged, and ActivityRecord.applyAppSpecificConfig gates the activity reconfiguration on + * having actually changed. What remains is one Binder round trip per launch, off the main + * thread. (This is why the deduplication in applyLanguage below does not generalise here: it + * compares against getApplicationLocales(), the authoritative value, not a private copy.) + * + * MainActivity declares `uiMode` in its `configChanges`, so any change that does result is + * delivered to `onConfigurationChanged` rather than recreating the activity. + */ + private suspend fun applyNightMode(theme: ThemeType) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return + + val mode = + when (theme) { + ThemeType.DARK -> UiModeManager.MODE_NIGHT_YES + ThemeType.LIGHT -> UiModeManager.MODE_NIGHT_NO + ThemeType.SYSTEM -> UiModeManager.MODE_NIGHT_AUTO + } + + try { + context.getSystemService()?.setApplicationNightMode(mode) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w("UiSharedPreferences", "Could not apply the per-application night mode", e) + } + } + + /** + * Pushes the preferred language into AppCompat, skipping the call when the app already + * runs in that locale. + * + * On API 33+, [AppCompatDelegate.setApplicationLocales] does not deduplicate: every call + * is a blocking Binder round trip into the system's LocaleManagerService, which commits a + * SharedPreferences file (and, on Samsung ROMs, appends to a log file) before returning. + * That was measured at ~220ms on a Galaxy device, charged to the calling thread. Since + * this flow starts eagerly, the app paid it on the main thread on every launch, even when + * the locale had not changed since the previous run -- and StrictMode reported it as a + * DiskReadViolation via the Binder call. + * + * [AppCompatDelegate.getApplicationLocales] is `@AnyThread` and only reads state, so the + * comparison runs off the main thread. Actual changes still hop to the main thread: + * below API 33 AppCompat applies them in process by reconfiguring (and possibly + * recreating) the active activities. + */ + private suspend fun applyLanguage(language: String?) { + val newLocales = LocaleListCompat.forLanguageTags(language) + if (newLocales == AppCompatDelegate.getApplicationLocales()) return + + withContext(Dispatchers.Main) { + AppCompatDelegate.setApplicationLocales(newLocales) + } + } + + @OptIn(FlowPreview::class) + val saving = + value.propertyWatchFlow + .debounce(1000) + .distinctUntilChanged() + .onEach { + store.save(it) + }.flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + value.toSettings(), + ) + + companion object { + suspend fun uiPreferences(dataStore: DataStore): UiSettings? = UiSettingsStore(dataStore).load() + } +} 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/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/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..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,9 +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.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 @@ -90,7 +87,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)) } @@ -203,8 +200,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 } @@ -213,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 } @@ -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/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/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/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/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/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..e430a79bc0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderStores.kt @@ -0,0 +1,82 @@ +/* + * 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.core.DataMigration +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.intPreferencesKey +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 +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. + * + * 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" + +/** 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" + +/** 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) + } + }, + ) + +/** 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 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 0ec27f7e71..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 @@ -48,7 +48,7 @@ import java.util.concurrent.TimeUnit * to as ACCEPTED. * * The work is bounded — scans LocalCache (which is bounded by the relay subscription) and - * consults [CalendarReminderStore] to skip events that have already been notified for. Run as + * consults [CalendarReminderLogStore] to skip events that have already been notified for. Run as * a 15-minute periodic worker: that's the WorkManager minimum and matches the resolution of * the reminder UI ("starts in ~15 min" is the smallest interval users perceive as "soon"). * @@ -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 = 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 = 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/service/pow/PowJobRestorer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowJobRestorer.kt index e28ec90c8a..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,6 +23,7 @@ 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.model.Account import com.vitorpamplona.quartz.nip01Core.core.Event @@ -33,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 08d040d01c..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 [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/navigation/bottombars/AppBottomBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt index 42c7f91d64..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 @@ -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 @@ -59,8 +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.FavoriteAppsRegistry import com.vitorpamplona.amethyst.favorites.rememberNappletIconModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -112,7 +111,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) { @@ -131,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/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/navigation/drawer/DrawerContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt index a500753e1b..2fe3758aa3 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.model.navigation.routeFor 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 5deaaacad1..0e0b1353f8 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/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 04244170d0..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,11 +96,13 @@ 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 import com.vitorpamplona.amethyst.commons.service.pow.PoWCategory import com.vitorpamplona.amethyst.commons.state.UiSettingsState +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.components.toasts.ToastManager @@ -115,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 @@ -141,7 +142,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.interactiveStories.InteractiveStoryBaseEvent 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..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 @@ -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,7 @@ 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,9 +151,12 @@ 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 iconKeys by BrowserIconRegistry.keys.collectAsStateWithLifecycle() + val apps by Amethyst.instance.favoriteApps.favorites + .collectAsStateWithLifecycle() + val history by Amethyst.instance.browserHistory.history + .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. @@ -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, ) } @@ -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, 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/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/buzz/AgentAttestationScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentAttestationScreen.kt index eaad72c995..273f518dd9 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/calendars/CalendarReminderSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarReminderSettingsScreen.kt index ea7a0d2f8b..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 @@ -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 @@ -54,20 +55,28 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.commons.ui.pluralStringRes import com.vitorpamplona.amethyst.commons.ui.stringRes -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.screen.loggedIn.settings.SettingsBlockTile 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 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 { 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/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/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt index 7ce0a5aed3..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,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.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 @@ -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..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,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.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 @@ -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..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,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.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 @@ -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/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 62% 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..a0cba559e4 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,19 @@ * AN ACTION OF CONTRACT, TORT OR 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 androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import com.vitorpamplona.amethyst.Amethyst /** - * 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 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 893c23fb17..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 @@ -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(chessDismissedGamesData) // Shared business logic (creates its own ChessLobbyState internally) private val logic = 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/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..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 @@ -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 @@ -72,9 +73,7 @@ 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.FavoriteAppsRegistry import com.vitorpamplona.amethyst.favorites.PreloadFavoriteNostrApps import com.vitorpamplona.amethyst.favorites.rememberNappletIconModel import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar @@ -94,7 +93,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 +126,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() @@ -189,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/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/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/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/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..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 @@ -89,7 +89,10 @@ 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.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 @@ -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/relays/common/BasicRelaySetupInfoClickableRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoClickableRow.kt index abe244fb9b..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,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.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 @@ -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..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,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.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 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..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,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.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 @@ -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..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,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.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 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..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,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.nip11RelayInfo.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/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 f165f0cb52..a5cb13d9f5 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 @@ -112,6 +111,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 @@ -135,7 +135,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 @@ -169,7 +168,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/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/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/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..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 @@ -34,14 +34,14 @@ 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.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.model.preferences.OtsSharedPreferences -import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @@ -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/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 a6b5937134..fcc9a24726 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.insets.imePaddingSafe import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.Size10dp @@ -89,7 +90,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText 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 3987e1dfcc..4425f0a89b 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.insets.imePaddingSafe import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.Size10dp @@ -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..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,8 +80,10 @@ 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.ui.stringRes 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()) + } +} 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/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..ddb6ca0aa0 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/LegacyPreferenceCleanupTest.kt @@ -0,0 +1,431 @@ +/* + * 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.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 +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, + 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 + + /** + * 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 || geohashPresent + + override suspend fun delete(npub: String): Boolean { + deleted = true + deletedGeohash = true + present = false + geohashPresent = false + return true + } +} + +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") + return stored + } + + override suspend fun privateKey(npub: String): String? { + if (throws) throw IllegalStateException("keystore unavailable") + return key + } + + override suspend fun geohashIdentity(npub: String): GeohashIdentitySecrets? { + if (throws) throw IllegalStateException("keystore unavailable") + return geohash + } +} + +/** + * 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) + } + + /** + * 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 secretsThatHaveMovedOnSinceTheCopyDoNotBlock() = + runTest { + val (files, subject) = + cleanup( + mapOf("legacy_flag" to true, "nip46BunkerSecret" to "what-the-file-still-says"), + secrets = FakeSecrets(stored = AccountSecrets(nip46BunkerSecret = "re-paired since")), + ) + + assertEquals(emptyList(), subject.verify(NPUB)) + assertEquals(LegacyCleanupResult.Deleted, subject.deleteIfVerified(NPUB)) + assertTrue(files.deleted) + } + + @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) + } + + /** Neither file — the account's own nor the location-chat one. */ + @Test + fun anAccountWithNoLegacyFileIsAlreadyDone() = + runTest { + val files = + FakeFiles(emptyMap()).also { + it.present = false + it.geohashPresent = 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), + ) + } + // ── 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) + } + + /** + * 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) + } +} 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/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/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..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,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 @@ -43,7 +44,7 @@ class PowAndUsageFileFormatTest { } private val powSample = - PowJobsFile( + PoWJobsFile( version = 1, jobs = listOf( @@ -79,7 +80,7 @@ class PowAndUsageFileFormatTest { @Test fun powJobsFromTheJacksonBuildStillLoad() { - val loaded = json.decodeFromString(POW_JACKSON_OUTPUT) + val loaded = json.decodeFromString(POW_JACKSON_OUTPUT) assertEquals(1, loaded.jobs.size) val job = loaded.jobs.first() @@ -113,7 +114,7 @@ class PowAndUsageFileFormatTest { assertEquals( "j1", json - .decodeFromString(pow) + .decodeFromString(pow) .jobs .first() .id, 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/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/build.gradle.kts b/commons/build.gradle.kts index b952fe67af..86c5c10c33 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) @@ -159,9 +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) - implementation(libs.androidx.datastore.preferences) + // 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..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 @@ -21,128 +21,130 @@ 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 return SecureKeyStorage() } + + /** + * 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 sharedStore by lazy { + EncryptedDataStore( + PreferenceDataStoreFactory.createWithPath( + scope = scope, + produceFile = { File(appContext.filesDir, STORE_FILE).toOkioPath() }, + ), + scope = scope, + ) + } } - // 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 store get() = sharedStore - @Suppress("DEPRECATION") - private val encryptedPrefs by lazy { - EncryptedSharedPreferences.create( - appContext, - PREFS_NAME, - masterKey, - EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, - EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, - ) - } + 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) } + /** + * 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 = - 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.contains(keyFor(npub)) + 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/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/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 53e63f7e8e..0000000000 --- a/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessDismissedGamesStorage.kt +++ /dev/null @@ -1,54 +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 - -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/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/BrowserHistoryRegistry.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserHistoryRegistry.kt similarity index 68% 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 5564139886..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,26 +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 androidx.datastore.preferences.preferencesDataStore -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 - -private val Context.browserHistoryDataStore by preferencesDataStore(name = "browser_history") +import kotlin.concurrent.Volatile /** * One device-local visited site, keyed by full [url]. [visitCount]/[lastVisitedAt] drive frecency ranking @@ -55,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 = ctx.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 } } @@ -100,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 = @@ -136,9 +134,9 @@ object BrowserHistoryRegistry { } private fun persist(json: String) { - val ctx = appContext ?: return + if (!started) return scope.launch { - ctx.browserHistoryDataStore.edit { it[KEY] = json } + store.edit { it[KEY] = json } } } @@ -151,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/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..bdd40d5199 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserIconRegistry.kt @@ -0,0 +1,148 @@ +/* + * 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, +) { + // 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>(emptySet()) + + /** Sanitized host keys that currently have a stored icon. Observe to recompose when an icon arrives. */ + val keys: StateFlow> = _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 { + 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 { + 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://" + (dir / (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/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/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..0efbba4b5e 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,13 @@ 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.IO 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 +45,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 +98,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 +172,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/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/favorites/FavoriteAppsRegistry.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/favorites/FavoriteAppsRegistry.kt similarity index 68% 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 99e36cf19b..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,79 +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 androidx.datastore.preferences.preferencesDataStore -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 - -private val Context.favoriteAppsDataStore by preferencesDataStore(name = "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 = ctx.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() @@ -128,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) { - val ctx = appContext ?: return + private fun persist( + key: Preferences.Key, + json: String, + ) { + if (!started) return scope.launch { - ctx.favoriteAppsDataStore.edit { it[KEY] = json } - } - } - - private fun persistManifests(json: String) { - val ctx = appContext ?: return - scope.launch { - ctx.favoriteAppsDataStore.edit { it[MANIFESTS_KEY] = json } + store.edit { it[key] = json } } } @@ -189,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. @@ -211,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/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/InMemoryMlsGroupStateStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/InMemoryMlsGroupStateStore.kt index d90de943c3..116ab56968 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/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/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/AccountPreferenceStores.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountPreferenceStores.kt new file mode 100644 index 0000000000..1cdcc25588 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountPreferenceStores.kt @@ -0,0 +1,114 @@ +/* + * 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 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 kotlinx.coroutines.job +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. + * + * [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() }, +) { + /** + * 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) { + 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 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. + * + * 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. + */ + 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 + platformFileSystem.delete(path) + return true + } +} 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..a188cb3719 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AppPreferenceStores.kt @@ -0,0 +1,171 @@ +/* + * 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 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 kotlinx.coroutines.job +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. + * + * @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 { + /** + * 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" + + private const val SUFFIX = ".preferences_pb" + } + + /** + * 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$SUFFIX" + + fun getDataStore(name: String): DataStore = + storeCache + .getOrCreate(name) { + val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + Entry( + scope, + PreferenceDataStoreFactory.createWithPath( + scope = scope, + migrations = migrations(name), + produceFile = { file(name) }, + ), + ) + }.store + + /** 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]. + * + * 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/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/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/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..671684e847 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/CopyOnceMigration.kt @@ -0,0 +1,73 @@ +/* + * 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 + +/** + * 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 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 + * 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. + */ +class CopyOnceMigration( + markerName: String, + private val copy: suspend (MutablePreferences) -> Unit, +) : 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() + + copy(updated) + updated[marker] = true + + return updated.toPreferences() + } + + override suspend fun cleanUp() = Unit +} 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/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..5a53bfdacd --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/DialogDismissalStore.kt @@ -0,0 +1,128 @@ +/* + * 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") + + /** + * 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 { + 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/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 92% 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 acca11936f..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 @@ -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/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..a72a628c97 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/FeedVisibilityStore.kt @@ -0,0 +1,102 @@ +/* + * 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") + + /** 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 { + 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/LatestEventCacheStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/LatestEventCacheStore.kt new file mode 100644 index 0000000000..f743239662 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/LatestEventCacheStore.kt @@ -0,0 +1,126 @@ +/* + * 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, +) { + 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 = + 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/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/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/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..fba9ded945 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/NotificationPrefsStore.kt @@ -0,0 +1,98 @@ +/* + * 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 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 + * 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") + + /** 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 = + 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, + ) + } + + /** 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..70e3b981e6 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/RelayAuthStore.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.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") + + /** + * 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 { + 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/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/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..e14d87a219 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/TopNavFollowListStore.kt @@ -0,0 +1,155 @@ +/* + * 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, +) { + 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. + * + * 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/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 85% 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 900cc3e78f..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,23 +18,25 @@ * AN ACTION OF CONTRACT, TORT OR 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.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 @@ -46,9 +48,9 @@ import kotlinx.coroutines.flow.stateIn import kotlin.coroutines.cancellation.CancellationException @Stable -class TorSharedPreferences( +class TorSettingsStore( 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 +65,7 @@ class TorSharedPreferences( .debounce(1000) .distinctUntilChanged() .onEach { - save(it, context) + save(it, store) }.flowOn(Dispatchers.IO) .stateIn( scope, @@ -71,9 +73,9 @@ class TorSharedPreferences( value.toSettings(), ) - override suspend fun loadLastBypassApprovalMs(): Long = TorSharedPreferences.loadLastBypassApprovalMs(context) + override suspend fun loadLastBypassApprovalMs(): Long = loadLastBypassApprovalMs(store) - override suspend fun saveLastBypassApprovalMs(value: Long) = TorSharedPreferences.saveLastBypassApprovalMs(value, context) + override suspend fun saveLastBypassApprovalMs(value: Long) = saveLastBypassApprovalMs(value, store) companion object { // loads faster when individualized @@ -92,10 +94,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, @@ -114,16 +116,16 @@ 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 } 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 @@ -141,30 +143,30 @@ 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}" } } } - 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}" } + Log.e("TorSettingsStore") { "Error reading lastBypassApprovalMs: ${e.message}" } 0L } 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) { 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/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/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..632afa0670 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/UploadSettingsStore.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.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") + + /** 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 { + 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/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..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 @@ -128,13 +129,60 @@ 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 — 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()) { + 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) + } + } + } + } // Track when games were last loaded to prevent duplicate fetches // (e.g., discoverUserGames loads a game, then polling immediately re-fetches it). @@ -964,23 +1012,15 @@ class ChessLobbyLogic( fun dismissCompletedGame(gameId: String) { state.removeCompletedGame(gameId) - val snapshot = - dismissedGameIdsLock.withLock { - dismissedGameIds.add(gameId) - dismissedGameIds.toSet() - } - dismissedStorage?.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?.save(userPubkey, snapshot) + dismissedGameIdsLock.withLock { dismissedGameIds.addAll(allIds) } + persistRequests.trySend(Unit) } /** 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/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/nip11RelayInfo/RelaySupportsNip.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/nip11RelayInfo/RelaySupportsNip.kt new file mode 100644 index 0000000000..d59f7bd6c1 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/nip11RelayInfo/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.nip11RelayInfo + +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/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/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/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/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 87% 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 7a99f80347..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,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.marmot +package com.vitorpamplona.amethyst.commons.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 @@ -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,18 +40,18 @@ 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 - * 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: KeyStoreEncryption = KeyStoreEncryption(), + private val encryption: SecretEncryption = SecretEncryption(), ) : KeyPackageBundleStore { private val mutex = Mutex() 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 95% 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 5d0928cd43..0c0cc10d4b 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,10 +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.marmot +package com.vitorpamplona.amethyst.commons.marmot +import com.vitorpamplona.amethyst.commons.model.preferences.SecretEncryption import com.vitorpamplona.amethyst.commons.storage.EncryptedAppendLog -import com.vitorpamplona.amethyst.model.preferences.KeyStoreEncryption import com.vitorpamplona.quartz.marmot.groups.MarmotMessageStore import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.utils.Log @@ -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: * ``` * /mls_groups//messages — encrypted message log * ``` @@ -44,15 +44,15 @@ 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: KeyStoreEncryption = KeyStoreEncryption(), + private val encryption: SecretEncryption = SecretEncryption(), ) : MarmotMessageStore { private val logMutex = Mutex() init { Log.d(TAG) { - "Initialized AndroidMarmotMessageStore: rootDir=${rootDir.absolutePath}" + "Initialized EncryptedMarmotMessageStore: rootDir=${rootDir.absolutePath}" } } @@ -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. @@ -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 94% 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 4a970b40f9..c7e2927b9c 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,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.marmot +package com.vitorpamplona.amethyst.commons.marmot -import com.vitorpamplona.amethyst.model.preferences.KeyStoreEncryption +import com.vitorpamplona.amethyst.commons.model.preferences.SecretEncryption import com.vitorpamplona.quartz.marmot.groups.MlsGroupStateStore import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.Dispatchers @@ -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 [KeyStoreEncryption] (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 * /mls_groups//ratchet — encrypted OwnSenderRatchet * ``` */ -class AndroidMlsGroupStateStore( +class EncryptedMlsGroupStateStore( private val rootDir: File, - private val encryption: KeyStoreEncryption = KeyStoreEncryption(), + 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 94% 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 445e91ee91..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,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.marmot +package com.vitorpamplona.amethyst.commons.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 @@ -31,8 +31,8 @@ import kotlinx.coroutines.withContext import java.io.File /** - * Android implementation of [MarmotPublishObligationStore], encrypted at rest - * with [KeyStoreEncryption] like the group-state and KeyPackage stores. + * File-backed [MarmotPublishObligationStore], encrypted at rest + * with [SecretEncryption] like the group-state and KeyPackage stores. * * ``` * /marmot_obligations/.obligation @@ -49,9 +49,9 @@ 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: KeyStoreEncryption = KeyStoreEncryption(), + private val encryption: SecretEncryption = SecretEncryption(), ) : MarmotPublishObligationStore { private val mutex = Mutex() @@ -184,6 +184,6 @@ class AndroidPublishObligationStore( } companion object { - private const val TAG = "AndroidPublishObligationStore" + private const val TAG = "EncryptedPublishObligationStore" } } 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) + } +} 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..46769ead3d --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountSecrets.kt @@ -0,0 +1,202 @@ +/* + * 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" +} + +/** + * 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" + + /** + * 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, + 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), + ) + +/** + * 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 new file mode 100644 index 0000000000..90d4a323e3 --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountSecretsEncryptedStores.kt @@ -0,0 +1,224 @@ +/* + * 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 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 kotlinx.coroutines.job +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: () -> Path, + val scope: CoroutineScope, + private val encryption: SecretEncryption = SecretEncryption(), +) { + companion object { + val nwc = stringPreferencesKey("nwc") + } + + /** + * 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, + ) + + 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) { + 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( + key = nwc, + parser = Nip47WalletConnect.Nip47URI::parser, + serializer = Nip47WalletConnect.Nip47URI::serializer, + ) + + /** + * Drops the account's secrets. + * + * 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. + */ + 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 + 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? { + // 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 = 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 and its marker as one edit. + * + * 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, + ) { + 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) + + put(AccountSecretKeys.migrated, "true") + } + } + + // ── 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) + ?.filter { it.isNotEmpty() } + ?.toSet() + ?: emptySet() +} 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 new file mode 100644 index 0000000000..477e50fe0b --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/EncryptedDataStore.kt @@ -0,0 +1,194 @@ +/* + * 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.MutablePreferences +import androidx.datastore.preferences.core.Preferences +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 +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: SecretEncryption = sharedSecretEncryption, + private val scope: CoroutineScope, +) { + private fun encrypt(value: String): String = Base64.encode(encryption.encrypt(value.encodeToByteArray())) + + private fun decrypt(value: String): String? = encryption.decrypt(Base64.decode(value))?.decodeToString() + + suspend fun remove(key: Preferences.Key) { + store.edit { prefs -> prefs.remove(key) } + } + + suspend fun save( + key: Preferences.Key, + value: String, + ) { + 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 -> + if (e is IOException) emit(emptyPreferences()) else throw e + }.firstOrNull() + ?.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. + * + * 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) } + + /** + * 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, + serializer: (T) -> String, + ): UpdatablePropertyFlow = + UpdatablePropertyFlow( + flow = + store.data + .catch { e -> + if (e is IOException) emit(emptyPreferences()) else throw e + }.map { prefs -> + prefs[key]?.let { decrypt(it) }?.takeIf { it.isNotBlank() }?.let(parser) + }, + update = { newValue -> + val serialized = newValue?.let(serializer) + if (serialized != null && serialized.isNotBlank()) { + save(key, serialized) + } else { + remove(key) + } + }, + 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/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/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessDismissedGamesStorage.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/SecretEncryption.kt similarity index 53% rename from commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessDismissedGamesStorage.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/SecretEncryption.kt index 7ea69cdd61..22ad8f450e 100644 --- a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessDismissedGamesStorage.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/SecretEncryption.kt @@ -18,34 +18,24 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.commons.nip64Chess +package com.vitorpamplona.amethyst.commons.model.preferences -import java.util.prefs.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 -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)) - } - } + /** Inverse of [encrypt]. Throws if the input is not what [encrypt] produced. */ + fun decrypt(bytes: ByteArray): ByteArray? } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/Nip11CachedRetriever.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relays/nip11RelayInfo/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/nip11RelayInfo/Nip11CachedRetriever.kt index e97b3a6270..b6fc1b1e38 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/Nip11CachedRetriever.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relays/nip11RelayInfo/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.nip11RelayInfo -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(1000) - private val relayInformationDocumentCache = LruCache(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(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/nip11RelayInfo/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/nip11RelayInfo/Nip11Retriever.kt index 10118b09ac..a8ca53429d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/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.model.nip11RelayInfo +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/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/RetrieveResult.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relays/nip11RelayInfo/RetrieveResult.kt similarity index 96% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/RetrieveResult.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relays/nip11RelayInfo/RetrieveResult.kt index 7f749ae12b..bb44f63cb7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/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.model.nip11RelayInfo +package com.vitorpamplona.amethyst.commons.relays.nip11RelayInfo 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/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 93% 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..7ee8316c18 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 @@ -41,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 { @@ -111,7 +109,7 @@ class PowJobStore( jobs = try { if (storageFile.exists() && storageFile.length() > 0) { - json.decodeFromString(storageFile.readText()).jobs.toMutableList() + json.decodeFromString(storageFile.readText()).jobs.toMutableList() } else { mutableListOf() } @@ -128,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" } @@ -146,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 @@ -156,7 +154,7 @@ class PowJobStore( } @Serializable -data class PowJobsFile( +data class PoWJobsFile( val version: Int = 1, val jobs: List = emptyList(), ) diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/storage/EncryptedAppendLog.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/storage/EncryptedAppendLog.kt index d81d5f37f3..0573a8bc96 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/storage/EncryptedAppendLog.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/storage/EncryptedAppendLog.kt @@ -65,7 +65,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/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 b2d4c32286..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.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/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..c11b1505da --- /dev/null +++ b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/SecretEncryption.jvm.kt @@ -0,0 +1,133 @@ +/* + * 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.appDataDir +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" + + internal fun defaultKeyFile(): File = File(appDataDir, 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/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessDismissedGamesStoreJvm.kt b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessDismissedGamesStoreJvm.kt new file mode 100644 index 0000000000..436f56a2a9 --- /dev/null +++ b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessDismissedGamesStoreJvm.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.nip64Chess + +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import com.vitorpamplona.amethyst.commons.util.appDataDir +import okio.Path.Companion.toOkioPath +import java.io.File + +/** + * 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 = 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() + ChessDismissedGamesStore(PreferenceDataStoreFactory.createWithPath(produceFile = { file.toOkioPath() })) +} diff --git a/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessDismissedGamesStorage.ios.kt b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/util/AppDataDir.kt similarity index 57% rename from commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessDismissedGamesStorage.ios.kt rename to commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/util/AppDataDir.kt index 9148daed9e..c589ceb54c 100644 --- a/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessDismissedGamesStorage.ios.kt +++ b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/util/AppDataDir.kt @@ -18,27 +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 -// 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 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 +/** + * 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/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/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) + } +} 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<DataMigration<Preferences>> = emptyList(), + ): DataStore<Preferences> = + 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/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(), + ) + } +} 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<Preferences> = + 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)) + } +} 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, + ) + } +} 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/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<DataMigration<Preferences>> = emptyList()): DataStore<Preferences> { + 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<String>(), 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<String>(), 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/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..5685c6c603 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountSecretsStoreTest.kt @@ -0,0 +1,320 @@ +/* + * 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)) + } + + /** + * 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)) + } + // ── 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_<npub>`, the identity from `secret_keeper_<pubkey hex>` — + * 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)) + } + + /** + * 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) + } +} 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..e6530895a7 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountSettingStoresTest.kt @@ -0,0 +1,193 @@ +/* + * 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<Preferences> { + 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) + } + + /** 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/AppPreferenceStoresTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AppPreferenceStoresTest.kt new file mode 100644 index 0000000000..59dfcefb7a --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AppPreferenceStoresTest.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.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.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()) + } + + /** + * 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<String>() + 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) + } + + /** + * 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) + } +} 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)) } } 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<Preferences> { + 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]) + } +} 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 92% 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..83bfc0a5b9 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 @@ -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 new file mode 100644 index 0000000000..07b4a055f4 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/EncryptedDataStoreTest.kt @@ -0,0 +1,300 @@ +/* + * 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.edit +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.job +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 + +/** + * 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)) + } + + /** + * [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() + scope.coroutineContext.job.join() + + // 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() + } + + /** + * 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() + } + + @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/model/preferences/LatestEventCacheStoreTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/LatestEventCacheStoreTest.kt new file mode 100644 index 0000000000..32d0003c47 --- /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<DataMigration<Preferences>> = 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") { 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/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<String, Any>, +) : 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<String>? +} + +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<Preferences> { + 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) + } +} 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..65a93958ec --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/SecretEncryptionTest.kt @@ -0,0 +1,127 @@ +/* + * 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.FileSystems +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) } + } + + 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()) + 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")) + } +} 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..f0fa524877 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/TopNavFollowListStoreTest.kt @@ -0,0 +1,229 @@ +/* + * 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.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 + +class TopNavFollowListStoreTest { + @get:Rule + val folder = TemporaryFolder() + + private var seq = 0 + + private fun store( + scope: CoroutineScope, + migrations: List<DataMigration<Preferences>> = 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<TopFilter>.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") { out -> legacy.forEach { (k, v) -> out[k] = v } })).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 <T> withStore(block: suspend (TopNavFollowListStore) -> T): T { + val scope = scope() + try { + return block( + TopNavFollowListStore( + PreferenceDataStoreFactory.createWithPath( + scope = scope, + migrations = + listOf( + CopyOnceMigration("migrated.followLists") { out -> + reads++ + legacy.forEach { (k, v) -> out[k] = v } + }, + ), + produceFile = { file.toOkioPath() }, + ), + ), + ) + } 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() + } + } + + 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) + } +} 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<Preferences> = + 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/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<androidx.datastore.core.DataMigration<Preferences>> = { emptyList() }) = AppPreferenceStores(rootFilesDir = { folder.root.toOkioPath() }, migrations = migrations) + + private fun rawStore(name: String): DataStore<Preferences> = + 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(), + ) + } +} 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..9e28130961 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessDismissedGamesStoreTest.kt @@ -0,0 +1,122 @@ +/* + * 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.assertSame +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<Preferences> { + 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")) + } + + /** + * 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()) + } +} 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" diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/Nip11RetrieverTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relays/nip11RelayInfo/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/nip11RelayInfo/Nip11RetrieverTest.kt index 07c46de62e..73a11a6335 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/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.model.nip11RelayInfo +package com.vitorpamplona.amethyst.commons.relays.nip11RelayInfo 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(" ")) + } +} 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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorSettings.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/tor/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/tor/TorSettingsLabels.kt index 4443d4bbdc..e9fdf4b476 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorSettings.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/tor/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.tor import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.tor_custom @@ -34,16 +34,17 @@ import com.vitorpamplona.amethyst.commons.resources.tor_small_payloads import com.vitorpamplona.amethyst.commons.resources.tor_small_payloads_explainer import com.vitorpamplona.amethyst.commons.resources.tor_when_needed import com.vitorpamplona.amethyst.commons.resources.tor_when_needed_explainer -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 UiSettingsLabels: 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 +53,6 @@ val TorType.resourceId: StringResource TorType.EXTERNAL -> Res.string.tor_external } -// Catalog keys for TorPresetType val TorPresetType.resourceId: StringResource get() = when (this) { 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 = 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<DerivedSecret> + /** + * 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<DerivedSecret> = 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<DerivedSecret> { 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<DerivedSecret> { 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<Pair<String, Int>>() + 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<String, Long> = client.keysets().keysets.associate { it.id to (it.inputFeePpk ?: 0L) } - private fun createBlindedOutputs( + private suspend fun createBlindedOutputs( amount: Long, keyset: KeysetDto, ): List<BlindOutput> = 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<Long>, keyset: KeysetDto, ): List<BlindOutput> { @@ -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)