Merge pull request #4190 from vitorpamplona/claude/happy-mccarthy-kdhg2s

refactor: move the preference layer off EncryptedSharedPreferences and into commons
This commit is contained in:
Vitor Pamplona
2026-09-26 16:06:29 -04:00
committed by GitHub
195 changed files with 11841 additions and 2781 deletions
+114 -17
View File
@@ -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 +
@@ -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_<pubkey hex>`, 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_<npub>` **and** the location-chat
identity's `secret_keeper_<pubkey hex>` — 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.
@@ -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
@@ -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
@@ -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_<npub>`, 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)))
}
@@ -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"
@@ -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_<npub>` 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_<pubkey hex>`. 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,
),
)
}
@@ -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.
@@ -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<Int>(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/<name>.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:<signer>:<client>`) 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)
@@ -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"
@@ -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_<npub>` 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<String>,
) : 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_<pubkey hex>` rather than `secret_keeper_<npub>`
* 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_<npub>` 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<LegacyKeyTable>,
private val accepted: Set<String>,
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<String> = 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<String> {
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<String>()
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<String> {
val reasons = mutableListOf<String>()
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_<pubkey hex>` 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<String> {
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"))
}
}
}
@@ -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<String> = 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<String>? = prefs.getStringSet(name, null)?.toSet()
}
File diff suppressed because it is too large Load Diff
@@ -1,124 +0,0 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.favorites
import android.content.Context
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.io.File
/**
* Device-local favicon store for browsed sites, keyed by host. Favicons are **captured from the WebView
* that already loaded the page** in the keyless `:napplet` browser host (where they ride the page's own —
* Tor-routed — network path) and relayed here as PNG bytes over IPC; this is the privacy-preserving
* alternative to the main app fetching `host/favicon.ico` itself, which would bypass Tor and leak the
* visit. Used to decorate favorite cards and omnibox suggestion rows.
*
* Lives only in the **main process**. Bytes are persisted as one small PNG per host under
* `filesDir/browser_icons`; the deterministic path means the only in-memory state is [keys] — the set of
* hosts that currently have an icon — which exists purely to drive Compose recomposition (and to keep
* `File.exists()` disk checks out of composition).
*/
object BrowserIconRegistry {
private const val DIR = "browser_icons"
private val _keys = MutableStateFlow<Set<String>>(emptySet())
/** Sanitized host keys that currently have a stored icon. Observe to recompose when an icon arrives. */
val keys: StateFlow<Set<String>> = _keys.asStateFlow()
@Volatile private var iconDir: File? = null
// Disk work runs here, never on the caller's thread. Both entry points are reached from threads
// that must not block: init() from app startup and record() from the broker's IPC handler, which
// is the main looper — StrictMode flagged the write, and a slow filesystem would have stalled the
// UI while a favicon was saved.
private val io = CoroutineScope(SupervisorJob() + Dispatchers.IO)
/**
* Binds the app context and indexes already-stored icons. Idempotent.
*
* [iconDir] is published synchronously so [iconModelFor] and [record] work immediately; only the
* directory scan is deferred. Until it lands [keys] is empty, so an icon simply renders its
* placeholder for one frame and then recomposes — [keys] is a StateFlow precisely so that arrival
* drives recomposition.
*/
fun init(context: Context) {
if (iconDir != null) return
val dir = File(context.applicationContext.filesDir, DIR)
iconDir = dir
io.launch {
dir.mkdirs()
_keys.value = dir.listFiles()?.mapNotNull { it.name.removeSuffix(PNG).takeIf { n -> n.isNotBlank() } }?.toSet() ?: emptySet()
}
}
/** Persists [bytes] as the favicon for [host] and marks it available. Called from the broker on IPC. */
fun record(
host: String,
bytes: ByteArray,
) {
val dir = iconDir ?: return
if (host.isBlank() || bytes.isEmpty()) return
val key = sanitize(host)
// Fire-and-forget: a favicon is a decoration, and the IPC handler must not wait on disk.
// [keys] updates only after the bytes are actually on disk, so a reader can never be told an
// icon exists before the file backing it does.
io.launch {
try {
dir.mkdirs()
File(dir, key + PNG).writeBytes(bytes)
_keys.update { it + key }
} catch (e: Exception) {
Log.w("BrowserIconRegistry", "Failed to store favicon for $host", e)
}
}
}
/**
* A Coil model (`file://…`) for [host]'s favicon, or null when none is stored. Reads [keys] so callers
* that observe the flow recompose as icons arrive — pass [keys]'s value as a `remember` key.
*/
fun iconModelFor(host: String): String? {
val dir = iconDir ?: return null
val key = sanitize(host)
if (key !in _keys.value) return null
return "file://" + File(dir, key + PNG).absolutePath
}
// Hosts map to a flat, filesystem-safe filename. Collisions (two hosts → one key) only mean a shared
// icon file, which is harmless for a decoration.
private fun sanitize(host: String): String =
host
.lowercase()
.map { if (it.isLetterOrDigit() || it == '.' || it == '-') it else '_' }
.joinToString("")
.take(120)
private const val PNG = ".png"
}
@@ -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(
@@ -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) }
}
/**
@@ -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)
@@ -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)
}
@@ -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<String, KeyPair>()
/**
* 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<String, KeyPair>()
@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_<hex>`, a different file from the account's own
* `secret_keeper_<npub>`. 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_<pubkey hex>` 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 {
@@ -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<String, AppPreferenceStores>()
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,
@@ -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
@@ -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))
@@ -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_<npub>` 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_<npub>.xml`. Keys are flat:
* - `counter_<keysetId>` → 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<String, CashuKeysetCounterStore>()
/**
* 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_<npub>`, 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
}
}
}
}
@@ -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<String, DataStore<Preferences>>()
fun file(npub: String) = File(rootFilesDir(), "datastore/$npub.preferences")
private fun getDataStore(npub: String): DataStore<Preferences> =
storeCache.getOrCreate(npub) {
PreferenceDataStoreFactory.create(
produceFile = { file(npub) },
)
}
fun removeAccount(npub: String): Boolean {
val deleted = file(npub).delete()
storeCache.remove(npub)
return deleted
}
}
@@ -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<String, EncryptedDataStore>()
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<Nip47WalletConnect.Nip47URI> =
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
}
}
@@ -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<Preferences>,
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<String>) {
store.edit { prefs ->
prefs.remove(key)
}
}
suspend fun save(
key: Preferences.Key<String>,
value: String,
) {
store.edit { prefs ->
prefs[key] = encrypt(value)
}
}
suspend fun get(key: Preferences.Key<String>): String? =
store.data
.catch { e ->
if (e is IOException) emit(emptyPreferences()) else throw e
}.firstOrNull()
?.get(key)
?.let { decrypt(it) }
fun <T> getProperty(
key: Preferences.Key<String>,
parser: (String) -> T,
serializer: (T) -> String,
): UpdatablePropertyFlow<T> =
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,
)
}
@@ -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<Preferences> 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<UiModeManager>()?.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}" }
}
}
}
}
@@ -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<Preferences>,
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<UiModeManager>()?.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<Preferences>): UiSettings? = UiSettingsStore(dataStore).load()
}
}
@@ -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
@@ -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
@@ -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 `"<coordinate>\u0000<capability>"` so a coordinate's grants can
@@ -43,9 +39,6 @@ class DataStoreNappletPermissionStore(
private val dataStore: DataStore<Preferences>,
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
@@ -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<Preferences>,
private val accountPubKey: () -> String,
) : NappletStorage {
constructor(context: Context, accountPubKey: () -> String) :
this(context.applicationContext.nappletStorageDataStore, accountPubKey)
override suspend fun get(
coordinate: String,
key: String,
@@ -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
}
@@ -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)
}
@@ -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<Preferences>
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 }
}
}
}
@@ -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)
}
@@ -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<Preferences>
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 }
}
}
}
@@ -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"
}
}
@@ -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
}
}
@@ -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<DataMigration<Preferences>> =
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<DataMigration<Preferences>> =
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))
@@ -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
@@ -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) {
@@ -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.
*
@@ -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)
}
}
@@ -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
@@ -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
@@ -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<String>): Set<DrawerSectionId> = 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<DrawerSectionId>.toNames(): Set<String> = mapTo(mutableSetOf()) { it.name }
private val DrawerNavigateItems: List<NavBarItem> =
listOf(
@@ -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
@@ -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<String>,
modifier: Modifier = Modifier,
) {
val model = remember(host, iconKeys) { BrowserIconRegistry.iconModelFor(host) }
val model = remember(host, iconKeys) { Amethyst.instance.browserIcons.iconModelFor(host) }
val symbol = if (isFavorite) MaterialSymbols.Star else MaterialSymbols.Public
val tint = MaterialTheme.colorScheme.onSurfaceVariant
if (model == null) {
@@ -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:<origin>`
@@ -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(
@@ -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 = {},
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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<String>
fun save(
userPubkey: String,
ids: Set<String>,
)
}
internal val chessDismissedGamesData: DataStore<Preferences>
get() = Amethyst.instance.appStores.getDataStore("chess_dismissed_games_v2")
@@ -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 =
@@ -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
@@ -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
@@ -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<FavoriteApp.NostrApp>().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) }
}
}
@@ -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)
}
@@ -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))
}
},
) {
@@ -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()))
}
},
)
@@ -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
@@ -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
@@ -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
@@ -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)
}
@@ -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(
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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, "")
}
@@ -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
@@ -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,
@@ -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,
) {
@@ -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(
@@ -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
@@ -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() {
@@ -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")
@@ -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() {
@@ -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
@@ -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
@@ -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<String, String> = 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())
}
}
@@ -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)
}
}
@@ -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<String> =
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<String>(),
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<String>(), migrated - allPrefKeys)
assertEquals(emptySet<String>(), LegacyAccountSecretNames.all - allPrefKeys)
assertEquals(emptySet<String>(), 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)
}
}
@@ -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<String, Any>,
) : 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<String>?
}
private class FakeFiles(
private val values: Map<String, Any>,
private val geohashValues: Map<String, Any> = emptyMap(),
) : LegacyAccountFiles {
var deleted = false
private set
/** The `secret_keeper_<pubkey hex>` 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_<npub>` 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<String, Any>,
current: Preferences = migrated,
secrets: MigratedSecrets = FakeSecrets(),
files: FakeFiles = FakeFiles(values),
retired: Boolean = true,
accepted: Set<String> = 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<String>(), 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<String>(), 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<String>(), 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<String>(), 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<String>(), 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_<pubkey hex>`, 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)
}
}
@@ -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<String, Any?>()
override fun getAll(): MutableMap<String, *> = data
override fun getString(
key: String,
defValue: String?,
): String? = data[key] as? String ?: defValue
override fun getStringSet(
key: String,
defValues: MutableSet<String>?,
): MutableSet<String>? {
@Suppress("UNCHECKED_CAST")
return data[key] as? MutableSet<String> ?: 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<String, Any?>,
) : SharedPreferences.Editor {
private val pending = mutableMapOf<String, Any?>()
private val removed = mutableSetOf<String>()
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<String>?,
): 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)
}
}
@@ -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
@@ -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
@@ -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<PowJobsFile>(POW_JACKSON_OUTPUT)
val loaded = json.decodeFromString<PoWJobsFile>(POW_JACKSON_OUTPUT)
assertEquals(1, loaded.jobs.size)
val job = loaded.jobs.first()
@@ -113,7 +114,7 @@ class PowAndUsageFileFormatTest {
assertEquals(
"j1",
json
.decodeFromString<PowJobsFile>(pow)
.decodeFromString<PoWJobsFile>(pow)
.jobs
.first()
.id,
@@ -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
@@ -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)
}
}
@@ -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 =
+10 -3
View File
@@ -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.
}
}
@@ -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
}
@@ -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

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