mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 11:18:24 +00:00
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:
+114
-17
@@ -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.
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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)
|
||||
}
|
||||
|
||||
+107
-42
@@ -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 {
|
||||
|
||||
+38
-15
@@ -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,
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+1
-34
@@ -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))
|
||||
|
||||
+47
-89
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-83
@@ -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
|
||||
}
|
||||
}
|
||||
-78
@@ -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
|
||||
}
|
||||
}
|
||||
-108
@@ -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,
|
||||
)
|
||||
}
|
||||
-322
@@ -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}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+186
@@ -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()
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
-7
@@ -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)
|
||||
}
|
||||
|
||||
+15
-5
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-66
@@ -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"
|
||||
}
|
||||
}
|
||||
-84
@@ -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
|
||||
}
|
||||
}
|
||||
+82
@@ -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))
|
||||
+6
-6
@@ -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) {
|
||||
|
||||
+1
-1
@@ -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.
|
||||
*
|
||||
|
||||
+6
-5
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+3
-2
@@ -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
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+1
-33
@@ -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(
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+16
-16
@@ -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) {
|
||||
|
||||
+5
-5
@@ -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>`
|
||||
|
||||
+1
-1
@@ -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(
|
||||
|
||||
+18
-11
@@ -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 = {},
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+13
-15
@@ -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")
|
||||
+2
-2
@@ -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 =
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+4
-2
@@ -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
|
||||
|
||||
+5
-4
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
+7
-6
@@ -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)
|
||||
}
|
||||
|
||||
+5
-4
@@ -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))
|
||||
}
|
||||
},
|
||||
) {
|
||||
|
||||
+6
-5
@@ -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()))
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-2
@@ -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)
|
||||
}
|
||||
|
||||
+4
-4
@@ -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(
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+2
-3
@@ -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
|
||||
|
||||
+5
-3
@@ -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, "")
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+2
-2
@@ -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,
|
||||
|
||||
+3
-3
@@ -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,
|
||||
) {
|
||||
|
||||
+1
-1
@@ -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(
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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() {
|
||||
|
||||
+1
-1
@@ -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")
|
||||
|
||||
+1
-1
@@ -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)
|
||||
}
|
||||
}
|
||||
-237
@@ -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)
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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
|
||||
|
||||
+4
-3
@@ -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)
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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 =
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+86
-84
@@ -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
|
||||
}
|
||||
|
||||
+5
-5
@@ -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
Reference in New Issue
Block a user