Compare commits

..
Author SHA1 Message Date
Claude 722d9526a1 fix: log unparseable relay frames at WARN, not ERROR
A frame the client can't decode (non-standard framing, unknown command,
malformed JSON) is a relay-side quirk, not an Amethyst error. For example,
multiplextr.coracle.social replies to a plain REQ with its own multiplexer
envelope `[{"relays":[]},["NOTICE","","Unable to handle message"]]`, whose
first element is an object rather than a command label, so the decoder
throws `Message null is not supported`.

BasicRelayClient already drops such frames and its comment states it
"doesn't expose parsing errors to lib users as errors", yet it logged at
ERROR. Downgrade to WARN so these relay-side quirks stop surfacing as app
errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01THVD3sY6xrxGzBHhUnhJAY
2026-07-07 22:14:39 +00:00
2240 changed files with 17681 additions and 241724 deletions
-46
View File
@@ -160,17 +160,6 @@ Summarize the survey in your plan: for each component, note whether it's
reused as-is, extracted from `amethyst/` to `commons/`, genuinely new
(platform-specific only), or a duplicate of an existing pattern to avoid.
**Relay client ops already exist — don't hand-roll subscribe/REQ/publish loops.**
One-shot and high-level relay operations (fetch a set, fetch one, page past the
relay cap, publish-and-confirm, NIP-45 count, NIP-77 sync/reconcile) are
`INostrClient` **extension functions** in
`quartz/…/nip01Core/relay/client/accessories/` (+ `…/reqs/` for the flow/subscribe
helpers). Because they're extensions, they don't surface under "usages of
`NostrClient`" or in completion — grep that package (or read its `README.md`, which
catalogs them) before writing a new subscription/collect loop. Reuse `fetchAll`,
`fetchFirst`, `fetchAllPages`, `publishAndConfirm`, `count`, `negentropyReconcile`,
etc. instead of re-implementing them.
**Share vs keep platform-native:**
- **Share** → `quartz/commonMain/` (business logic, data models, protocol) and
@@ -282,41 +271,6 @@ Do this before considering the task complete.
- The only acceptable inline fully-qualified names are: a genuine name
collision (prefer `import ... as Alias` instead), or where the language
requires it. Comments, KDoc, and string literals are exempt.
- **Prefer the `androidx.core` KTX extension over the raw platform Java call**
when one exists — this is what Android Lint's `UseKtx` flags. Common swaps:
`Bitmap.createBitmap(w, h, cfg)``createBitmap(w, h)`,
`Bitmap.createScaledBitmap(src, w, h, f)``src.scale(w, h, f)`,
`Uri.parse(s)``s.toUri()`, and `prefs.edit()…apply()``prefs.edit { }`.
Only adopt the KTX form when it's behaviour-preserving: keep any explicit
argument that differs from the extension's default (a non-`ARGB_8888`
`Bitmap.Config`, `scale(filter = false)`), and leave calls the KTX has no
equivalent for (e.g. the `createBitmap` pixels/matrix overloads, or a
conditional-`apply()` editor loop) untouched.
- **This "prefer the KTX sugar" rule does NOT extend to collection operators.**
The KTX preference is about platform wrappers (`Bitmap`/`Uri`/`SharedPreferences`),
which compile to the identical call. Collections are the opposite: in hot
event/parse paths Quartz deliberately uses raw JVM arrays (`TagArray =
Array<Array<String>>`) and the inline `fast*` operators (`fastForEach`,
`fastAny`, `fastFirstOrNull`, `fastFirstNotNullOfOrNull`, … in
`nip01Core/core/TagArray.kt`) instead of Kotlin `List` + stdlib
`forEach`/`map`/`filter`/`any` — the `fast*` variants allocate no iterator,
no intermediate list, and no lambda object. Don't "modernize" those into
stdlib collection calls; match the surrounding hot-path style.
- **Never put raw invisible/bidirectional Unicode characters in source files**
— write them as `\uXXXX` escapes instead (`'\u202E'`, `Regex("[\u200B-\u200D\uFEFF]")`).
This covers the bidi family Sonar's Trojan-Source rule (CVE-2021-42574)
flags — U+202AU+202E, the isolates U+2066U+2069, U+200E/U+200F, U+061C —
plus zero-width characters (U+200BU+200D, U+FEFF, U+2060). The escape
compiles to the identical codepoint, so behaviour is unchanged; the point is
that the file on disk stays visually unambiguous. Applies even when the
character is *intentional* (sanitizer strip-lists, adversarial test
payloads) — that's data, and escapes express it just as well. Exceptions:
U+200D as part of a real emoji ZWJ sequence in test data (👩‍👧 — functional,
not a bidi control), and LRM/RLM inside Crowdin-managed `strings.xml`
translations (legitimate RTL typography; don't touch those files by hand
anyway). Note the tooling trap: the Edit tool may normalise a typed
`\uXXXX` back into the raw character — if that happens, do the replacement
at byte level (`perl -CSD -pe 's/\x{202E}/\\u202E/g'`).
### Navigation Shell
- **Desktop**: Sidebar + main content area
+9 -4
View File
@@ -63,10 +63,15 @@ before="$(git diff HEAD -- '*.kt' '*.kts' 2>/dev/null | sha1sum)"
log="$(mktemp /tmp/spotless-gate.XXXXXX.log)"
if ! ./gradlew spotlessApply >"$log" 2>&1; then
# Any failure blocks. The web sandbox pre-seeds the Gradle distribution (see
# .claude/hooks/session-start.sh) and Gradle resolves deps through the proxy,
# so spotlessApply no longer fails for infra reasons — a failure here is a
# real formatting/compile error, not a restricted-sandbox hiccup.
# Distinguish a formatting failure (block) from Gradle being unable to RUN —
# e.g. deps can't resolve in a restricted sandbox. An infra failure must not
# strand the agent; warn and let CI's spotlessCheck be the backstop.
if grep -qiE "could not resolve|could not (get|download)|handshake|connect timed out|no address|unable to (find|resolve) host|read timed out" "$log"; then
echo "WARN: could not run spotlessApply (Gradle infra/network failure), skipping the formatting gate." >&2
echo " CI's spotlessCheck still enforces formatting on the PR." >&2
rm -f "$log"
exit 0
fi
echo "BLOCKED: spotlessApply failed — fix the build/formatting error before pushing." >&2
echo "----- gradle output (tail) -----" >&2
tail -n 40 "$log" >&2
-54
View File
@@ -211,60 +211,6 @@ install_konan_dep "llvm-19-x86_64-linux-essentials-109" \
install_konan_dep "libffi-3.2.1-2-linux-x86-64" \
"$KONAN_DEPS_URL/libffi-3.2.1-2-linux-x86-64.tar.gz"
# --- Gradle distribution: pre-seed the wrapper distribution ---
# The wrapper's distributionUrl (services.gradle.org) 307-redirects to
# github.com release assets, which the web sandbox's git-only GitHub proxy
# blocks (403) even at Full network access — so `./gradlew` can't bootstrap.
# Download the pinned distribution from a mirror instead, but verify it against
# Gradle's OFFICIAL sha256 (served from services.gradle.org, reachable here)
# so a tampered/wrong mirror file is rejected and never executed. Idempotent:
# skips entirely if the distribution is already installed.
seed_gradle_distribution() {
local props="$CLAUDE_PROJECT_DIR/gradle/wrapper/gradle-wrapper.properties"
[ -f "$props" ] || return 0
local url zip name hash dir ver official mirror ok=""
url=$(sed -n 's/^distributionUrl=//p' "$props" | sed 's/\\//g')
[ -n "$url" ] || return 0
zip=${url##*/}; name=${zip%.zip}
# Gradle stores the dist under base36(md5(distributionUrl)) — derive it so this
# keeps working across version bumps instead of hardcoding the hash dir.
hash=$(python3 - "$url" <<'PY'
import hashlib, sys
n = int.from_bytes(hashlib.md5(sys.argv[1].encode()).digest(), 'big')
d = "0123456789abcdefghijklmnopqrstuvwxyz"; s = ""
while n:
s = d[n % 36] + s; n //= 36
print(s or "0")
PY
)
dir="${GRADLE_USER_HOME:-$HOME/.gradle}/wrapper/dists/$name/$hash"
ver=${name%-bin}; ver=${ver%-all}
if [ -x "$dir/$ver/bin/gradle" ]; then return 0; fi # already installed
echo "Seeding Gradle distribution $ver (github release blocked; using verified mirror)..." >&2
mkdir -p "$dir"
official=$(curl -fsSL "https://services.gradle.org/distributions/${zip}.sha256") || {
echo "Could not fetch official Gradle checksum; leaving gradlew to fail as before." >&2
return 0
}
for mirror in \
"https://mirrors.cloud.tencent.com/gradle" \
"https://mirrors.huaweicloud.com/gradle"; do
if curl -fsSL -o "$dir/$zip" "$mirror/$zip" \
&& echo "${official} $dir/$zip" | sha256sum -c - >/dev/null 2>&1; then
ok=1; break
fi
echo "Mirror $mirror failed download/verify; trying next." >&2
rm -f "$dir/$zip"
done
if [ -z "$ok" ]; then
echo "Gradle seed failed against all mirrors; leaving gradlew to fail as before." >&2
return 0
fi
unzip -q "$dir/$zip" -d "$dir" && touch "$dir/$zip.ok"
echo "Gradle $ver seeded and verified against official sha256." >&2
}
seed_gradle_distribution
cd "$CLAUDE_PROJECT_DIR"
./gradlew --version > /dev/null 2>&1
+11 -136
View File
@@ -7,9 +7,7 @@ description: Use when comparing Android strings.xml locale files to find untrans
## Overview
Extract string resource keys from a default `values/strings.xml` that are absent in a target locale's `strings.xml`, excluding non-translatable entries. Outputs missing keys and offers to translate them.
The repo now has **two independent Crowdin-managed resource trees** — you must scan **both** (see "Resource trees" below).
Extract string resource keys from the default `values/strings.xml` that are absent in a target locale's `strings.xml`, excluding non-translatable entries. Outputs missing keys and offers to translate them.
## When to Use
@@ -17,56 +15,6 @@ The repo now has **two independent Crowdin-managed resource trees** — you must
- Preparing a batch of strings for a translator
- Checking translation coverage after adding new features
## Resource trees (scan BOTH)
There are two separate `strings.xml` trees, each with its own default `values/` and per-locale `values-<locale>/` files, each wired into `crowdin.yml` independently:
| Tree | Default file | Per-locale file |
|------|--------------|-----------------|
| **amethyst** (Android app) | `amethyst/src/main/res/values/strings.xml` | `amethyst/src/main/res/values-<locale>/strings.xml` |
| **commons** (KMP Compose resources, shared by Android + Desktop) | `commons/src/commonMain/composeResources/values/strings.xml` | `commons/src/commonMain/composeResources/values-<locale>/strings.xml` |
The `commons` tree appeared when shared event-renderer composables were extracted out of `amethyst/` into `commons/` (Compose Multiplatform `stringResource`). It is **not** a copy of the amethyst tree — the vast majority of its keys are commons-only; only a small handful overlap. Every diff/count/translate command below works on either tree by swapping the base path — **run the whole technique once per tree** and report them separately (each maps to its own Crowdin file, so the counts should reconcile against two different Crowdin UI numbers).
**Locale-qualifier caveat:** `commons` uses the same region-qualified locale dirs as amethyst for our four targets (`values-cs`, `values-de-rDE`, `values-sv-rSE`, `values-pt-rBR`), but the *full* set of locale dirs differs between trees. Enumerate `values-*` under each tree's own base rather than assuming they match.
**Overlap (copy — but only after checking the English matches):** a few `commons` keys share a *name* with a key in the amethyst tree. For such a key already translated in the amethyst locale file you may **copy the existing approved translation verbatim** — but **only if the two English source values are byte-identical.** A shared key name does **not** guarantee a shared meaning.
> ⚠️ **Mistake we actually made (2026-07-18):** `napplet_card_permissions` exists in *both* trees with the *same key name* but *different English* — commons = `"What it can access"`, amethyst = `"Permissions:"`. Copying the amethyst translation by key name produced the wrong string in commons (it said "Permissions:" where the UI reads "What it can access"). **Always diff the English values, not just the key names.** When the English differs, translate the commons value fresh — or, better, find the amethyst key whose *value* matches (here `favorite_app_access_show` = "What it can access") and copy *that* approved translation.
Detect name-overlap **and flag value mismatches** in one pass:
```bash
cdef=commons/src/commonMain/composeResources/values/strings.xml
adef=amethyst/src/main/res/values/strings.xml
comm -12 \
<(grep '<string name=' "$cdef" | sed 's/.*name="\([^"]*\)".*/\1/' | sort -u) \
<(grep '<string name=' "$adef" | grep -v 'translatable="false"' | sed 's/.*name="\([^"]*\)".*/\1/' | sort -u) \
| while read -r k; do
cv=$(grep -m1 "name=\"$k\"" "$cdef" | sed 's/.*>\(.*\)<\/string>/\1/')
av=$(grep -m1 "name=\"$k\"" "$adef" | sed 's/.*>\(.*\)<\/string>/\1/')
[ "$cv" = "$av" ] && echo "SAFE-COPY $k" || echo "VALUE-DIFFERS $k commons=\"$cv\" amethyst=\"$av\""
done
```
Only `SAFE-COPY` keys may be copied verbatim. For `VALUE-DIFFERS`, translate the commons English fresh (or copy from the amethyst key that has the *matching value*).
**Whitespace-quote convention differs between trees.** Android string resources use surrounding double-quotes to preserve leading/trailing whitespace (`"replying to "`). The **commons Compose-resources tree does NOT use this convention** — it authors trailing/leading spaces raw and unquoted (`replying to `). So when copying/translating a commons string with edge whitespace, **match the commons source: raw spaces, no wrapping quotes.** (Mistake we made: we copied amethyst's quoted `"replying to "` into commons, where the quotes would render literally.) A quick check for stray quote-wrapping you introduced:
```bash
grep -nE '<string name="[^"]*">"' commons/src/commonMain/composeResources/values-*/strings.xml
# The commons English tree has zero quote-wrapped values — any hit in a locale file is almost certainly a bad copy from amethyst.
```
**Why two catalogs exist — the duplication is NOT a bug to "fix" (don't ask again).** You will see the same English text (`Cancel`, `Save`, `Delete`, `Open`, …) defined *many* times across the amethyst tree under per-feature keys **and** once more in commons under generic keys (`action_cancel`, `action_save`, …). This is **required architecture, not an error:**
- The two trees are **different resource systems**: amethyst uses Android `R.string`; commons uses Compose-Multiplatform `Res.string` (`com.vitorpamplona.amethyst.commons.resources.Res`).
- **`commons` cannot depend on `amethyst`** (amethyst depends on commons — the reverse would be circular). So a composable extracted *into* commons physically cannot reference `R.string.cancel`; it needs its own string, hence the generic `action_*` keys. That is the only way an extracted shared composable can render "Cancel."
- The scattered amethyst per-feature duplicates (`nip46_signer_cancel`, `nest_create_cancel`, …) are **pre-existing tech debt**; the commons keys did not create them.
- Both catalogs are Crowdin-managed **independently**, and Crowdin's translation memory pre-fills repeats, so translating the same word in both trees is **not** wasted effort.
**Do not** treat the value-overlap as something to deduplicate during a translation pass. Migrating amethyst's own screens onto the shared `action_*` strings is a *separate, optional* refactor and a maintainer call — out of scope for this skill. Just translate each tree correctly and independently.
## Background: Crowdin strip-identical behavior
This repo syncs translations via Crowdin (branch `l10n_crowdin_translations`). Crowdin's default export behavior **omits any translation that exactly equals the source**, so a key that the translator deliberately kept as English (common for brand terms like `"Nowhere Drop"`, single-word loanwords like `"Apps"` / `"Feed"` / `"Issues"`, or version prefixes like `"v%1$s"`) will not appear in the locale's `strings.xml` even though the Crowdin UI shows it as 100% translated.
@@ -105,25 +53,9 @@ The default set of locales (unless the user specifies otherwise):
### 1. Identify files
Do this for **each** resource tree (see "Resource trees" above). The examples below use the amethyst base path; repeat every step with the commons base path swapped in.
```
# amethyst tree
Default: amethyst/src/main/res/values/strings.xml
Target: amethyst/src/main/res/values-<locale>/strings.xml
# commons tree
Default: commons/src/commonMain/composeResources/values/strings.xml
Target: commons/src/commonMain/composeResources/values-<locale>/strings.xml
```
A convenient way to run the whole technique twice is to loop over the two base dirs:
```bash
for base in amethyst/src/main/res commons/src/commonMain/composeResources; do
echo "########## tree: $base ##########"
# ... run the diff/count/value-extraction commands with $base/values[...] ...
done
```
### 2. Find missing keys using cs as reference
@@ -212,35 +144,17 @@ Before presenting results, **scan the missing English strings** for two red-flag
Also **audit existing `<plurals>` resources** for two anti-patterns:
1. **`quantity="one"` items that hardcode the literal `1`** (instead of using a `%d` / `%1$d` placeholder) — broken for languages where the `one` CLDR category covers more than just `n=1` (Russian, Ukrainian, Croatian, etc.).
2. **`quantity="zero"` items in any locale that doesn't natively use the `zero` CLDR category** — i.e. everything except **Arabic (`ar`)**, **Latvian (`lv`)** and **Welsh (`cy`)**. ICU/CLDR maps `count=0` to `other` for English and most of the locales we ship to (cs, de, pt-BR, sv, etc.), so `<item quantity="zero">` is **dead code** there: `getQuantityString(id, 0)` will pick `other`, never the zero entry, and the visible runtime string ends up `"…0 items"` instead of the intended `"…no items"`.
2. **`quantity="zero"` items in any locale that doesn't natively use the `zero` CLDR category** — i.e. **everything except Arabic (`ar`) and Welsh (`cy`)**. ICU/CLDR maps `count=0` to `other` for English and all the locales we ship to (cs, de, pt-BR, sv, etc.), so `<item quantity="zero">` is **dead code** there: `getQuantityString(id, 0)` will pick `other`, never the zero entry, and the visible runtime string ends up `"…0 items"` instead of the intended `"…no items"`.
> ⚠️ **Latvian is the trap here — do NOT strip its `zero` items** (we nearly did, 2026-07-22). `lv` has an integer-bearing `zero` category that covers far more than 0: `select(0)`, `select(10)` and `select(11)` all return `zero` (the rule is `n % 10 = 0` or `n % 100 = 11..19`). So a Latvian `<item quantity="zero">` is *live code on the majority of counts*, and it must read as a normal plural form ("%1$d minūšu"), **not** as "no items" wording. An earlier version of this skill claimed only `ar` and `cy` had `zero`, which flagged all ~40 correct Latvian entries as dead and would have deleted working translations.
If a UX genuinely wants special "no items" wording at count=0, that has to be a call-site `if (count == 0)` branch to a separate `<string>`, **not** a `quantity="zero"` plural item. (This is why `zero` is the wrong tool even where it exists: in `lv` it does not mean "zero".)
**Verify, don't recall.** Before asserting any locale's category set, check it against CLDR rather than memory:
```bash
python3 -m venv /tmp/cldr && /tmp/cldr/bin/pip -q install babel
/tmp/cldr/bin/python -c "
from babel import Locale
for c in ['en','lv','ar','cy','cs','de','sv','pt_BR','ru','pl']:
r = Locale.parse(c).plural_form
print(c, sorted({r(n) for n in range(0,10001)}), 'select(0)=', r(0), 'select(10)=', r(10))
"
```
Across the 56 locale dirs this repo ships, **only `ar-rSA` and `lv-rLV`** have an integer-bearing `zero`.
If a UX genuinely wants special "no items" wording at count=0, that has to be a call-site `if (count == 0)` branch to a separate `<string>`, **not** a `quantity="zero"` plural item.
Flag and offer to fix:
```bash
# Scan every locale's strings.xml for <item quantity="one"> entries that
# hardcode "1" (or other literal digits) instead of using a placeholder.
# Looks at default + all values-* locales, in BOTH resource trees.
for f in amethyst/src/main/res/values/strings.xml amethyst/src/main/res/values-*/strings.xml \
commons/src/commonMain/composeResources/values/strings.xml \
commons/src/commonMain/composeResources/values-*/strings.xml; do
# Looks at default + all values-* locales.
for f in amethyst/src/main/res/values/strings.xml amethyst/src/main/res/values-*/strings.xml; do
awk -v file="$f" '
/<plurals/ { in_plurals = 1; name = $0; sub(/.*name="/, "", name); sub(/".*/, "", name) }
in_plurals && /quantity="one"/ {
@@ -256,16 +170,13 @@ for f in amethyst/src/main/res/values/strings.xml amethyst/src/main/res/values-*
done
```
Then scan for dead `quantity="zero"` entries. CLDR's `zero` category is integer-bearing only in **Arabic (`ar`)**, **Latvian (`lv`)** and **Welsh (`cy`)** — those three are skipped below, so a hit is a genuine bug. In every other locale, count=0 falls through to `other`, so a `<item quantity="zero">` entry is dead and likely a translator/author bug (or it silently never fires):
Then scan for dead `quantity="zero"` entries. CLDR's `zero` category is integer-bearing only in **Arabic (`ar`)** and **Welsh (`cy`)**. In every other locale, count=0 falls through to `other`, so a `<item quantity="zero">` entry is dead and likely a translator/author bug (or it silently never fires):
```bash
for f in amethyst/src/main/res/values/strings.xml amethyst/src/main/res/values-*/strings.xml \
commons/src/commonMain/composeResources/values/strings.xml \
commons/src/commonMain/composeResources/values-*/strings.xml; do
# Skip Arabic, Latvian and Welsh — they natively use the zero category.
# (Latvian's zero covers 0, 10, 11-19, 20, 30, … — stripping it breaks most counts.)
for f in amethyst/src/main/res/values/strings.xml amethyst/src/main/res/values-*/strings.xml; do
# Skip Arabic and Welsh — they natively use the zero category.
case "$f" in
*values-ar*|*values-cy*|*values-lv*) continue ;;
*values-ar*|*values-cy*) continue ;;
esac
awk -v file="$f" '
/<plurals/ { in_plurals = 1; name = $0; sub(/.*name="/, "", name); sub(/".*/, "", name) }
@@ -329,10 +240,9 @@ When adding or proposing **`<plurals>`** entries, follow these rules:
- Polish (`pl`): `one`, `few`, `many`, `other`
- Russian (`ru`): `one`, `few`, `many`, `other`
- Arabic (`ar`): `zero`, `one`, `two`, `few`, `many`, `other`
- Latvian (`lv`): `zero`, `one`, `other` — its `zero` is **not** "no items"; it covers 0, 10, 1119, 20, 30, …
- German / Swedish / Brazilian Portuguese: `one`, `other`
- When a missing string contains a count placeholder and is conceptually a singular/plural pair, **flag it before translating** — it may belong as a `<plurals>` resource rather than a single `<string>`. Surface this to the user before proposing translations.
- **Do not use `quantity="zero"` outside Arabic (`ar`), Latvian (`lv`) and Welsh (`cy`).** CLDR's `zero` category is integer-bearing only in those three languages. Android calls `PluralRules.select(0)` for the device locale; in English/German/Czech/Polish/Russian/Swedish/Portuguese/etc. it returns `other`, so the explicit `<item quantity="zero">` is never picked at runtime and the user sees `"…0 items"` instead of the intended wording. Conversely, **never delete an existing `zero` item from `ar`/`lv`/`cy`** — there it is live. If the design calls for "no items" at count=0, model it as a separate `<string>` and an `if (count == 0)` branch at the call site:
- **Do not use `quantity="zero"` outside Arabic (`ar`) and Welsh (`cy`).** CLDR's `zero` category is integer-bearing only in those two languages. Android calls `PluralRules.select(0)` for the device locale; in English/German/Czech/Polish/Russian/Swedish/Portuguese/etc. it returns `other`, so the explicit `<item quantity="zero">` is never picked at runtime and the user sees `"…0 items"` instead of the intended wording. If the design calls for "no items" at count=0, model it as a separate `<string>` and an `if (count == 0)` branch at the call site:
```kotlin
val label = if (count == 0) {
stringRes(R.string.foo_no_items, dateLabel)
@@ -350,50 +260,15 @@ When adding translated strings to locale files:
- **Append new strings at the bottom** of the file, just before the closing `</resources>` tag.
- Do NOT try to insert them in alphabetical or matching order — a separate process handles ordering.
- **Insert into each locale ONLY the keys missing from *that* locale — never a shared "union" block.** Because Crowdin strips keys asymmetrically (Step 2), a key you translate may already exist in some target locales. If you compute one union set of missing keys, translate it, and paste the *same* block into every locale, you will create **duplicate keys** in whichever locales already had them. Drive the insertion off the **per-locale** diff, not the union:
```bash
# For each locale, insert only the keys comm -23 reports missing FOR THAT LOCALE.
for l in cs de-rDE sv-rSE pt-rBR; do
missing=$(comm -23 \
<(grep '<string name=' $base/values/strings.xml | grep -v 'translatable="false"' \
| sed 's/.*name="\([^"]*\)".*/\1/' | sort) \
<(grep '<string name=' $base/values-$l/strings.xml \
| sed 's/.*name="\([^"]*\)".*/\1/' | sort))
# ... append ONLY the $missing keys' translations to values-$l/strings.xml ...
done
```
(This bit us on 2026-07-21: `ps1_save_block`, `podcast_value_for_value`, and `chats_history_relays` were each missing in only *some* commons locales, but the same 3-key block was pasted into all four — producing duplicates in the locales that already had them.)
- **After inserting, verify each edited file has no duplicate keys AND is well-formed XML — before you call the task done.** A duplicate key is not a warning: the `commons` tree's Compose-resources build task fails hard on it (`convertXmlValueResourcesForCommonMain: … Duplicated key '…'`), which breaks the build for everyone. Quick post-insertion gate over every file you touched:
```bash
for f in <every edited strings.xml>; do
dups=$(grep -oE '<(string|plurals) name="[^"]*"' "$f" \
| sed 's/.*name="\([^"]*\)"/\1/' | sort | uniq -d)
[ -n "$dups" ] && echo "DUP in $f: $dups"
python3 -c "import xml.dom.minidom; xml.dom.minidom.parse('$f')" \
|| echo "MALFORMED $f"
done
# For a commons change, also run the build task that enforces this:
# ./gradlew :commons:convertXmlValueResourcesForCommonMain
```
## Common Mistakes
- **Scanning only the amethyst tree** — there are now **two** Crowdin-managed `strings.xml` trees (`amethyst/src/main/res` and `commons/src/commonMain/composeResources`). A key extracted into `commons/` will never show up in the amethyst diff. Run the whole technique once per tree (see "Resource trees") and report each separately.
- **Copying an overlapping `commons` translation by key name alone** — a shared key name does NOT mean shared English. `napplet_card_permissions` is "What it can access" in commons but "Permissions:" in amethyst; copying by name produced the wrong string. Diff the English *values* first; copy verbatim only when they're byte-identical, else translate fresh (see "Overlap" in Resource trees).
- **Applying amethyst's `"…"` whitespace-quote convention to a commons string** — the commons Compose-resources tree authors edge whitespace raw and unquoted; wrapping quotes copied from amethyst render literally there. Match the commons source format.
- **Trying to "dedupe" the amethyst↔commons value-overlap** — it's required architecture (commons can't depend on amethyst, so shared composables need their own `Res.string` catalog), not an error. Don't fold consolidation into a translation pass.
- **Forgetting `translatable="false"`** — these should never appear in locale files
- **Diffing only `<string name=`** — `<plurals>` is a separate resource type; a source `<plurals>` missing from a locale will never show up in a `<string>` diff. Always run the diff twice (once per resource type) as shown in Step 2. The same goes for `<string-array>` if the project uses it.
- **Trusting a git "sync-timestamp" heuristic to pre-filter the list** — this skill used to skip keys added before the last `New Crowdin translations` commit, on the theory that Crowdin had already "decided" them. It was dropped: a key added shortly before an export that translators hadn't reached yet is genuinely missing, so the heuristic silently dropped real work. Use the raw on-disk diff and reconcile against the Crowdin web UI's untranslated count instead.
- **Adding source-identical fallbacks locally** — they get overwritten on the next Crowdin sync. Android falls back to `values/strings.xml` at runtime anyway, so a key intentionally kept as English already renders correctly. Skip these by inspection (brand terms, loanwords, `v%1$s`-style strings); don't translate them to an identical value.
- **Skipping per-locale diffs when only diffing cs** — Crowdin can strip different keys in different locales (each translator's choice), so cs is not a reliable upper bound. Diff each target locale and union the results.
- **Pasting the union set of missing keys into every locale → duplicate keys** — the union is the right set to *translate*, but the wrong set to *insert*. A key missing in only some locales, inserted into all of them, duplicates in the ones that already had it. Drive each file's insertion off its own per-locale diff (see Step 6). In `commons`, a duplicate key is build-breaking: `convertXmlValueResourcesForCommonMain` fails with `Duplicated key '…'`. **Always run the post-insertion duplicate + XML-wellformedness gate in Step 6 before declaring done.** (Happened 2026-07-21 with `ps1_save_block` / `podcast_value_for_value` / `chats_history_relays`.)
- **Inserting strings in a specific position** — always append at the bottom; ordering is handled separately
- **Hardcoding `"1"` in a `<plurals>` `quantity="one"` item** — always use the count placeholder; otherwise non-English `one` categories produce wrong text
- **Copying English's `one`/`other` set into every locale** — each language must include all CLDR plural categories it uses (e.g. Czech needs `one`, `few`, `many`, `other`)
- **Using `<item quantity="zero">` to special-case count=0** — outside Arabic, Latvian and Welsh, this entry is unreachable: ICU/CLDR maps 0 → `other`, so the runtime never picks the zero item and the user sees `"…0 items"`. Special-case at the call site with a separate `<string>` instead.
- **Reporting Latvian `quantity="zero"` entries as dead code** — `lv` has a real, integer-bearing `zero` category covering 0, 10, 1119, 20, 30, … so those entries fire on *most* counts. An earlier version of this skill excluded only `ar`/`cy` from the zero audit and flagged all ~40 correct `values-lv-rLV` entries; acting on that would have deleted working translations. Confirm any locale's category set against CLDR (the babel snippet in Step 4) before calling a `zero` item dead.
- **Using `<item quantity="zero">` to special-case count=0** — outside Arabic and Welsh, this entry is unreachable: ICU/CLDR maps 0 → `other`, so the runtime never picks the zero item and the user sees `"…0 items"`. Special-case at the call site with a separate `<string>` instead.
+3 -3
View File
@@ -7,7 +7,7 @@ description: Integration guide for using the Quartz Nostr KMP library in externa
Reference for integrating `com.vitorpamplona.quartz:quartz` into external Nostr KMP projects.
**Published artifact**: `com.vitorpamplona.quartz:quartz:1.13.1` (Maven Central)
**Published artifact**: `com.vitorpamplona.quartz:quartz:1.12.6` (Maven Central)
**Targets**: JVM 21+, Android (minSdk 21+), iOS (XCFramework `quartz-kmpKit`)
**License**: MIT
@@ -19,7 +19,7 @@ Reference for integrating `com.vitorpamplona.quartz:quartz` into external Nostr
```toml
[versions]
quartz = "1.13.1"
quartz = "1.12.6"
[libraries]
quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" }
@@ -41,7 +41,7 @@ kotlin {
```kotlin
dependencies {
implementation("com.vitorpamplona.quartz:quartz:1.13.1")
implementation("com.vitorpamplona.quartz:quartz:1.12.6")
}
```
@@ -3,7 +3,7 @@
## Current version
```
com.vitorpamplona.quartz:quartz:1.13.1
com.vitorpamplona.quartz:quartz:1.12.6
```
Check latest: https://central.sonatype.com/artifact/com.vitorpamplona.quartz/quartz
@@ -16,7 +16,7 @@ Check latest: https://central.sonatype.com/artifact/com.vitorpamplona.quartz/qua
```toml
[versions]
quartz = "1.13.1"
quartz = "1.12.6"
[libraries]
quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" }
@@ -55,7 +55,7 @@ kotlin {
```kotlin
// build.gradle.kts (app module)
dependencies {
implementation("com.vitorpamplona.quartz:quartz:1.13.1")
implementation("com.vitorpamplona.quartz:quartz:1.12.6")
}
```
@@ -70,7 +70,7 @@ plugins {
}
dependencies {
implementation("com.vitorpamplona.quartz:quartz:1.13.1")
implementation("com.vitorpamplona.quartz:quartz:1.12.6")
// JNA needed for libsodium (NIP-44) on JVM
implementation("net.java.dev.jna:jna:5.18.1")
}
-6
View File
@@ -123,12 +123,6 @@ Each subscription tracks "End of Stored Events" per relay. The eose manager in `
## Related
- **Headless / one-shot client ops** (CLI, geode, tests, non-compose code): don't go
through `Subscribable` — use the `INostrClient` extension functions in
`quartz/…/nip01Core/relay/client/accessories/` (`fetchAll`, `fetchFirst`,
`fetchAllPages`, `publishAndConfirm`, `count`, `negentropyReconcile`/`negentropySync`,
…). They're extensions, so they don't show up under "usages of `NostrClient`" — see
that package's `README.md` for the catalog before writing a raw subscribe/collect loop.
- `nostr-expert/references/tag-patterns.md` — how tags inform what a filter needs to look for.
- `kotlin-coroutines/references/relay-patterns.md` — relay pool internals (sibling layer beneath assemblers).
- `feed-patterns` skill — feeds compose several Subscribables (content + metadata + reactions).
-17
View File
@@ -1,17 +0,0 @@
# Keep the Docker build context small + reproducible: exclude VCS metadata,
# Gradle/build outputs, IDE files, and local caches. The build stage runs a
# fresh `./gradlew :geode:installDist` inside the image, so nothing under any
# build/ directory needs to travel with the context.
.git
.github
**/build/
.gradle/
**/.gradle/
.idea/
*.iml
.kotlin/
**/.kotlin/
local.properties
# Docs + non-geode app modules aren't needed to build the relay, but the Gradle
# settings reference every module, so we can't drop the module source trees;
# only their build artifacts (covered above) are excluded.
@@ -1,73 +0,0 @@
name: Resolve Release
description: >-
Resolve a bump workflow's target tag and that release's real published state.
Companion to assert-stable-release: this one FETCHES the facts, that one
ENFORCES them. Split so the enforcement stays a pure function of its inputs.
Handles both entry points of the bump workflows:
- workflow_run -> tag comes from the triggering run's head_branch
- workflow_dispatch (manual recovery) -> tag comes from the input
In both cases draft/prerelease are read back from the GitHub API rather than
inferred, so a draft or prerelease can never slip through to a third-party
package repo just because the trigger payload lacked the flags.
inputs:
tag:
description: "Release tag to resolve (e.g. vX.Y.Z)"
required: true
github_token:
description: "Token used to read the release via the GH API"
required: true
outputs:
tag:
description: "The resolved tag, verbatim (e.g. v1.13.1)"
value: ${{ steps.resolve.outputs.tag }}
ver:
description: "The tag with the leading 'v' stripped (e.g. 1.13.1)"
value: ${{ steps.resolve.outputs.ver }}
is_prerelease:
description: "'true' if the GH Release is flagged prerelease"
value: ${{ steps.resolve.outputs.is_prerelease }}
is_draft:
description: "'true' if the GH Release is still a draft"
value: ${{ steps.resolve.outputs.is_draft }}
runs:
using: composite
steps:
- name: Resolve tag and release state
id: resolve
shell: bash
env:
TAG: ${{ inputs.tag }}
GH_TOKEN: ${{ inputs.github_token }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
if [[ -z "$TAG" ]]; then
echo "::error::No tag to resolve (neither workflow_run.head_branch nor the dispatch input was set)"
exit 1
fi
# A missing release here is a real fault, not something to paper over:
# every caller is about to publish this version to an external package
# manager. Fail loudly and let the caller's Report-failure step file it.
if ! META=$(gh release view "$TAG" --repo "$REPO" --json isDraft,isPrerelease 2>&1); then
echo "::error::No GH Release found for tag $TAG in $REPO -- refusing to bump"
echo "$META"
exit 1
fi
IS_DRAFT=$(echo "$META" | jq -r '.isDraft')
IS_PRERELEASE=$(echo "$META" | jq -r '.isPrerelease')
{
echo "tag=$TAG"
echo "ver=${TAG#v}"
echo "is_draft=$IS_DRAFT"
echo "is_prerelease=$IS_PRERELEASE"
} >> "$GITHUB_OUTPUT"
echo "resolved tag=$TAG ver=${TAG#v} draft=$IS_DRAFT prerelease=$IS_PRERELEASE"
+1 -51
View File
@@ -110,42 +110,6 @@ jobs:
name: ${{ matrix.desktop-artifact-name }}
path: ${{ matrix.desktop-artifact-path }}
# geode (the standalone Nostr relay) is JVM-only, so it runs in its own job
# rather than the build-desktop matrix — one runner suffices (no reason to test
# a platform-independent module 3× across the desktop OS matrix). Isolating it
# also keeps its default suite's CPU-heavy throughput benchmarks (a 1M-event
# mirror sync, WireReqFloor, NegentropyServerReconcile) from contending with the
# timing-sensitive quartz relay-client tests under org.gradle.parallel — that
# contention flakes tests like NostrClientReqBypassingRelayLimitsTest.
test-geode:
needs: lint
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5
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' }}
- name: Test geode (gradle)
run: ./gradlew :geode:test
- name: Upload geode Test Reports
uses: actions/upload-artifact@v7
if: failure()
with:
name: geode Test Reports
path: geode/build/reports
test-quartz-ios:
# Phase 1 of the iOS support plan
# (amethyst/plans/2026-05-24-ios-support.md): keep :quartz green on iOS
@@ -267,23 +231,9 @@ jobs:
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 +
# Python 2 Docker image on every run — bionic's apt archives have since gone
# unreliable and broke this job. Pinned to a commit SHA (not the movable
# v6.4.2 tag) to close the supply-chain hole. annotate_only avoids needing
# `checks: write`, so it keeps working on pull requests from forks (where the
# GITHUB_TOKEN is read-only). fail_on_failure preserves the old step's
# behavior of marking the job red when a test fails.
- name: Android Test Report
uses: mikepenz/action-junit-report@d9f48fc87bc235f7e214acf696ca5abc0a986f16 # v6.4.2
uses: asadmansr/android-test-report-action@v1.2.0
if: always()
with:
report_paths: '**/build/test-results/**/TEST-*.xml'
annotate_only: true
detailed_summary: true
fail_on_failure: true
- name: Upload Test Results
uses: actions/upload-artifact@v7
-169
View File
@@ -1,169 +0,0 @@
name: Bump Homebrew Formula (amy CLI)
# Sibling of bump-homebrew.yml, but for a DIFFERENT Homebrew artifact:
# - bump-homebrew.yml -> Cask `amethyst-nostr` (the desktop GUI app / DMG)
# - this workflow -> Formula `amy` (the headless CLI jar bundle)
#
# What it does today: after a stable release, download the published
# `amy-<version>-jvm.tar.gz` bundle, compute its sha256, and open a PR that
# syncs `cli/packaging/homebrew/amy.rb`'s url + sha256 to that release. That is
# exactly the manual step the formula header calls out ("replace the version in
# the url and the sha256 with the values for the actual published release
# asset"), so keeping the in-repo reference formula accurate makes the eventual
# homebrew-core submission a copy-paste.
#
# What it does NOT do yet: open a PR against Homebrew/homebrew-core. `brew
# bump-formula-pr` can only bump a formula that already EXISTS in homebrew-core,
# and `amy` has never been submitted there — that first submission is a manual,
# human-reviewed new-formula PR (the one-time bootstrap). Once it lands, wire the
# auto-bump here (symmetric to the cask action in bump-homebrew.yml) — see the
# "TODO(bootstrap)" note at the bottom of this file.
# Trigger: after "Create Release Assets" succeeds for a tag push. NOT
# `release: types: [released]` — that event never fires, because the release is
# created by create-release.yml under GITHUB_TOKEN and GitHub suppresses
# workflow-triggering events for it. See the full note in bump-homebrew.yml.
on:
workflow_run:
workflows: ["Create Release Assets"]
types: [completed]
workflow_dispatch:
inputs:
tag:
description: 'Release tag to sync (for manual recovery)'
required: true
type: string
permissions:
contents: write
pull-requests: write
# The "Report failure" step opens a [release-ops] issue via
# github.rest.issues.create, which needs issues:write.
issues: write
concurrency:
# Serialize per tag; do not cancel in-progress runs.
group: bump-homebrew-formula-${{ github.event.workflow_run.head_branch || inputs.tag }}
cancel-in-progress: false
jobs:
sync-formula:
# See bump-homebrew.yml for why these three conditions: successful, tag-push
# (not a dry-run dispatch), v-prefixed. Exact format enforced downstream.
if: >-
github.event_name == 'workflow_dispatch' ||
(github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push' &&
startsWith(github.event.workflow_run.head_branch, 'v'))
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Resolve release
id: rel
uses: ./.github/actions/resolve-release
with:
tag: ${{ github.event.workflow_run.head_branch || inputs.tag }}
github_token: ${{ secrets.GITHUB_TOKEN }}
- name: Re-assert stable release
uses: ./.github/actions/assert-stable-release
with:
tag: ${{ steps.rel.outputs.tag }}
is_prerelease: ${{ steps.rel.outputs.is_prerelease }}
is_draft: ${{ steps.rel.outputs.is_draft }}
- name: Download jvm bundle and compute sha256
id: asset
run: |
set -euo pipefail
TAG="${{ steps.rel.outputs.tag }}"
VER="${{ steps.rel.outputs.ver }}"
URL="https://github.com/${{ github.repository }}/releases/download/${TAG}/amy-${VER}-jvm.tar.gz"
echo "Fetching $URL"
# workflow_run fires only after every upload leg has finished, so the
# asset should already be there. Retry anyway for release-CDN
# propagation (mirrors the repo's push/pull retry ethos).
ok=0
for i in 1 2 3 4 5; do
if curl -fsSL -o amy-jvm.tar.gz "$URL"; then ok=1; break; fi
wait=$(( 2 ** i ))
echo "attempt $i failed; retrying in ${wait}s"
sleep "$wait"
done
[[ "$ok" == 1 ]] || { echo "::error::could not download $URL"; exit 1; }
test -s amy-jvm.tar.gz
SHA=$(shasum -a 256 amy-jvm.tar.gz | awk '{print $1}')
echo "url=$URL" >> "$GITHUB_OUTPUT"
echo "sha256=$SHA" >> "$GITHUB_OUTPUT"
echo "amy-${VER}-jvm.tar.gz -> $SHA"
- name: Update reference formula
run: |
set -euo pipefail
FORMULA=cli/packaging/homebrew/amy.rb
URL="${{ steps.asset.outputs.url }}"
SHA="${{ steps.asset.outputs.sha256 }}"
# Rewrite the two indented lines in the formula block. Anchoring on the
# 2-space indent avoids touching the header comment's example curl url.
sed -i -E "s|^( url ).*|\1\"${URL}\"|" "$FORMULA"
sed -i -E "s|^( sha256 ).*|\1\"${SHA}\"|" "$FORMULA"
echo "----- $FORMULA -----"
grep -E "^ (url|sha256) " "$FORMULA"
- name: Open or update the formula-sync PR
# peter-evans/create-pull-request is MIT-licensed CI-only tooling (not
# linked into any shipped artifact). It no-ops when there is no diff.
uses: peter-evans/create-pull-request@v8
with:
token: ${{ secrets.GITHUB_TOKEN }}
base: main
branch: chore/bump-amy-formula-${{ steps.rel.outputs.tag }}
add-paths: cli/packaging/homebrew/amy.rb
commit-message: 'chore: sync amy Homebrew formula to ${{ steps.rel.outputs.tag }}'
title: 'chore: sync amy Homebrew formula to ${{ steps.rel.outputs.tag }}'
body: |
Auto-synced `cli/packaging/homebrew/amy.rb` to the
`${{ steps.rel.outputs.tag }}` release:
- `url` -> `${{ steps.asset.outputs.url }}`
- `sha256` -> `${{ steps.asset.outputs.sha256 }}`
Opened by `.github/workflows/bump-homebrew-formula.yml`. Merge to keep
the reference formula ready for the homebrew-core submission/bump.
# TODO(bootstrap): once `amy` is accepted into Homebrew/homebrew-core, add
# a LOCAL script mirroring scripts/bump-homebrew-cask.sh that runs `brew
# bump-formula-pr amy --url=<url> --sha256=<sha>` from a maintainer's
# machine. Do NOT wire that into CI: bump-formula-pr forks homebrew-core
# into the token owner's account, which needs a classic `repo`-scoped PAT,
# and that scope grants write to every repo the account can reach —
# including this one — to anyone with push access here. See
# BUILDING.md § Homebrew cask for the full reasoning.
- name: Report failure
if: failure()
uses: actions/github-script@v9
with:
script: |
const tag = context.payload.workflow_run?.head_branch || context.payload.inputs?.tag || 'unknown';
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: `[release-ops] bump-homebrew-formula failed for ${tag}`,
body: [
`amy Homebrew formula sync failed for release \`${tag}\`.`,
``,
`- Run: ${runUrl}`,
`- Channel: Homebrew Formula (\`amy\` CLI)`,
``,
`Recovery options:`,
`1. Re-run the workflow once the underlying issue is fixed`,
`2. Manually update \`cli/packaging/homebrew/amy.rb\` (url + sha256) from the release asset`,
`3. Check the release actually published \`amy-${tag.replace(/^v/, '')}-jvm.tar.gz\``
].join('\n'),
labels: ['release-ops', 'bug']
});
@@ -1,165 +0,0 @@
name: Bump Homebrew Formula (geode relay)
# Sibling of bump-homebrew-formula.yml (the amy CLI). Same mechanism, different
# artifact:
# - bump-homebrew-formula.yml -> Formula `amy` (the headless CLI)
# - this workflow -> Formula `geode` (the standalone relay)
#
# After a stable release, download the published `geode-<version>-jvm.tar.gz`
# bundle, compute its sha256, and open a PR that syncs
# `geode/packaging/homebrew/geode.rb`'s url + sha256 to that release. Keeping the
# in-repo reference formula accurate makes the eventual homebrew-core submission a
# copy-paste.
#
# What it does NOT do yet: open a PR against Homebrew/homebrew-core. `brew
# bump-formula-pr` can only bump a formula that already EXISTS in homebrew-core,
# and `geode` has never been submitted there — that first submission is a manual,
# human-reviewed new-formula PR (the one-time bootstrap). Once it lands, wire the
# auto-bump here — see the "TODO(bootstrap)" note at the bottom of this file.
# Trigger: after "Create Release Assets" succeeds for a tag push. NOT
# `release: types: [released]` — that event never fires, because the release is
# created by create-release.yml under GITHUB_TOKEN and GitHub suppresses
# workflow-triggering events for it. See the full note in bump-homebrew.yml.
on:
workflow_run:
workflows: ["Create Release Assets"]
types: [completed]
workflow_dispatch:
inputs:
tag:
description: 'Release tag to sync (for manual recovery)'
required: true
type: string
permissions:
contents: write
pull-requests: write
# The "Report failure" step opens a [release-ops] issue via
# github.rest.issues.create, which needs issues:write.
issues: write
concurrency:
# Serialize per tag; do not cancel in-progress runs.
group: bump-homebrew-geode-formula-${{ github.event.workflow_run.head_branch || inputs.tag }}
cancel-in-progress: false
jobs:
sync-formula:
# See bump-homebrew.yml for why these three conditions: successful, tag-push
# (not a dry-run dispatch), v-prefixed. Exact format enforced downstream.
if: >-
github.event_name == 'workflow_dispatch' ||
(github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push' &&
startsWith(github.event.workflow_run.head_branch, 'v'))
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Resolve release
id: rel
uses: ./.github/actions/resolve-release
with:
tag: ${{ github.event.workflow_run.head_branch || inputs.tag }}
github_token: ${{ secrets.GITHUB_TOKEN }}
- name: Re-assert stable release
uses: ./.github/actions/assert-stable-release
with:
tag: ${{ steps.rel.outputs.tag }}
is_prerelease: ${{ steps.rel.outputs.is_prerelease }}
is_draft: ${{ steps.rel.outputs.is_draft }}
- name: Download jvm bundle and compute sha256
id: asset
run: |
set -euo pipefail
TAG="${{ steps.rel.outputs.tag }}"
VER="${{ steps.rel.outputs.ver }}"
URL="https://github.com/${{ github.repository }}/releases/download/${TAG}/geode-${VER}-jvm.tar.gz"
echo "Fetching $URL"
# workflow_run fires only after every upload leg has finished, so the
# asset should already be there. Retry anyway for release-CDN
# propagation (mirrors the repo's push/pull retry ethos).
ok=0
for i in 1 2 3 4 5; do
if curl -fsSL -o geode-jvm.tar.gz "$URL"; then ok=1; break; fi
wait=$(( 2 ** i ))
echo "attempt $i failed; retrying in ${wait}s"
sleep "$wait"
done
[[ "$ok" == 1 ]] || { echo "::error::could not download $URL"; exit 1; }
test -s geode-jvm.tar.gz
SHA=$(shasum -a 256 geode-jvm.tar.gz | awk '{print $1}')
echo "url=$URL" >> "$GITHUB_OUTPUT"
echo "sha256=$SHA" >> "$GITHUB_OUTPUT"
echo "geode-${VER}-jvm.tar.gz -> $SHA"
- name: Update reference formula
run: |
set -euo pipefail
FORMULA=geode/packaging/homebrew/geode.rb
URL="${{ steps.asset.outputs.url }}"
SHA="${{ steps.asset.outputs.sha256 }}"
# Rewrite the two indented lines in the formula block. Anchoring on the
# 2-space indent avoids touching the header comment's example curl url.
sed -i -E "s|^( url ).*|\1\"${URL}\"|" "$FORMULA"
sed -i -E "s|^( sha256 ).*|\1\"${SHA}\"|" "$FORMULA"
echo "----- $FORMULA -----"
grep -E "^ (url|sha256) " "$FORMULA"
- name: Open or update the formula-sync PR
# peter-evans/create-pull-request is MIT-licensed CI-only tooling (not
# linked into any shipped artifact). It no-ops when there is no diff.
uses: peter-evans/create-pull-request@v8
with:
token: ${{ secrets.GITHUB_TOKEN }}
base: main
branch: chore/bump-geode-formula-${{ steps.rel.outputs.tag }}
add-paths: geode/packaging/homebrew/geode.rb
commit-message: 'chore: sync geode Homebrew formula to ${{ steps.rel.outputs.tag }}'
title: 'chore: sync geode Homebrew formula to ${{ steps.rel.outputs.tag }}'
body: |
Auto-synced `geode/packaging/homebrew/geode.rb` to the
`${{ steps.rel.outputs.tag }}` release:
- `url` -> `${{ steps.asset.outputs.url }}`
- `sha256` -> `${{ steps.asset.outputs.sha256 }}`
Opened by `.github/workflows/bump-homebrew-geode-formula.yml`. Merge to
keep the reference formula ready for the homebrew-core submission/bump.
# TODO(bootstrap): once `geode` is accepted into Homebrew/homebrew-core,
# add a LOCAL script mirroring scripts/bump-homebrew-cask.sh that runs
# `brew bump-formula-pr geode --url=<url> --sha256=<sha>` from a
# maintainer's machine. Do NOT wire that into CI — it needs a classic
# `repo`-scoped PAT, which as a CI secret would hand write access to this
# repo to anyone with push access. See BUILDING.md § Homebrew cask.
- name: Report failure
if: failure()
uses: actions/github-script@v9
with:
script: |
const tag = context.payload.workflow_run?.head_branch || context.payload.inputs?.tag || 'unknown';
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: `[release-ops] bump-homebrew-geode-formula failed for ${tag}`,
body: [
`geode Homebrew formula sync failed for release \`${tag}\`.`,
``,
`- Run: ${runUrl}`,
`- Channel: Homebrew Formula (\`geode\` relay)`,
``,
`Recovery options:`,
`1. Re-run the workflow once the underlying issue is fixed`,
`2. Manually update \`geode/packaging/homebrew/geode.rb\` (url + sha256) from the release asset`,
`3. Check the release actually published \`geode-${tag.replace(/^v/, '')}-jvm.tar.gz\``
].join('\n'),
labels: ['release-ops', 'bug']
});
+29 -139
View File
@@ -1,181 +1,71 @@
name: Sync Homebrew Cask Reference
name: Bump Homebrew Cask
# Sibling of bump-homebrew-formula.yml (amy) and bump-homebrew-geode-formula.yml
# (geode). Same mechanism, third artifact:
# - this workflow -> Cask `amethyst-nostr` (the desktop GUI app / DMG)
#
# What it does: after a stable release, download the published macOS DMG, assert
# it is notarized + stapled, compute its sha256, and open a PR syncing
# `desktopApp/packaging/homebrew/amethyst-nostr.rb` to that release.
#
# What it does NOT do: open a PR against Homebrew/homebrew-cask. That step is
# deliberately MANUAL and runs on a maintainer's machine —
# `scripts/bump-homebrew-cask.sh`. Reason: `brew bump-cask-pr` forks
# homebrew-cask into the token owner's account, which requires a CLASSIC PAT
# with the `repo` scope; that scope grants write to every repository the account
# can reach, and stored as a CI secret it would be usable by anyone with push
# access to this repo. Keeping it in a maintainer's shell instead of a CI secret
# removes that blast radius entirely, at the cost of one command per release.
# See BUILDING.md § Homebrew cask.
#
# Consequence: this workflow needs NO external token. GITHUB_TOKEN is enough,
# exactly like the two formula workflows.
#
# Trigger: after "Create Release Assets" succeeds for a tag push. NOT
# `release: types: [released]` — that event never fires, because the release is
# created by create-release.yml under GITHUB_TOKEN and GitHub suppresses
# workflow-triggering events for it. See the note in bump-homebrew-formula.yml.
# Fires when a GH Release is published (not draft, not prerelease).
# `release.types: [released]` event fires only for stable releases — still
# double-checked by .github/actions/assert-stable-release for defense-in-depth.
on:
workflow_run:
workflows: ["Create Release Assets"]
types: [completed]
release:
types: [released]
workflow_dispatch:
inputs:
tag:
description: 'Release tag to sync (for manual recovery)'
description: 'Release tag to bump (for manual recovery)'
required: true
type: string
permissions:
contents: write
pull-requests: write
# The "Report failure" step opens a [release-ops] issue via
# github.rest.issues.create, which needs issues:write.
issues: write
contents: read
concurrency:
# Serialize per tag; do not cancel in-progress runs.
group: bump-homebrew-${{ github.event.workflow_run.head_branch || inputs.tag }}
# Serialize bumps per tag; do not cancel in-progress bumps.
group: bump-homebrew-${{ github.event.release.tag_name || inputs.tag }}
cancel-in-progress: false
jobs:
sync-cask:
# See bump-homebrew-formula.yml for why these three conditions: successful,
# tag-push (not a dry-run dispatch), v-prefixed. Format enforced downstream.
if: >-
github.event_name == 'workflow_dispatch' ||
(github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push' &&
startsWith(github.event.workflow_run.head_branch, 'v'))
# macOS runner: `xcrun stapler` is the only way to verify the notarization
# ticket, and shipping an unnotarized DMG to the cask is the failure mode
# this whole channel is most exposed to.
runs-on: macos-latest
timeout-minutes: 20
bump:
if: github.event_name == 'workflow_dispatch' || github.event.release.prerelease == false
runs-on: ubuntu-latest # brew runs on Linux — saves macOS runner quota
timeout-minutes: 30
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Resolve release
id: rel
uses: ./.github/actions/resolve-release
with:
tag: ${{ github.event.workflow_run.head_branch || inputs.tag }}
github_token: ${{ secrets.GITHUB_TOKEN }}
- name: Re-assert stable release
uses: ./.github/actions/assert-stable-release
with:
tag: ${{ steps.rel.outputs.tag }}
is_prerelease: ${{ steps.rel.outputs.is_prerelease }}
is_draft: ${{ steps.rel.outputs.is_draft }}
tag: ${{ github.event.release.tag_name || inputs.tag }}
is_prerelease: ${{ github.event.release.prerelease || 'false' }}
is_draft: ${{ github.event.release.draft || 'false' }}
- name: Download DMG, verify notarization, compute sha256
id: asset
run: |
set -euo pipefail
TAG="${{ steps.rel.outputs.tag }}"
VER="${{ steps.rel.outputs.ver }}"
URL="https://github.com/${{ github.repository }}/releases/download/${TAG}/amethyst-desktop-${VER}-macos-arm64.dmg"
echo "Fetching $URL"
# workflow_run fires only after every upload leg has finished, so the
# asset should already be there. Retry anyway for release-CDN
# propagation (mirrors the repo's push/pull retry ethos).
ok=0
for i in 1 2 3 4 5; do
if curl -fsSL -o amethyst.dmg "$URL"; then ok=1; break; fi
wait=$(( 2 ** i ))
echo "attempt $i failed; retrying in ${wait}s"
sleep "$wait"
done
[[ "$ok" == 1 ]] || { echo "::error::could not download $URL"; exit 1; }
test -s amethyst.dmg
# A cask must point at a notarized+stapled DMG or every user hits a
# Gatekeeper block. Refuse to advertise one that is not.
if ! xcrun stapler validate amethyst.dmg; then
echo "::error::${TAG} DMG has no stapled notarization ticket -- refusing to sync the cask. Check the notarizeReleaseDmg step in create-release.yml."
exit 1
fi
SHA=$(shasum -a 256 amethyst.dmg | awk '{print $1}')
echo "sha256=$SHA" >> "$GITHUB_OUTPUT"
echo "amethyst-desktop-${VER}-macos-arm64.dmg -> $SHA"
- name: Update reference cask
run: |
set -euo pipefail
CASK=desktopApp/packaging/homebrew/amethyst-nostr.rb
VER="${{ steps.rel.outputs.ver }}"
SHA="${{ steps.asset.outputs.sha256 }}"
# Anchor on the 2-space indent so the header comment's example lines
# are never touched.
sed -i '' -E "s|^( version ).*|\1\"${VER}\"|" "$CASK"
sed -i '' -E "s|^( sha256 ).*|\1\"${SHA}\"|" "$CASK"
echo "----- $CASK -----"
grep -E "^ (version|sha256) " "$CASK"
- name: Open or update the cask-sync PR
# peter-evans/create-pull-request is MIT-licensed CI-only tooling (not
# linked into any shipped artifact). It no-ops when there is no diff.
uses: peter-evans/create-pull-request@v8
- name: Bump cask (push-or-update PR)
uses: macauley/action-homebrew-bump-cask@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0
with:
token: ${{ secrets.GITHUB_TOKEN }}
base: main
branch: chore/bump-amethyst-cask-${{ steps.rel.outputs.tag }}
add-paths: desktopApp/packaging/homebrew/amethyst-nostr.rb
commit-message: 'chore: sync amethyst-nostr cask to ${{ steps.rel.outputs.tag }}'
title: 'chore: sync amethyst-nostr cask to ${{ steps.rel.outputs.tag }}'
body: |
Auto-synced `desktopApp/packaging/homebrew/amethyst-nostr.rb` to the
`${{ steps.rel.outputs.tag }}` release:
- `version` -> `${{ steps.rel.outputs.ver }}`
- `sha256` -> `${{ steps.asset.outputs.sha256 }}`
The DMG was verified notarized + stapled before this PR was opened.
**Merge this, then push it upstream from a maintainer machine:**
```bash
scripts/bump-homebrew-cask.sh ${{ steps.rel.outputs.tag }}
```
That step is manual on purpose — it needs a classic PAT with the
`repo` scope, which is deliberately NOT stored as a CI secret. See
BUILDING.md § Homebrew cask.
token: ${{ secrets.HOMEBREW_TOKEN }}
tap: homebrew/cask
cask: amethyst-nostr
tag: ${{ github.event.release.tag_name || inputs.tag }}
- name: Report failure
if: failure()
uses: actions/github-script@v9
with:
script: |
const tag = context.payload.workflow_run?.head_branch || context.payload.inputs?.tag || 'unknown';
const tag = context.payload.release?.tag_name || context.payload.inputs?.tag || 'unknown';
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: `[release-ops] sync-homebrew-cask failed for ${tag}`,
title: `[release-ops] bump-homebrew failed for ${tag}`,
body: [
`amethyst-nostr cask sync failed for release \`${tag}\`.`,
`Homebrew cask bump failed for release \`${tag}\`.`,
``,
`- Run: ${runUrl}`,
`- Channel: Homebrew Cask (\`amethyst-nostr\`)`,
``,
`Recovery options:`,
`1. Re-run the workflow once the underlying issue is fixed`,
`2. Check the DMG is notarized: \`xcrun stapler validate\` on the release asset`,
`3. Manually update \`desktopApp/packaging/homebrew/amethyst-nostr.rb\` (version + sha256)`
`1. Re-run the workflow once underlying issue is fixed`,
`2. Manually run \`brew bump-cask-pr amethyst-nostr --version ${tag.replace(/^v/, '')}\``,
`3. File PR directly against Homebrew/homebrew-cask`
].join('\n'),
labels: ['release-ops', 'bug']
});
+26 -172
View File
@@ -1,214 +1,68 @@
name: Sync Winget Manifest Reference
name: Bump Winget Manifest
# Fourth sibling of the three Homebrew sync workflows, same shape:
# bump-homebrew-formula.yml -> Formula `amy`
# bump-homebrew-geode-formula.yml -> Formula `geode`
# bump-homebrew.yml -> Cask `amethyst-nostr`
# this workflow -> Winget `VitorPamplona.Amethyst`
#
# What it does: after a stable release, download the published Windows MSI,
# compute its sha256, read its ProductCode, and open a PR syncing
# desktopApp/packaging/winget/*.yaml to that release.
#
# What it does NOT do: open a PR against microsoft/winget-pkgs. That step is
# deliberately MANUAL and runs on a maintainer's machine —
# `scripts/bump-winget.sh`. Reason: submitting requires push access to a fork of
# winget-pkgs. The previous design stored a classic `public_repo` PAT as
# WINGET_TOKEN and handed it to a third-party action; that scope grants write to
# every public repo the account can reach, and as an Actions secret it was
# usable by anyone with push access to this repo. The local script uses the
# maintainer's existing `gh` auth instead, so no PAT is created at all.
# See BUILDING.md § Winget.
#
# Consequence: this workflow needs NO external token and no third-party action.
#
# Trigger: after "Create Release Assets" succeeds for a tag push. NOT
# `release: types: [released]` — that event never fires, because the release is
# created by create-release.yml under GITHUB_TOKEN and GitHub suppresses
# workflow-triggering events for it. See the note in bump-homebrew-formula.yml.
on:
workflow_run:
workflows: ["Create Release Assets"]
types: [completed]
release:
types: [released]
workflow_dispatch:
inputs:
tag:
description: 'Release tag to sync (for manual recovery)'
description: 'Release tag to submit (for manual recovery)'
required: true
type: string
permissions:
contents: write
pull-requests: write
# The "Report failure" step opens a [release-ops] issue via
# github.rest.issues.create, which needs issues:write.
issues: write
contents: read
concurrency:
group: bump-winget-${{ github.event.workflow_run.head_branch || inputs.tag }}
group: bump-winget-${{ github.event.release.tag_name || inputs.tag }}
cancel-in-progress: false
jobs:
sync-manifest:
# See bump-homebrew-formula.yml for why these three conditions: successful,
# tag-push (not a dry-run dispatch), v-prefixed. Format enforced downstream.
if: >-
github.event_name == 'workflow_dispatch' ||
(github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push' &&
startsWith(github.event.workflow_run.head_branch, 'v'))
# Linux, not Windows: msitools reads the MSI Property table just as well, and
# this leg is billed 1x instead of 2x.
runs-on: ubuntu-latest
timeout-minutes: 20
bump:
if: github.event_name == 'workflow_dispatch' || github.event.release.prerelease == false
runs-on: windows-latest
timeout-minutes: 30
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Resolve release
id: rel
uses: ./.github/actions/resolve-release
with:
tag: ${{ github.event.workflow_run.head_branch || inputs.tag }}
github_token: ${{ secrets.GITHUB_TOKEN }}
- name: Re-assert stable release
uses: ./.github/actions/assert-stable-release
with:
tag: ${{ steps.rel.outputs.tag }}
is_prerelease: ${{ steps.rel.outputs.is_prerelease }}
is_draft: ${{ steps.rel.outputs.is_draft }}
tag: ${{ github.event.release.tag_name || inputs.tag }}
is_prerelease: ${{ github.event.release.prerelease || 'false' }}
is_draft: ${{ github.event.release.draft || 'false' }}
- name: Install msitools
run: sudo apt-get update -qq && sudo apt-get install -y -qq msitools
- name: Download MSI, compute sha256 + ProductCode
id: asset
run: |
set -euo pipefail
TAG="${{ steps.rel.outputs.tag }}"
VER="${{ steps.rel.outputs.ver }}"
URL="https://github.com/${{ github.repository }}/releases/download/${TAG}/amethyst-desktop-${VER}-windows-x64.msi"
echo "Fetching $URL"
# workflow_run fires only after every upload leg has finished, so the
# asset should already be there. Retry anyway for release-CDN
# propagation (mirrors the repo's push/pull retry ethos).
ok=0
for i in 1 2 3 4 5; do
if curl -fsSL -o amethyst.msi "$URL"; then ok=1; break; fi
wait=$(( 2 ** i ))
echo "attempt $i failed; retrying in ${wait}s"
sleep "$wait"
done
[[ "$ok" == 1 ]] || { echo "::error::could not download $URL"; exit 1; }
test -s amethyst.msi
SHA=$(sha256sum amethyst.msi | awk '{print $1}' | tr '[:lower:]' '[:upper:]')
# ProductCode is the ARP key winget uses to detect an existing install.
# jpackage regenerates it per build, so read it rather than pin it.
PRODUCT_CODE=$(msiinfo export amethyst.msi Property \
| awk -F'\t' '$1 == "ProductCode" { print $2 }' | tr -d '\r')
if [[ ! "$PRODUCT_CODE" =~ ^\{[0-9A-Fa-f-]{36}\}$ ]]; then
echo "::error::could not read a valid ProductCode from the MSI (got: '${PRODUCT_CODE}')"
exit 1
fi
echo "url=$URL" >> "$GITHUB_OUTPUT"
echo "sha256=$SHA" >> "$GITHUB_OUTPUT"
echo "product_code=$PRODUCT_CODE" >> "$GITHUB_OUTPUT"
echo "sha256=$SHA"
echo "ProductCode=$PRODUCT_CODE"
- name: Update reference manifests
run: |
set -euo pipefail
DIR=desktopApp/packaging/winget
TAG="${{ steps.rel.outputs.tag }}"
VER="${{ steps.rel.outputs.ver }}"
SHA="${{ steps.asset.outputs.sha256 }}"
URL="${{ steps.asset.outputs.url }}"
PC="${{ steps.asset.outputs.product_code }}"
# Anchored substitutions so the header comments are never touched.
sed -i -E "s|^(PackageVersion: ).*|\1${VER}|" \
"$DIR/VitorPamplona.Amethyst.yaml" \
"$DIR/VitorPamplona.Amethyst.installer.yaml" \
"$DIR/VitorPamplona.Amethyst.locale.en-US.yaml"
sed -i -E "s|^( InstallerUrl: ).*|\1${URL}|" "$DIR/VitorPamplona.Amethyst.installer.yaml"
# Quoted: an all-digit 64-char digest would otherwise parse as a YAML
# integer and fail the schema's `string` type.
sed -i -E "s|^( InstallerSha256: ).*|\1'${SHA}'|" "$DIR/VitorPamplona.Amethyst.installer.yaml"
sed -i -E "s|^( ProductCode: ).*|\1'${PC}'|" "$DIR/VitorPamplona.Amethyst.installer.yaml"
sed -i -E "s|^(ReleaseNotesUrl: ).*|\1https://github.com/${{ github.repository }}/releases/tag/${TAG}|" \
"$DIR/VitorPamplona.Amethyst.locale.en-US.yaml"
echo "----- synced -----"
grep -hE "^(PackageVersion| InstallerUrl| InstallerSha256| ProductCode|ReleaseNotesUrl): " "$DIR"/*.yaml
- name: Sanity-check the manifests still parse
run: |
set -euo pipefail
python3 - <<'PY'
import glob, sys, yaml
for f in sorted(glob.glob('desktopApp/packaging/winget/*.yaml')):
d = yaml.safe_load(open(f))
assert d['PackageIdentifier'] == 'VitorPamplona.Amethyst', f
assert d['PackageVersion'], f
print('OK', f, d['ManifestType'])
PY
- name: Open or update the manifest-sync PR
# peter-evans/create-pull-request is MIT-licensed CI-only tooling (not
# linked into any shipped artifact). It no-ops when there is no diff.
uses: peter-evans/create-pull-request@v8
- name: Submit manifest to winget-pkgs
uses: vedantmgoyal9/winget-releaser@4ffc7888bffd451b357355dc214d43bb9f23917e # v2
with:
token: ${{ secrets.GITHUB_TOKEN }}
base: main
branch: chore/bump-winget-manifest-${{ steps.rel.outputs.tag }}
add-paths: desktopApp/packaging/winget
commit-message: 'chore: sync winget manifests to ${{ steps.rel.outputs.tag }}'
title: 'chore: sync winget manifests to ${{ steps.rel.outputs.tag }}'
body: |
Auto-synced `desktopApp/packaging/winget/` to the
`${{ steps.rel.outputs.tag }}` release:
- `PackageVersion` -> `${{ steps.rel.outputs.ver }}`
- `InstallerSha256` -> `${{ steps.asset.outputs.sha256 }}`
- `ProductCode` -> `${{ steps.asset.outputs.product_code }}`
**Merge this, then push it upstream from a maintainer machine:**
```bash
scripts/bump-winget.sh ${{ steps.rel.outputs.tag }}
```
That step is manual on purpose — it needs push access to a fork of
`microsoft/winget-pkgs`, which is deliberately NOT stored as a CI
secret. The script uses your existing `gh` auth. See
BUILDING.md § Winget.
identifier: VitorPamplona.Amethyst
version: ${{ github.event.release.tag_name || inputs.tag }}
# Asset naming contract: scripts/asset-name.sh
installers-regex: '^amethyst-desktop-.*-windows-x64\.msi$'
token: ${{ secrets.WINGET_TOKEN }}
- name: Report failure
if: failure()
uses: actions/github-script@v9
with:
script: |
const tag = context.payload.workflow_run?.head_branch || context.payload.inputs?.tag || 'unknown';
const tag = context.payload.release?.tag_name || context.payload.inputs?.tag || 'unknown';
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: `[release-ops] sync-winget-manifest failed for ${tag}`,
title: `[release-ops] bump-winget failed for ${tag}`,
body: [
`Winget manifest sync failed for release \`${tag}\`.`,
`Winget manifest submission failed for release \`${tag}\`.`,
``,
`- Run: ${runUrl}`,
`- Channel: Winget (\`VitorPamplona.Amethyst\`)`,
``,
`Recovery options:`,
`1. Re-run the workflow once the underlying issue is fixed`,
`2. Check the release actually published \`amethyst-desktop-${tag.replace(/^v/, '')}-windows-x64.msi\``,
`3. Manually update \`desktopApp/packaging/winget/*.yaml\` (version, sha256, ProductCode)`
`1. Re-run the workflow once underlying issue is fixed`,
`2. Manually submit via \`wingetcreate update VitorPamplona.Amethyst -v ${tag.replace(/^v/, '')}\``,
`3. File PR directly against microsoft/winget-pkgs`
].join('\n'),
labels: ['release-ops', 'bug']
});
+3 -387
View File
@@ -44,7 +44,7 @@ jobs:
- { os: ubuntu-latest, arch: x64, family: linux, tasks: "packageReleaseDeb packageReleaseRpm" }
- { os: ubuntu-latest, arch: x64, family: linux-portable, tasks: "createReleaseAppImage createReleaseDistributable" }
runs-on: ${{ matrix.os }}
timeout-minutes: 60 # linux-portable leg also downloads the freedesktop runtime + builds the Flatpak bundle
timeout-minutes: 45
defaults:
run:
shell: bash
@@ -101,25 +101,6 @@ jobs:
fi
chmod +x desktopApp/packaging/appimage/appimagetool-x86_64.AppImage
# Flatpak tooling + the freedesktop runtime/sdk the manifest pins
# (runtime-version is greped from the manifest so this never drifts).
# Retried: the runtime download from Flathub is ~1 GB and flatpak
# install resumes cleanly on re-run.
- name: Install Flatpak tooling + runtimes (linux-portable only)
if: matrix.family == 'linux-portable'
uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0
with:
max_attempts: 3
timeout_minutes: 15
command: |
set -euo pipefail
sudo apt-get update && sudo apt-get install -y flatpak flatpak-builder
flatpak remote-add --user --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo
FDO_VER=$(grep -E "^runtime-version:" desktopApp/packaging/flatpak/com.vitorpamplona.amethyst.Desktop.yml | cut -d"'" -f2)
flatpak install --user --noninteractive flathub \
"org.freedesktop.Platform//${FDO_VER}" \
"org.freedesktop.Sdk//${FDO_VER}"
# macOS only: import the Developer ID Application cert into a throwaway
# keychain so jpackage's codesign pass can find it. Soft — if the
# MAC_CERTIFICATE_P12 secret isn't set (forks, or before Apple creds are
@@ -155,44 +136,8 @@ jobs:
AMETHYST_NOTARY_TEAM_ID: ${{ secrets.MAC_NOTARY_TEAM_ID }}
with:
max_attempts: 2
# macOS needs far longer: notarizeReleaseDmg blocks on `notarytool
# submit --wait`, which is minutes-to-tens-of-minutes on Apple's side.
timeout_minutes: ${{ matrix.family == 'macos' && 45 || 15 }}
# Append notarization on the macOS leg. `packageReleaseDmg` only SIGNS
# the DMG — notarization is a separate Compose task, and because it was
# never invoked every release up to v1.13.1 shipped a signed but
# UNNOTARIZED DMG that Gatekeeper blocks on first launch. The task runs
# `notarytool submit --wait` and then `stapler staple`, in place, so the
# asset-collection step below still finds the same file.
#
# Gated on the cert AND all three notary secrets being present, so forks
# and credential-less runs keep producing a plain unsigned DMG exactly as
# before instead of failing.
command: ./gradlew --no-daemon :desktopApp:${{ matrix.tasks }}${{ (matrix.family == 'macos' && steps.mac_keychain.outputs.signing == 'true' && secrets.MAC_NOTARY_APPLE_ID != '' && secrets.MAC_NOTARY_PASSWORD != '' && secrets.MAC_NOTARY_TEAM_ID != '') && ' :desktopApp:notarizeReleaseDmg' || '' }}
# Regression guard. The missing-notarization bug was invisible for many
# releases precisely because nothing ever asserted the outcome; assert it
# now so a silently-dropped notarization step can never ship again.
- name: Verify the DMG is notarized and stapled (macOS leg)
if: matrix.family == 'macos'
env:
EXPECT_NOTARIZED: ${{ (steps.mac_keychain.outputs.signing == 'true' && secrets.MAC_NOTARY_APPLE_ID != '' && secrets.MAC_NOTARY_PASSWORD != '' && secrets.MAC_NOTARY_TEAM_ID != '') && 'true' || 'false' }}
run: |
set -euo pipefail
DMG=$(find desktopApp/build/compose/binaries -name "*.dmg" -print -quit)
[[ -n "$DMG" ]] || { echo "::error::no DMG produced"; exit 1; }
echo "Checking $DMG"
if [[ "$EXPECT_NOTARIZED" != "true" ]]; then
echo "::warning::Apple signing/notary credentials are not configured; this DMG is unsigned and unnotarized. Gatekeeper will block it, and it is not eligible for the Homebrew cask."
exit 0
fi
if ! xcrun stapler validate "$DMG"; then
echo "::error::$DMG has no stapled notarization ticket -- notarizeReleaseDmg did not run or failed"
exit 1
fi
echo "notarization ticket stapled OK"
timeout_minutes: 15
command: ./gradlew --no-daemon :desktopApp:${{ matrix.tasks }}
# jpackage pins libicu to the build host's version (libicu74 on
# ubuntu-24.04). Rewrite the .deb so it installs across Debian/Ubuntu.
@@ -216,36 +161,6 @@ jobs:
( cd "$APP" && tar czf "../../../../portable/amethyst-desktop-${VER}-linux-x64.tar.gz" Amethyst/ )
fi
# Flatpak bundle: wraps the same createReleaseDistributable tree the
# AppImage uses. The manifest (desktopApp/packaging/flatpak/) copies the
# prebuilt jpackage tree into /app — no Gradle runs inside the sandbox.
# build-bundle emits a single-file .flatpak whose baked-in runtime-repo
# lets the user's flatpak fetch the freedesktop runtime from Flathub on
# install. --disable-rofiles-fuse: GH runners lack a usable rofiles-fuse.
- name: Build Flatpak bundle (linux-portable only)
if: matrix.family == 'linux-portable'
run: |
set -euo pipefail
VER="${{ steps.ver.outputs.version }}"
PKG="desktopApp/packaging/flatpak"
APP_ID="com.vitorpamplona.amethyst.Desktop"
OUT="desktopApp/build/flatpak"
# Inject the AppStream <release> entry for this build (the checked-in
# metainfo deliberately carries none — CI is the source of truth).
sed -i "s|<releases>|<releases>\n <release version=\"${VER}\" date=\"$(date -u +%F)\" />|" \
"${PKG}/${APP_ID}.metainfo.xml"
mkdir -p "$OUT"
flatpak-builder --user --force-clean --disable-rofiles-fuse \
--state-dir="${OUT}/.flatpak-builder" \
--repo="${OUT}/repo" \
"${OUT}/build-dir" \
"${PKG}/${APP_ID}.yml"
flatpak build-bundle "${OUT}/repo" \
"${OUT}/Amethyst-${VER}-x86_64.flatpak" \
"$APP_ID" \
--runtime-repo=https://dl.flathub.org/repo/flathub.flatpakrepo
ls -la "$OUT"
- name: Collect + rename assets
run: |
set -euo pipefail
@@ -554,305 +469,6 @@ jobs:
ls -la dist >> "$GITHUB_STEP_SUMMARY"
echo '```' >> "$GITHUB_STEP_SUMMARY"
# ---------------------------------------------------------------------------
# geode relay build matrix. Same shape as build-cli — geode is the same kind
# of `application`-plugin JVM module — producing a self-contained bundle with a
# minimal jlink'd JRE (no system Java required) plus native Linux packages.
#
# geodeImage task (all legs): geode/build/geode-image/geode/ → geode-*.tar.gz
# jpackageDeb / jpackageRpm: geode/build/jpackage/geode_*.deb + geode-*.rpm
# no-JRE jvm bundle (linux): geode-<ver>-jvm.tar.gz for the Homebrew formula
#
# Unlike build-cli there is no "no Compose UI" assertion — geode depends only on
# :quartz and never pulls the Compose render stack. The GHCR Docker image is a
# separate job (docker-geode) below.
#
# Asset naming: geode-<version>-<family>-<arch>.<ext>. See scripts/asset-name.sh.
# ---------------------------------------------------------------------------
build-geode:
strategy:
fail-fast: false
matrix:
include:
- { os: macos-14, arch: arm64, family: macos, tasks: "geodeImage" }
- { os: ubuntu-latest, arch: x64, family: linux, tasks: "geodeImage jpackageDeb jpackageRpm" }
runs-on: ${{ matrix.os }}
timeout-minutes: 45 # macOS leg also codesigns + notarizes the jlink image
defaults:
run:
shell: bash
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: 21
- name: Resolve tag + version
id: ver
env:
DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }}
TEST_TAG: ${{ github.event.inputs.test_tag || '' }}
run: |
set -euo pipefail
if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then
TAG="${TEST_TAG:-v0.0.0-dryrun}"
else
TAG="${GITHUB_REF_NAME}"
fi
VER="${TAG#v}"
TOML_VER=$(grep -E '^app\s*=' gradle/libs.versions.toml | head -1 | cut -d'"' -f2)
if [[ "${GITHUB_EVENT_NAME}" != "workflow_dispatch" ]]; then
if [[ "$TOML_VER" != "$VER" ]]; then
echo "::error::gradle/libs.versions.toml app=$TOML_VER but tag is $TAG"
exit 1
fi
fi
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "version=$VER" >> "$GITHUB_OUTPUT"
- name: Install RPM tooling (linux only)
if: matrix.family == 'linux'
run: sudo apt-get update && sudo apt-get install -y rpm fakeroot
- name: Build geode artifacts
uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0
with:
max_attempts: 2
timeout_minutes: 15
command: ./gradlew --no-daemon :geode:${{ matrix.tasks }}
# Boot the bundled jlink image before shipping it. `--version` proves the
# JVM starts and the main class loads; the serve leg proves the jlink
# module list is complete for the real relay path (Ktor CIO + SQLite +
# NIP-11 serialization) — a too-tight module list links fine but fails
# here with NoClassDefFound instead of on an operator's machine.
- name: Smoke-test the geode image
run: |
set -euo pipefail
IMG="geode/build/geode-image/geode"
"$IMG/bin/geode" --version
"$IMG/bin/geode" --port 17447 &
PID=$!
ok=0
for i in $(seq 1 20); do
if curl -fsS -H 'Accept: application/nostr+json' http://127.0.0.1:17447/ -o /tmp/nip11.json; then ok=1; break; fi
sleep 1
done
kill "$PID" 2>/dev/null || true
wait "$PID" 2>/dev/null || true
[[ "$ok" == 1 ]] || { echo "::error::geode image did not serve NIP-11 within 20s"; exit 1; }
echo "NIP-11 doc:"; head -c 400 /tmp/nip11.json; echo
grep -q '"supported_nips"' /tmp/nip11.json || { echo "::error::NIP-11 doc missing supported_nips"; exit 1; }
# macOS only: import the Developer ID cert (no-op without the secret) so
# the next step can codesign the jlink image. The jvm bundle for
# Homebrew-core is NOT signed here — Homebrew strips quarantine itself.
- name: Import Apple Developer ID certificate (macOS leg, if configured)
if: matrix.family == 'macos'
id: mac_keychain
uses: ./.github/actions/import-macos-cert
with:
certificate-p12-base64: ${{ secrets.MAC_CERTIFICATE_P12 }}
certificate-password: ${{ secrets.MAC_CERTIFICATE_PASSWORD }}
# Codesign + notarize the macOS jlink image (geode-<ver>-macos-arm64.tar.gz)
# for operators who download it directly. A loose tarball can't be stapled
# (stapler only does .app/.dmg/.pkg), so Gatekeeper verifies notarization
# online on first run. Runs before "Collect" so the tarred image is signed.
- name: Sign + notarize geode image (macOS leg, if configured)
if: matrix.family == 'macos' && steps.mac_keychain.outputs.signing == 'true'
env:
SIGN_IDENTITY: ${{ secrets.MAC_SIGN_IDENTITY }}
NOTARY_APPLE_ID: ${{ secrets.MAC_NOTARY_APPLE_ID }}
NOTARY_PASSWORD: ${{ secrets.MAC_NOTARY_PASSWORD }}
NOTARY_TEAM_ID: ${{ secrets.MAC_NOTARY_TEAM_ID }}
run: |
set -euo pipefail
IMG="geode/build/geode-image/geode"
ENTITLEMENTS="geode/packaging/macos/geode.entitlements"
# Sign the macOS Mach-O natives buried INSIDE the bundled jars
# (secp256k1/sqlite) first — Apple's notary recurses into jars and
# rejects any unsigned Mach-O, so this must run before notarize.
SIGN_IDENTITY="$SIGN_IDENTITY" scripts/sign-macos-jar-natives.sh "$IMG"
# Sign every loose Mach-O binary in the bundled JRE. Executables get
# the hardened-runtime entitlements; dylibs don't.
while IFS= read -r f; do
case "$(file -b "$f")" in
*Mach-O*executable*)
codesign --force --options runtime --timestamp \
--entitlements "$ENTITLEMENTS" --sign "$SIGN_IDENTITY" "$f" ;;
*Mach-O*)
codesign --force --options runtime --timestamp \
--sign "$SIGN_IDENTITY" "$f" ;;
esac
done < <(find "$IMG" -type f)
codesign --verify --strict --verbose=2 "$IMG/runtime/bin/java"
# Notarize: zip the signed image, submit, wait for Apple's verdict.
ZIP="$RUNNER_TEMP/geode-notarize.zip"
OUT="$RUNNER_TEMP/notary-submit.json"
ditto -c -k --keepParent "$IMG" "$ZIP"
if ! xcrun notarytool submit "$ZIP" \
--apple-id "$NOTARY_APPLE_ID" --password "$NOTARY_PASSWORD" \
--team-id "$NOTARY_TEAM_ID" --wait --output-format json > "$OUT"; then
echo "::warning::notarytool submit exited non-zero"
fi
cat "$OUT"
STATUS="$(jq -r '.status // "Unknown"' "$OUT" 2>/dev/null || echo Unknown)"
SUBMISSION_ID="$(jq -r '.id // empty' "$OUT" 2>/dev/null || true)"
if [ "$STATUS" != "Accepted" ]; then
echo "::error::Notarization status: $STATUS"
if [ -n "$SUBMISSION_ID" ]; then
echo "----- notary log -----"
xcrun notarytool log "$SUBMISSION_ID" \
--apple-id "$NOTARY_APPLE_ID" --password "$NOTARY_PASSWORD" \
--team-id "$NOTARY_TEAM_ID" || true
fi
exit 1
fi
# jpackage pins libicu to the build host's version (libicu74 on
# ubuntu-24.04). Rewrite the .deb so it installs across Debian/Ubuntu.
- name: Relax libicu dependency in .deb
if: matrix.family == 'linux'
run: |
set -euo pipefail
chmod +x scripts/relax-deb-libicu.sh
scripts/relax-deb-libicu.sh geode/build/jpackage/*.deb
- name: Collect + rename assets
run: |
set -euo pipefail
# shellcheck source=scripts/asset-name.sh
source scripts/asset-name.sh
collect_geode_assets "${{ matrix.family }}" "${{ matrix.arch }}" "${{ steps.ver.outputs.version }}" dist
# Homebrew-core ships a no-JRE jar bundle and depends_on "openjdk" — it
# cannot use the jlink tarball above (bundled runtime) nor build from
# source (its sandbox blocks Gradle's Maven downloads). installDist
# (bin/geode + lib/*.jar, no runtime/) is exactly that bundle. It is pure
# JVM bytecode, so one platform-independent asset serves every OS; we cut
# it on the linux leg only. geodeImage depends on installDist, so the
# geode/build/install/geode tree already exists here.
- name: Package no-JRE jvm bundle for Homebrew (linux leg only)
if: matrix.family == 'linux'
run: |
set -euo pipefail
VER="${{ steps.ver.outputs.version }}"
SRC="geode/build/install/geode"
test -x "$SRC/bin/geode"
( cd "$SRC" && tar czf "$OLDPWD/dist/geode-${VER}-jvm.tar.gz" bin lib )
echo "Collected: dist/geode-${VER}-jvm.tar.gz"
- name: Enforce geode size budget (200 MB per asset)
run: |
set -euo pipefail
fail=0
for f in dist/*; do
if [[ -f "$f" ]]; then
size=$(wc -c < "$f")
mb=$(( size / 1048576 ))
if (( size > 209715200 )); then
echo "::error file=$f::asset is ${mb} MB — exceeds 200 MB geode budget"
fail=1
else
echo "OK: $f — ${mb} MB"
fi
fi
done
[[ "$fail" == 0 ]]
- name: Classify release
id: classify
run: |
TAG="${{ steps.ver.outputs.tag }}"
if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "prerelease=false" >> "$GITHUB_OUTPUT"
else
echo "prerelease=true" >> "$GITHUB_OUTPUT"
fi
- name: Upload to GH Release (skip on dry-run)
if: github.event_name != 'workflow_dispatch' || github.event.inputs.dry_run != 'true'
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
with:
files: dist/*
tag_name: ${{ steps.ver.outputs.tag }}
prerelease: ${{ steps.classify.outputs.prerelease }}
draft: false
fail_on_unmatched_files: true
generate_release_notes: false # Android job writes release notes (last-writer-wins race)
- name: Dry-run summary
if: github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run == 'true'
run: |
echo "### Dry-run: geode ${{ matrix.family }}/${{ matrix.arch }}" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo '```' >> "$GITHUB_STEP_SUMMARY"
ls -la dist >> "$GITHUB_STEP_SUMMARY"
echo '```' >> "$GITHUB_STEP_SUMMARY"
# ---------------------------------------------------------------------------
# geode Docker image → GitHub Container Registry (GHCR). A relay is most often
# deployed as a container, so this is geode's primary distribution channel.
# Builds geode/Dockerfile (multi-stage: gradle installDist → temurin JRE) and
# pushes ghcr.io/<owner>/geode:<version> (+ :latest on a stable release).
# Tag-push only — skipped on the workflow_dispatch dry-run.
# ---------------------------------------------------------------------------
docker-geode:
if: github.event_name != 'workflow_dispatch'
runs-on: ubuntu-latest
timeout-minutes: 45
permissions:
contents: read
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Resolve version + tags
id: meta
run: |
set -euo pipefail
TAG="${GITHUB_REF_NAME}"
VER="${TAG#v}"
# Lowercase owner — GHCR repository paths must be lowercase.
OWNER="$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]')"
IMAGE="ghcr.io/${OWNER}/geode"
TAGS="${IMAGE}:${VER}"
# Only move :latest for a stable vX.Y.Z tag, never a prerelease.
if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
TAGS="${TAGS},${IMAGE}:latest"
fi
echo "tags=$TAGS" >> "$GITHUB_OUTPUT"
echo "image=$IMAGE" >> "$GITHUB_OUTPUT"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push image
uses: docker/build-push-action@v6
with:
context: .
file: geode/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: |
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.version=${{ steps.meta.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max
# ---------------------------------------------------------------------------
# Android build + sign + direct-upload. Logic preserved from previous workflow;
# uses softprops/action-gh-release@v2 instead of deprecated upload-release-asset.
-10
View File
@@ -41,15 +41,6 @@ jobs:
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
# crowdin/github-action runs in a Docker container as root, so any file or
# directory it downloads (especially a brand-new locale folder like
# values-en-rGB/) ends up owned by root. The unprivileged runner user in
# the create-pull-request step below then can't unlink those files, which
# aborts its branch checkout with "unable to unlink ... Permission denied".
# Reclaim ownership of the whole working tree before touching git.
- name: Fix ownership after Crowdin Docker action
run: sudo chown -R "$(id -u):$(id -g)" "$GITHUB_WORKSPACE"
# Keep docs/changelog/translators.json seeded with everyone who has translated
# recently, so the per-release `## Translations` credits (scripts/translators.sh)
# can resolve them to npubs. Only adds rows when a genuinely new contributor
@@ -70,7 +61,6 @@ jobs:
branch: l10n_crowdin_translations
add-paths: |
amethyst/src/main/res/**/strings.xml
commons/src/commonMain/composeResources/**/strings.xml
docs/changelog/translators.json
commit-message: 'chore: sync Crowdin translations and seed translator npub placeholders'
title: 'New Crowdin Translations'
-5
View File
@@ -180,11 +180,6 @@ desktopApp/src/jvmMain/appResources/*/ffmpeg/*
desktopApp/packaging/appimage/appimagetool-x86_64.AppImage
desktopApp/packaging/appimage/squashfs-root/
# flatpak-builder state/cache from local builds (CI uses --state-dir under desktopApp/build/)
.flatpak-builder/
desktopApp/packaging/flatpak/**/build-dir/
desktopApp/packaging/flatpak/**/repo/
# Git worktrees
.worktrees/
.claude/worktrees/
+1 -1
View File
@@ -8,6 +8,6 @@
</component>
<component name="KotlinJpsPluginSettings">
<option name="externalSystemId" value="Gradle" />
<option name="version" value="2.4.10" />
<option name="version" value="2.4.0" />
</component>
</project>
+22 -202
View File
@@ -37,9 +37,7 @@ Platform-specific:
- **macOS**: Xcode Command Line Tools (`xcode-select --install`)
- **Windows**: WiX Toolset 3.x on PATH (for MSI). `winget install WiXToolset.WiXToolset`
- **Linux (all)**: nothing extra for `.deb`; `rpm` + `fakeroot` for `.rpm`;
`appimagetool` + `desktop-file-utils` for AppImage; `flatpak` +
`flatpak-builder` for the Flatpak bundle (see
[`desktopApp/packaging/flatpak/README.md`](desktopApp/packaging/flatpak/README.md))
`appimagetool` + `desktop-file-utils` for AppImage
Install Linux RPM tooling:
@@ -111,7 +109,6 @@ are **not** required to build Amethyst from the committed sources.
| Linux `.deb` | `./gradlew :desktopApp:packageReleaseDeb` | `desktopApp/build/compose/binaries/main-release/deb/amethyst_*.deb` |
| Linux `.rpm` | `./gradlew :desktopApp:packageReleaseRpm` | `desktopApp/build/compose/binaries/main-release/rpm/amethyst-*.rpm` |
| Linux AppImage | `./gradlew :desktopApp:createReleaseAppImage` | `desktopApp/build/appimage/Amethyst-*-x86_64.AppImage` |
| Linux Flatpak | `flatpak-builder` over `createReleaseDistributable` output — see [`desktopApp/packaging/flatpak/README.md`](desktopApp/packaging/flatpak/README.md) | `desktopApp/build/flatpak/Amethyst-*-x86_64.flatpak` (CI) |
| Windows `.zip` portable | See below (inline `7z`) | — |
| Linux `.tar.gz` portable | See below (inline `tar`) | — |
@@ -151,7 +148,7 @@ Where:
| `<version>` | Tag stripped of leading `vX.YY.ZZ` |
| `<family>` | `macos`, `windows`, `linux` |
| `<arch>` | `x64`, `arm64` |
| `<ext>` | `dmg`, `msi`, `zip`, `deb`, `rpm`, `AppImage`, `flatpak`, `tar.gz` |
| `<ext>` | `dmg`, `msi`, `zip`, `deb`, `rpm`, `AppImage`, `tar.gz` |
Single source of truth: [`scripts/asset-name.sh`](scripts/asset-name.sh).
Package manager manifests (Homebrew cask, Winget) depend on this exact scheme —
@@ -163,7 +160,6 @@ Examples:
- `amethyst-desktop-1.12.1-macos-arm64.dmg`
- `amethyst-desktop-1.12.1-windows-x64.msi`
- `amethyst-desktop-1.12.1-linux-x64.AppImage`
- `amethyst-desktop-1.12.1-linux-x64.flatpak`
---
@@ -217,85 +213,6 @@ toolchain drifted — file it before publishing.
---
## Local SonarQube analysis (opt-in)
The build supports running a [SonarQube](https://www.sonarsource.com/products/sonarqube/)
analysis against a locally hosted server. It is **off by default**: unless you
opt in, the scanner plugin is neither downloaded nor applied and the build is
unaffected.
### 1. Install and start a local SonarQube server
Either run the official Docker image:
```bash
docker run -d --name sonarqube -p 9000:9000 sonarqube:community
```
or download the [Community Build zip](https://www.sonarsource.com/products/sonarqube/downloads/),
unzip it, and start it (requires a JDK 17+ on `PATH`):
```bash
cd sonarqube-<version>
bin/macosx-universal-64/sonar.sh console # pick the folder matching your OS
```
Once it reports up, open <http://localhost:9000> (first login `admin`/`admin`,
you'll be asked to change it), create a **local project** named `Amethyst` with
project key `Amethyst`, and generate a **project analysis token** for it
(*Project Settings → Analysis Method → With Gradle*, or
*My Account → Security → Generate token*). The token looks like `sqp_…`.
### 2. Point the build at your server
Add the server and token to `local.properties` (gitignored — the token never
lands in the repo):
```properties
sonar.host.url=http://localhost:9000
sonar.token=sqp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```
### 3. Run the analysis
```bash
./gradlew sonar
```
When it finishes, browse the results at
<http://localhost:9000/dashboard?id=Amethyst>.
### 4. Optional: include Android Lint results
The scanner auto-imports each Android module's lint report and shows the
findings as external issues alongside Sonar's own. It only *imports* — it never
runs lint itself — so without the reports on disk the analysis warns
`Unable to import Android Lint report file(s)`. Generate them first, then run
the scan as a **separate** invocation (chaining lint and `sonar` in one Gradle
call does not guarantee lint finishes first):
```bash
./gradlew :amethyst:lintPlayDebug :benchmark:lintBenchmark :nappletHost:lintDebug
./gradlew sonar
```
The reports persist under each module's `build/reports/`, so re-run lint only
when you want fresh lint data in the next scan.
Every `sonar.*` entry in `local.properties` is forwarded to the scanner, so any
[analysis parameter](https://docs.sonarsource.com/sonarqube-server/latest/analyzing-source-code/analysis-parameters/)
can be set there. `sonar.projectKey` / `sonar.projectName` default to the root
project name (`Amethyst`).
Even when opted in, the scanner plugin only loads on invocations that actually
request the `sonar` task — ordinary builds and IDE syncs are unaffected (which
is also why `./gradlew tasks` doesn't list it).
Note: the SonarQube Gradle scanner plugin is LGPL-3.0. It is a build-time-only
tool fetched after explicit opt-in; it is never linked into shipped artifacts.
---
## Release runbook
The release flow is driven by a tag push. Every cut ships Android + Desktop +
@@ -325,29 +242,15 @@ Quartz library in one pipeline.
3. **Wait** for the `Create Release Assets` workflow to finish (~2530 min).
4. **Verify** — the GH Release should hold **31 assets**:
- **8 desktop** — `dmg` (macOS arm64), `msi` + `zip` (Windows), `deb`, `rpm`,
`AppImage`, `flatpak`, `tar.gz` (Linux). There is **no Intel/x64 macOS
DMG** — `jpackage` cannot cross-compile and no Intel runner leg is
configured, so macOS ships arm64-only.
- **13 Android** — 5 Google Play APKs + 5 F-Droid APKs + 2 AABs + the
F-Droid `.apks` set built for Accrescent.
- **5 amy** + **5 geode** bundles.
4. **Verify**:
- GH Release contains 8 desktop assets + 12 Android assets
- Asset sizes look sane (see §Enforce asset size budget — CI auto-fails at 1 GB/asset)
- Intel + ARM DMGs both present
- Android flow unchanged
Quick diff against the previous release, which catches a silently-dropped
matrix leg better than any count:
```bash
diff <(gh release view v1.13.0 --json assets --jq '.assets[].name' | sed 's/1\.13\.0/VER/g' | sort) \
<(gh release view v1.13.1 --json assets --jq '.assets[].name' | sed 's/1\.13\.1/VER/g' | sort)
```
5. **Stable vs prerelease** — a tag containing `-rc`, `-beta`, `-alpha`, `-dev`,
or `-snapshot` is auto-classified as prerelease. Only stable tags run the
Homebrew + Winget bump workflows (and those are no-ops until the one-time
bootstrap PRs land — see § Bootstrap).
or `-snapshot` is auto-classified as prerelease. Stable tags trigger the
Homebrew + Winget bump workflows.
### Dry-run (no tag push)
@@ -403,8 +306,8 @@ provided automatically; everything else you set yourself.)
| `MAC_NOTARY_APPLE_ID` | Apple ID email of the notarization account | Apple notarization (`notarytool`) |
| `MAC_NOTARY_PASSWORD` | **App-specific** password for that Apple ID (not the login password) | Same |
| `MAC_NOTARY_TEAM_ID` | 10-char Apple Developer **Team ID** | Same |
| ~~`HOMEBREW_TOKEN`~~ | *Not used.* The cask bump runs on a maintainer's machine — see § Homebrew cask | — |
| ~~`WINGET_TOKEN`~~ | *Not used.* The winget bump runs on a maintainer's machine — see § Winget | — |
| `HOMEBREW_TOKEN` | PAT for `Homebrew/homebrew-cask` | Desktop cask bump (stable tags) |
| `WINGET_TOKEN` | PAT for `microsoft/winget-pkgs` | Desktop winget bump (stable tags) |
| `CROWDIN_PERSONAL_TOKEN`, `CROWDIN_PROJECT_ID` | Crowdin API creds | Translation sync (separate workflow, not the release) |
Note the **three distinct signing identities** people often conflate:
@@ -487,11 +390,11 @@ distributes; the official Amethyst rollout for each is in
| Channel | How it ships | Push or pull |
|---|---|---|
| **GitHub Releases** | The release workflow builds + signs all assets and attaches them to the tag's Release | Automatic (CI) |
| **Maven Central** | Same workflow runs `publishAllPublicationsToMavenCentral` for `quartz` — a *step* at the end of the `deploy-android` job, not a job of its own, so it does not appear in a job list | Automatic (CI) |
| **Maven Central** | Same workflow runs `publishAllPublicationsToMavenCentral` for `quartz` | Automatic (CI) |
| **Google Play** | Download the signed `amethyst-googleplay-<version>.aab` from the GH Release and upload it in Play Console | **Manual push** |
| **F-Droid** | F-Droid's build server detects the new tag and **builds the `fdroid` flavor from source** per its recipe in the external [`fdroiddata`](https://gitlab.com/fdroid/fdroiddata) repo, then signs + publishes itself | **Pull (build-from-source)** |
| **Zapstore** | The [`zsp`](https://zapstore.dev/) CLI reads [`zapstore.yaml`](zapstore.yaml) and publishes a Nostr software-release event signed with the app's nsec | **Manual push (Nostr)** |
| **Homebrew + Winget** | `bump-homebrew.yml` / `bump-winget.yml` open version-bump PRs on stable tags — **currently no-ops**: neither package has been bootstrapped upstream yet (§ Bootstrap) | Automatic (CI), inactive |
| **Homebrew + Winget** | `bump-homebrew.yml` / `bump-winget.yml` open version-bump PRs on stable tags | Automatic (CI) |
Two channels need the build to stay split into product flavors (see
`amethyst/build.gradle.kts` → `productFlavors`):
@@ -512,88 +415,20 @@ reads an optional per-release changelog from
## Bootstrap runbook (one-time)
> **Status as of v1.13.1: neither Homebrew nor Winget has been bootstrapped.**
> `https://formulae.brew.sh/api/cask/amethyst-nostr.json` and
> `microsoft/winget-pkgs/manifests/v/VitorPamplona/Amethyst` both 404, so
> **Amethyst does not currently ship through either channel.** The bump
> workflows detect this and skip with a `::warning::` instead of failing, so a
> green release run does *not* mean Homebrew/Winget shipped. The two subsections
> below are the work that activates them; until then treat the desktop app as
> GitHub-Releases-only on macOS and Windows.
### Package-manager credentials (and why there are none)
### Secrets to provision in GitHub repo settings
The full secret inventory is in [§ Secrets the CI needs](#secrets-the-ci-needs).
Neither package-manager channel adds anything to it:
The two that need the most setup care are the package-manager PATs, because of
their token type and scope:
**There are deliberately no package-manager PATs in CI.** Both the Homebrew
cask and the Winget manifest bumps run on a maintainer's machine. The reasoning
is worth keeping, because it is the reason this repo has no third secret to
rotate:
| Secret | Purpose | Scope |
|---|---|---|
| `HOMEBREW_TOKEN` | Bump Homebrew cask | Fine-grained PAT — `Homebrew/homebrew-cask` only — `Contents: write` + `Pull requests: write` — 90d expiry |
| `WINGET_TOKEN` | Submit Winget manifests | Classic PAT — `public_repo` — 90d expiry (dedicated bot account preferred; `vedantmgoyal9/winget-releaser` does not support fine-grained) |
`brew bump-cask-pr` forks `Homebrew/homebrew-cask` **into the token owner's
account** (`POST /repos/Homebrew/homebrew-cask/forks`), pushes a branch to that
fork, then opens the PR upstream. That shape forces a **classic** PAT with the
`repo` scope:
- A fine-grained PAT cannot express it. Its "Repository access" selector only
lists repos owned by the resource owner, so `Homebrew/homebrew-cask` can never
be selected — and Homebrew's API layer authorises against classic OAuth scopes
(`x-oauth-scopes`), which fine-grained tokens do not emit.
- Homebrew declares the requirement in source as
`CREATE_ISSUE_FORK_OR_PR_SCOPES = ["repo"]` (`utils/github.rb`).
And `repo` cannot be narrowed: it grants write to *every* repository the owning
account can reach — including `vitorpamplona/amethyst` itself. Stored as an
Actions secret it would be usable by **anyone with push access to this repo**,
since a pushed branch containing a workflow runs with repo secrets. That is a
strict escalation for a channel that ships one DMG a month.
So the split is:
- **CI** (`bump-homebrew.yml`, `GITHUB_TOKEN` only) does the error-prone
bookkeeping: downloads the DMG, asserts it is notarized + stapled, computes
the sha256, and opens an in-repo PR syncing
`desktopApp/packaging/homebrew/amethyst-nostr.rb`.
- **A maintainer** merges that PR and runs `scripts/bump-homebrew-cask.sh`,
which re-verifies the sha256 and the notarization ticket against the live
asset before calling `brew bump-cask-pr`.
The token then lives only in that maintainer's shell:
```bash
export HOMEBREW_GITHUB_API_TOKEN=ghp_... # classic PAT, `repo` scope
scripts/bump-homebrew-cask.sh v1.13.2
```
Create one at
<https://github.com/settings/tokens/new?scopes=repo&description=Homebrew%20cask%20bump>.
Prefer a dedicated bot account whose only asset is a fork of `homebrew-cask`, so
a leak reaches nothing else.
### Winget
Same split, and it needs **no token at all**. `scripts/bump-winget.sh` drives
`gh`, which a maintainer is already authenticated with, and it does not need
`wingetcreate` (Windows-only) because winget manifests are plain YAML — so it
runs fine from macOS or Linux:
```bash
scripts/bump-winget.sh v1.13.2
```
CI (`bump-winget.yml`, `GITHUB_TOKEN` only) does the bookkeeping: downloads the
MSI, computes the sha256, reads the `ProductCode` out of the MSI Property table
with `msitools`, and opens an in-repo PR syncing
`desktopApp/packaging/winget/*.yaml`. The script re-verifies the sha256 against
the live asset, then forks `microsoft/winget-pkgs`, commits the three manifests
to `manifests/v/VitorPamplona/Amethyst/<version>/`, and opens the PR.
The previous design stored a classic `public_repo` PAT as `WINGET_TOKEN` and
passed it to the third-party `vedantmgoyal9/winget-releaser` action — a token
with write access to every public repo the account owns, handed to code we do
not control, in a place any push-access collaborator could read it from. None of
that is needed.
Rotate both on a 90-day cadence. Owner: assigned via `RELEASE_OPS.md`
or equivalent issue tracker. On rotation, paste new token and run
`gh workflow run bump-homebrew.yml` on the most recent stable tag to verify.
### Homebrew cask (one-time initial PR)
@@ -711,25 +546,12 @@ State is shared across install channels (DMG, Homebrew, MSI, Winget, .deb,
expose downgrade migration risks — **prefer a single install channel per
machine**.
**Exception: Flatpak.** The sandbox redirects XDG dirs into
`~/.var/app/com.vitorpamplona.amethyst.Desktop/`, so a Flatpak install keeps
its own separate state and does not see (or risk downgrading) state written
by any other channel.
| OS | App location | State directories |
|---|---|---|
| macOS | `/Applications/Amethyst.app` | `~/.amethyst` (accounts + keys)<br>`~/Library/Application Support/Amethyst` (Tor)<br>`~/Library/Caches/AmethystDesktop` (image cache)<br>`~/Library/Preferences/com.apple.java.util.prefs.plist` (**shared** — see below) |
| macOS | `/Applications/Amethyst.app` | `~/Library/Application Support/Amethyst`<br>`~/Library/Preferences/com.vitorpamplona.amethyst.desktop.plist`<br>`~/Library/Caches/Amethyst` |
| Windows | `%LOCALAPPDATA%\Amethyst` or `C:\Program Files\Amethyst` | `%APPDATA%\Amethyst`<br>`%LOCALAPPDATA%\Amethyst` |
| Linux (deb/rpm) | `/opt/amethyst` | `~/.config/amethyst`<br>`~/.local/share/amethyst`<br>`~/.cache/amethyst` |
| Linux (AppImage/tar.gz) | user-chosen | Same as above |
| Linux (Flatpak) | `/var/lib/flatpak` or `~/.local/share/flatpak` | `~/.var/app/com.vitorpamplona.amethyst.Desktop/` |
**macOS preferences are in a SHARED file.** `DesktopPreferences` uses the Java
Preferences API, which on macOS writes into
`~/Library/Preferences/com.apple.java.util.prefs.plist` — one plist for *every*
Java application on the machine, not a per-app file. Never delete it to "reset
Amethyst": that wipes unrelated apps' settings. This is why the Homebrew cask's
`zap` stanza deliberately omits it.
Uninstall:
@@ -738,8 +560,6 @@ Uninstall:
- .deb: `sudo apt remove amethyst`
- .rpm: `sudo dnf remove amethyst`
- AppImage / tar.gz: delete the file / extracted directory
- Flatpak: `flatpak uninstall com.vitorpamplona.amethyst.Desktop` (add
`--delete-data` to also remove `~/.var/app/…`)
- macOS `.dmg`: drag from `/Applications` to Trash, then delete state dirs manually
---
+3 -7
View File
@@ -158,17 +158,13 @@ Google Play Services infrastructure.
### Layer 4: AlarmManager Watchdog (5 minutes)
**What:** `ServiceWatchdogManager` fires an `ELAPSED_REALTIME` alarm every 5
**What:** `ServiceWatchdogManager` fires an `ELAPSED_REALTIME_WAKEUP` alarm every 5
minutes. The receiver checks if the service should be running and restarts it.
**Why needed:** This is the "belt and suspenders" layer. If all of the above layers fail
(sticky restart blocked, alarm from `onTaskRemoved` didn't fire, broadcast wasn't
delivered), the watchdog will catch it within 5 minutes of the device being awake.
The alarm deliberately does NOT use the `_WAKEUP` variant: pulling the CPU out of
sleep every 5 minutes is a battery cost with no payoff, because a service restarted
on a sleeping device can't do useful network work until the device wakes anyway.
While the device sleeps, Layer 5 (WorkManager) and Layer 8 (FCM/UnifiedPush) cover
delivery; the moment the device wakes, the pending watchdog alarm fires.
delivered), the watchdog will catch it within 5 minutes. Uses `ELAPSED_REALTIME_WAKEUP` to
wake the device from sleep, ensuring the check happens even in Doze.
### Layer 5: WorkManager Periodic Catch-Up (15 minutes)
+6 -37
View File
@@ -17,7 +17,7 @@ Join the social network you control.
[![Maven Central](https://img.shields.io/maven-central/v/com.vitorpamplona.quartz/quartz?label=Quartz%20%28Maven%20Central%29&labelColor=27303D&color=0877d2)](https://central.sonatype.com/artifact/com.vitorpamplona.quartz/quartz)
[![JitPack snapshots](https://img.shields.io/badge/Quartz%20snapshots-JitPack-27303D?labelColor=27303D&color=0877d2)](https://jitpack.io/#vitorpamplona/amethyst)
[![CI](https://img.shields.io/github/actions/workflow/status/vitorpamplona/amethyst/build.yml?labelColor=27303D)](https://github.com/vitorpamplona/amethyst/actions/workflows/build.yml)
[![License: MIT](https://img.shields.io/github/license/vitorpamplona/amethyst?labelColor=27303D&color=0877d2)](/LICENSE)
[![License: Apache-2.0](https://img.shields.io/github/license/vitorpamplona/amethyst?labelColor=27303D&color=0877d2)](/LICENSE)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/vitorpamplona/amethyst)
## Download and Install
@@ -56,37 +56,6 @@ _Coming soon (separate PR): Scoop (Windows), AUR (Arch Linux)._
</div>
## Verifying the APK signature
If you sideload Amethyst (Obtainium, GitHub Releases, Zap Store), verify that your
APK is signed by the official release key before installing. All official Amethyst
APKs — both the `googleplay` and `fdroid` flavors — are signed with the same
certificate, whose SHA-256 fingerprint is:
```
C2:D0:AA:86:BC:B6:B6:20:90:56:1A:41:BB:E3:36:E9:8B:78:C2:D0:21:0A:49:8D:C8:85:F2:8E:13:48:CF:17
```
To check a downloaded APK yourself, run (`apksigner` ships with the Android SDK
build-tools):
```bash
apksigner verify --print-certs amethyst-*.apk
```
and confirm the reported `Signer #1 certificate SHA-256 digest` is
`c2d0aa86bcb6b62090561a41bbe336e98b78c2d0210a498dc885f28e1348cf17`.
Without the Android SDK, `keytool -printcert -jarfile amethyst-*.apk` (bundled
with any JDK) prints the same SHA-256 fingerprint.
With [AppVerifier](https://github.com/soupslurpr/appverifier), paste or share the
APK and compare against:
```
com.vitorpamplona.amethyst
C2:D0:AA:86:BC:B6:B6:20:90:56:1A:41:BB:E3:36:E9:8B:78:C2:D0:21:0A:49:8D:C8:85:F2:8E:13:48:CF:17
```
## Supported Features
<img align="right" src="./docs/screenshots/home.png" data-canonical-src="./docs/screenshots/home.png" width="350px">
@@ -328,16 +297,16 @@ repositories {
Add the following line to your `commonMain` dependencies:
```gradle
implementation('com.vitorpamplona.quartz:quartz:1.13.1')
implementation('com.vitorpamplona.quartz:quartz:1.12.6')
```
Variations to each platform are also available:
```gradle
implementation('com.vitorpamplona.quartz:quartz-android:1.13.1')
implementation('com.vitorpamplona.quartz:quartz-jvm:1.13.1')
implementation('com.vitorpamplona.quartz:quartz-iosarm64:1.13.1')
implementation('com.vitorpamplona.quartz:quartz-iossimulatorarm64:1.13.1')
implementation('com.vitorpamplona.quartz:quartz-android:1.12.6')
implementation('com.vitorpamplona.quartz:quartz-jvm:1.12.6')
implementation('com.vitorpamplona.quartz:quartz-iosarm64:1.12.6')
implementation('com.vitorpamplona.quartz:quartz-iossimulatorarm64:1.12.6')
```
Check versions on [MavenCentral](https://central.sonatype.com/search?q=com.vitorpamplona.quartz)
+23 -94
View File
@@ -14,7 +14,7 @@ specific to shipping the official Amethyst artifacts.
## At a glance
A release is one tag push that fans out to four live distribution channels:
A release is one tag push that fans out to five distribution channels:
| Channel | Mechanism | Who pushes |
|---|---|---|
@@ -22,11 +22,10 @@ A release is one tag push that fans out to four live distribution channels:
| **Google Play** | **Manual** — download the signed AAB from the GH Release, upload in Play Console | Maintainer |
| **F-Droid** | **Pull** — F-Droid's build server builds the `fdroid` flavor from source when it sees the new tag | F-Droid (we just maintain the recipe + metadata) |
| **Zapstore** | `zsp publish` reads `zapstore.yaml`, signs a Nostr release event with Amethyst's nsec | Maintainer |
| **Homebrew + Winget** | ⚠️ **Not shipping.** The bump workflows run, but skip: neither package exists upstream yet | Nobody (see § 3) |
| **Homebrew + Winget** | Automatic — `bump-homebrew.yml` / `bump-winget.yml` fire on stable tags | CI |
Maven Central (the `quartz` library) also publishes automatically from the same
workflow — as a *step* at the end of the `deploy-android` job, not a job of its
own, so don't expect to find it in the run's job list.
workflow.
---
@@ -62,10 +61,8 @@ own, so don't expect to find it in the run's job list.
`scripts/translators.sh --seed` with `CROWDIN_PROJECT_ID` /
`CROWDIN_PERSONAL_TOKEN` set, which refreshes the file.)
3. **Publish the release-notes note on Nostr** — *minor releases only.* In
practice this id has only ever been bumped on `x.y.0` (1.11.0, 1.12.0,
1.13.0); patch releases leave it pointing at their minor's note. Publish with
Amethyst's account and paste the event id into `amethyst/build.gradle.kts`:
3. **Publish the release-notes note on Nostr** with Amethyst's account and paste
its event id into `amethyst/build.gradle.kts`:
```kotlin
buildConfigField("String", "RELEASE_NOTES_ID", "\"<new-event-id-hex>\"")
```
@@ -88,33 +85,17 @@ own, so don't expect to find it in the run's job list.
Commit, tag, push — see [`BUILDING.md` § Release runbook](BUILDING.md#release-runbook)
for the exact commands. The tag must equal `app` from the catalog (the workflow
asserts this and fails fast otherwise). A clean `vMAJOR.MINOR.PATCH` tag is
classified **stable** and runs the Homebrew/Winget bump workflows; anything with
a `-rc`/`-beta`/`-alpha`/`-dev` suffix is a prerelease and skips them.
Heads-up on the `git push`: this repo has `git-credential-manager` configured as
a credential helper, and it blocks on an interactive prompt (a plain
`GIT_TERMINAL_PROMPT=0` does **not** stop it — the push just hangs). If that
happens, push using `gh`'s helper for the one command:
```bash
git -c credential.helper= -c credential.helper='!gh auth git-credential' push upstream main
```
classified **stable** and triggers the Homebrew/Winget bumps; anything with a
`-rc`/`-beta`/`-alpha`/`-dev` suffix is a prerelease and skips them.
When the `Create Release Assets` workflow finishes (~2530 min) the GH Release
holds **31 assets**, per the asset-name contract:
holds, per the asset-name contract:
- **Android (13):** 5 Google Play APKs + 5 F-Droid APKs + 2 AABs + the F-Droid
`.apks` set for Accrescent
(`amethyst-googleplay-*-v…apk` / `.aab`, `amethyst-fdroid-*-v…apk` / `.aab` / `.apks`)
- **Desktop (8):** DMG (macOS **arm64 only** — there is no Intel DMG),
MSI + zip, DEB, RPM, AppImage, flatpak, tar.gz
- **CLI (5):** the `amy` artifacts
- **Relay (5):** the `geode` artifacts, plus the geode Docker image
- **Maven Central:** `com.vitorpamplona.quartz:quartz:<version>` published.
`repo1.maven.org` lags the publish by tens of minutes — a 404 right after the
run is normal. Confirm the step's log says "Deployment is being published to
Maven Central", and compare against the *previous* version's POM before
concluding anything is broken.
- **Android:** 5 Google Play APKs + 5 F-Droid APKs + 2 AABs
(`amethyst-googleplay-*-v…apk` / `.aab`, `amethyst-fdroid-*-v…apk` / `.aab`)
- **Desktop:** 8 assets (DMG/MSI/DEB/RPM/AppImage/zip/tar.gz)
- **CLI:** the `amy` artifacts
- **Maven Central:** `com.vitorpamplona.quartz:quartz:<version>` published
---
@@ -184,53 +165,11 @@ RELAY_URLS="wss://relay.zapstore.dev,wss://relay.damus.io,wss://nos.lol,wss://vi
Keep `wss://relay.zapstore.dev` in the list — that is the relay the Zapstore app
itself reads from.
### Homebrew + Winget — ⚠️ not shipping yet
`bump-homebrew.yml` and `bump-winget.yml` are wired to open PRs against
`Homebrew/homebrew-cask` (cask `amethyst-nostr`) and `microsoft/winget-pkgs`
(`VitorPamplona.Amethyst`) — but **neither package has ever been submitted
upstream**, so both workflows detect that and skip with a `::warning::`. As of
**v1.13.1** these two channels deliver nothing; macOS and Windows users get the
desktop app from GitHub Releases only.
Two separate faults kept this invisible until v1.13.1, both now fixed:
1. **The workflows never ran at all** — for *any* release. They triggered on
`release: types: [released]`, and GitHub does not raise workflow-triggering
events for a release created by `GITHUB_TOKEN`, which is exactly how
`create-release.yml` creates it. They now trigger on `workflow_run` after
`Create Release Assets` succeeds, which also fixes a latent race — the old
event fired while assets were still uploading.
2. **Nothing exists upstream to bump.** `brew bump-cask-pr` and
`winget-releaser` can only *update* an existing package. The first
submission is a manual, human-reviewed PR: BUILDING.md § Homebrew cask
(one-time initial PR) and § Winget (one-time initial submission).
Until someone does that bootstrap, a green release run means the bump workflows
*skipped cleanly* — not that Homebrew/Winget shipped. Check the run's warnings
if you want to confirm which case you're in.
**Both bumps are half-manual by design.** CI does the bookkeeping with
`GITHUB_TOKEN` only — verifying the artifact, computing hashes, and opening an
in-repo PR syncing the reference packaging files. Pushing upstream needs
credentials that would be dangerous as CI secrets (a `repo`-scoped PAT is
readable by anyone with push access here), so a maintainer runs the last step:
```bash
# after merging the sync PRs
export HOMEBREW_GITHUB_API_TOKEN=ghp_... # classic PAT, `repo` scope
scripts/bump-homebrew-cask.sh v1.13.2
scripts/bump-winget.sh v1.13.2 # no token — uses your `gh` auth
```
Both scripts re-verify the published artifact's sha256 before submitting, and
the Homebrew one additionally refuses if the DMG is not notarized + stapled.
See BUILDING.md § Package-manager credentials.
All four in-repo sync workflows (`amy` formula, `geode` formula,
`amethyst-nostr` cask, winget manifests) open PRs against *this* repo on every
release. Merge them to keep the reference packaging files current.
### Homebrew + Winget — automatic
`bump-homebrew.yml` and `bump-winget.yml` fire on stable tags and open PRs
against `Homebrew/homebrew-cask` (cask `amethyst-nostr`) and
`microsoft/winget-pkgs` (`VitorPamplona.Amethyst`). No action unless one fails —
then see BUILDING.md § Bootstrap and § Incident response.
---
@@ -272,7 +211,7 @@ ownership:
| `SIGNING_KEY`, `KEY_ALIAS`, `KEY_STORE_PASSWORD`, `KEY_PASSWORD` | The **Android upload keystore** — losing/leaking it is the worst case; Play app signing identity | Keep the keystore backed up offline; never rotate casually (Play upload key reset is a support process) |
| `SONATYPE_USERNAME`, `SONATYPE_PASSWORD` | Maven Central namespace `com.vitorpamplona` | On compromise |
| `SIGNING_PRIVATE_KEY`, `SIGNING_PASSWORD` | The **GPG key** signing Maven artifacts | Per GPG key expiry |
| *(none for Homebrew/Winget)* | — | Both bumps run on a maintainer's machine — `scripts/bump-homebrew-cask.sh` and `scripts/bump-winget.sh` — so neither channel's PAT ever becomes a CI secret. See BUILDING.md § Package-manager credentials |
| `HOMEBREW_TOKEN`, `WINGET_TOKEN` | Cask + winget bump PRs | **90-day cadence** (see BUILDING.md § Bootstrap) |
| `CROWDIN_PERSONAL_TOKEN`, `CROWDIN_PROJECT_ID` | Translation sync | On compromise |
Owner assignments and rotation reminders live with the team (issue tracker).
@@ -283,23 +222,13 @@ Owner assignments and rotation reminders live with the team (issue tracker).
## 6. Post-release verification
- [ ] GH Release: 31 assets, sizes sane, and the asset-name set matches the
previous release (see the `diff` one-liner in BUILDING.md § Release
runbook). macOS is arm64-only — do **not** look for an Intel DMG.
- [ ] Maven Central: `quartz:<version>` resolves (allow tens of minutes of
propagation; the publish step's log is the authoritative signal).
- [ ] GH Release: expected asset count, Intel + ARM DMGs, sizes sane.
- [ ] Maven Central: `quartz:<version>` resolves (allow propagation time).
- [ ] Play Console: rollout started, no policy rejection.
- [ ] Zapstore: release event visible.
- [ ] F-Droid: new version detected (may lag days).
- [ ] Four sync PRs opened against this repo (`amy` formula, `geode` formula,
`amethyst-nostr` cask, winget manifests) — merge them.
- [ ] Cask pushed upstream: `scripts/bump-homebrew-cask.sh vX.Y.Z` (manual, needs
`HOMEBREW_GITHUB_API_TOKEN` in your shell).
- [ ] Winget pushed upstream: `scripts/bump-winget.sh vX.Y.Z` (manual, no token —
uses your `gh` auth).
Both scripts error clearly until the one-time bootstrap PRs land (§ 3).
- [ ] In-app "Release Notes" link opens the note matching `RELEASE_NOTES_ID`
(only bumped on minor releases — patches keep pointing at the x.y.0 note).
- [ ] Homebrew + Winget bump PRs opened (stable only).
- [ ] In-app "Release Notes" link opens the note matching `RELEASE_NOTES_ID`.
- [ ] Push still works on a `play` build (only if the push contract changed —
see § 4); UnifiedPush still works on an `fdroid` build.
+43 -43
View File
@@ -24,9 +24,7 @@ Build customized Amethyst Nostr clients for Android. Fork, rebrand, customize, a
2. **Android SDK**
- Command-line tools from https://developer.android.com/studio#command-line-tools-only
- Required components: build-tools, platform-tools, platforms;android-37
- The exact SDK level is `android-compileSdk` in `gradle/libs.versions.toml` —
check there if this number has drifted.
- Required components: build-tools, platform-tools, platforms;android-35
3. **Git** for cloning the repository
@@ -68,54 +66,48 @@ keyPassword=your-password
### 3. Configure Signing
Add to `amethyst/build.gradle.kts` inside the `android {}` block:
Add to `amethyst/build.gradle` inside the `android {}` block:
```kotlin
val keystorePropertiesFile = rootProject.file("keystore.properties")
val keystoreProperties = Properties()
```gradle
def keystorePropertiesFile = rootProject.file("keystore.properties")
def keystoreProperties = new Properties()
if (keystorePropertiesFile.exists()) {
keystorePropertiesFile.inputStream().use { keystoreProperties.load(it) }
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
}
signingConfigs {
create("release") {
release {
if (keystorePropertiesFile.exists()) {
storeFile = rootProject.file(keystoreProperties["storeFile"] as String)
storePassword = keystoreProperties["storePassword"] as String
keyAlias = keystoreProperties["keyAlias"] as String
keyPassword = keystoreProperties["keyPassword"] as String
storeFile rootProject.file(keystoreProperties['storeFile'])
storePassword keystoreProperties['storePassword']
keyAlias keystoreProperties['keyAlias']
keyPassword keystoreProperties['keyPassword']
}
}
}
```
This needs `import java.util.Properties` at the top of the file.
Update the release buildType to use the signing config:
```kotlin
```gradle
buildTypes {
getByName("release") {
signingConfig = signingConfigs.getByName("release")
release {
signingConfig signingConfigs.release
// ... existing config
}
}
```
Verify with `./gradlew :amethyst:signingReport` — the release variants should
report your keystore rather than `~/.android/debug.keystore`.
### 4. Disable Google Services (Required for F-Droid)
**⚠️ CRITICAL:** The Google Services plugin fails when you change the package name. For F-Droid builds, disable it.
Edit `amethyst/build.gradle.kts`, comment out the plugin:
```kotlin
Edit `amethyst/build.gradle`, comment out the plugin:
```gradle
plugins {
alias(libs.plugins.androidApplication)
alias(libs.plugins.jetbrainsKotlinAndroid)
// alias(libs.plugins.googleServices) // DISABLED for F-Droid
alias(libs.plugins.jetbrainsComposeCompiler)
alias(libs.plugins.serialization)
alias(libs.plugins.googleKsp)
}
```
@@ -149,8 +141,8 @@ Edit `amethyst/src/main/res/values/strings.xml`:
### Change Package ID
Edit `amethyst/build.gradle.kts`:
```kotlin
Edit `amethyst/build.gradle`:
```gradle
android {
defaultConfig {
applicationId = "com.yourcompany.yourapp"
@@ -160,8 +152,8 @@ android {
### Change Project Name
Edit `settings.gradle.kts`:
```kotlin
Edit `settings.gradle`:
```gradle
rootProject.name = "YourAppName"
```
@@ -175,28 +167,36 @@ Replace icon files in:
Make your app identify itself on posts with `["client", "YourAppName"]`.
You do **not** need to add the tag per event type. The client tag is applied
centrally by `NostrSignerWithClientTag`, a signer decorator that appends the tag
to everything it signs (and respects the user's "add client tag" privacy
setting). Changing the name is a one-constant edit:
**1. Create tag builder extension:**
Edit `amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt`:
Create `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/clientTag/TagArrayBuilderExt.kt`:
```kotlin
const val CLIENT_TAG_NAME = "YourAppName"
package com.vitorpamplona.quartz.nip01Core.tags.clientTag
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
fun <T : Event> TagArrayBuilder<T>.client(clientName: String) =
addUnique(arrayOf(ClientTag.TAG_NAME, clientName))
```
That constant is passed to `NostrSignerWithClientTag` when the account's signer
is built, so every signed event carries your name.
**2. Add to TextNoteEvent:**
The tag itself lives in
`quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/`
(`ClientTag`, `TagArrayBuilderExt`, `NostrSignerWithClientTag`) — you only need to
touch it if you want the optional NIP-89 handler address / relay hint variants.
Edit `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/TextNoteEvent.kt`:
Add import:
```kotlin
import com.vitorpamplona.quartz.nip01Core.tags.clientTag.client
```
In both `build()` functions, add after `alt(...)`:
```kotlin
client("YourAppName")
```
### Modify Default Relays
Edit `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/defaults/Constants.kt`
(see also `AmethystDefaults.kt` and `DefaultDmIndexerRelays.kt` in the same folder).
Edit relay configuration in `quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/` or the UI settings files.
## Troubleshooting
+3 -37
View File
@@ -1,7 +1,4 @@
import org.gradle.api.services.BuildService
import org.gradle.api.services.BuildServiceParameters
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins {
alias(libs.plugins.androidApplication)
@@ -84,16 +81,14 @@ android {
.get()
.toInt()
versionName = generateVersionName(libs.versions.app.get(), rootDir)
buildConfigField("String", "RELEASE_NOTES_ID", "\"f54843af6397f78e39fa75dbe3b7f7de14eb18c4f9c56e60e7825a2c6715719b\"")
buildConfigField("String", "RELEASE_NOTES_ID", "\"40e817712e397c07ba31784a92fa474aa095896a828c0e2dea0d09c60d49ee1e\"")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary = true
}
}
androidResources {
localeFilters +=
@Suppress("UnstableApiUsage")
resourceConfigurations +=
listOf(
"ar",
"ar-rSA",
@@ -345,30 +340,6 @@ kotlin {
}
}
// Gradle schedules Kotlin compilations of different variants of this module
// concurrently (e.g. playDebug + playBenchmark when CI runs unit tests, lint,
// and assembleBenchmark in one invocation), but they all share a single Kotlin
// daemon whose heap (kotlin.daemon.jvmargs) cannot fit two full :amethyst
// codegen passes — CI runs died with "GC overhead limit exceeded" inside the
// daemon. This no-op shared build service with maxParallelUsages = 1 tells the
// scheduler to run this module's Kotlin compile tasks one at a time; other
// projects' tasks (JVM tests, lint analysis, packaging) still run in parallel.
//
// CI-only: the OOM needs a cache-cold compile of several variants at once,
// which local builds (incremental, usually one variant) don't produce.
abstract class AmethystKotlinCompileLimiter : BuildService<BuildServiceParameters.None>
if (System.getenv("CI") != null) {
val kotlinCompileLimiter =
gradle.sharedServices.registerIfAbsent("amethystKotlinCompileLimiter", AmethystKotlinCompileLimiter::class) {
maxParallelUsages.set(1)
}
tasks.withType<KotlinCompile>().configureEach {
usesService(kotlinCompileLimiter)
}
}
composeCompiler {
reportsDestination = layout.buildDirectory.dir("compose_compiler")
metricsDestination = layout.buildDirectory.dir("compose_compiler")
@@ -391,10 +362,6 @@ dependencies {
implementation(project(":commons"))
implementation(project(":nestsClient"))
implementation(project(":nappletHost"))
// Compose Multiplatform resources runtime, so app-side screens that share a
// string with a commons renderer can read commons' generated `Res` directly
// instead of duplicating the key in the Android res tree.
implementation(libs.jetbrains.compose.components.resources)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.activity.compose)
@@ -567,7 +534,6 @@ dependencies {
testImplementation(libs.junit)
testImplementation(libs.mockk)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.secp256k1.kmp.jni.jvm)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.junit)
@@ -1,385 +0,0 @@
# Contextual AUTH Permissions — Ask *why*, and trust follows
**Date:** 2026-07-01
**Module:** `amethyst` (+ shared bits in `commons`)
**Status:** Implemented — see "As-built" below for where the shipped design diverged from this proposal.
## As-built (final)
The implementation kept this doc's core ideas (purpose derivation, a prompt bus,
per-relay overrides, grant rationale) but the policy model was reshaped during
review:
- **Global mode is `RelayAuthPolicy { ALWAYS, NEVER, CUSTOM }`** — the earlier
`IF_IN_MY_LIST` / `TRUSTED_FOLLOWS` values were dropped. `CUSTOM` applies a
`RelayAuthCustomToggles` set of independent switches: **my relays & venues**,
**read posts from follows**, **message follows**, **message strangers**
(off by default). New-install default is `CUSTOM` with the first three on.
- **`AuthPurpose` is a `data class` (kind + counterparties + venues)** over an
`AuthPurposeKind` enum (SEND_DM, NOTIFY_INBOX, READ_OUTBOX, POST_VENUE,
READ_VENUE, MY_OWN_RELAY, OTHER) — not a sealed interface. Venues (NIP-28
public chats, NIP-72 communities, NIP-53 live activities) are first-class.
- **Settings screen** uses the app's settings design system (`SettingsSection`
card + `SettingsSwitchTile`) for the toggles and a grouped, lazily-rendered
per-relay list (NIP-11 icon, `displayUrl`, tap → relay info).
- **Give-up signal**: quartz's outbox surfaces `onEventGaveUp`, toasted by
`RelayPublishFailureToast`. `auth-required` NAKs never burn the retry budget
(they reset it) so a slow AUTH handshake can't drop the event.
- **Known limitation**: an event queued to a relay the user then *denies* stays
pending in the outbox (auth-required never gives up); evicting it would need a
quartz "give up on relay for this event" API — deferred.
## Context
Now that Amethyst answers NIP-42 relay AUTH challenges, we need to decide
*when* to reveal the user's identity to a relay — and, crucially, to tell the
user **why** an auth is being requested so they can make an informed choice.
The motivating cases:
- **NIP-17 DM send.** The recipient's DM inbox relays (kind 10050) may require
auth. If we silently refuse, the message never leaves the device and the user
has no idea why. We should ask: *"Relay X wants you to log in to deliver your
private message to Alice — allow?"*
- **Public inbox notifications.** Replying to / mentioning / reacting to someone
publishes to *their* NIP-65 inbox (kind 10002 read relays), which may require
auth.
- **Feed download from outboxes.** Reading a followed author's posts may require
auth to *their* write/outbox relays.
We also want an **automatic mode** for users who trust Amethyst's judgement:
auth (or not) based on a follow-graph heuristic — *if I follow the counterparty
(in any follow list), I trust them enough to reveal my identity to the relay
that serves them.* And regardless of mode, **explicit per-relay overrides** must
be able to force-allow or force-block a single relay. The blocked-relay list
(kind 10006) is a hard block.
## What already exists (reuse — do NOT rebuild)
The NIP-42 plumbing and a first-cut permission gate are already in place:
| Piece | Location |
|---|---|
| AUTH challenge receipt, kind-22242 signing, resend-on-OK | `quartz/.../nip01Core/relay/client/auth/RelayAuthenticator.kt`, `RelayAuthStatus.kt`, `nip42RelayAuth/RelayAuthEvent.kt` |
| Permission gate (per logged-in account) | `amethyst/.../service/relayClient/authCommand/model/AuthCoordinator.kt` |
| Decision engine (per-relay override → global policy) | `.../authCommand/model/RelayAuthPermissionLedger.kt` |
| Policy enum `ALWAYS`/`NEVER`/`IF_IN_MY_LIST`, decision enum `ALLOW`/`DENY` | `commons/.../relayauth/RelayAuthPolicy.kt` |
| Per-relay override persistence interface + DataStore impl | `commons/.../relayauth/RelayAuthPermissionStore.kt`, `amethyst/.../authCommand/model/DataStoreRelayAuthPermissionStore.kt` |
| Settings screen (global policy + per-relay list) | `amethyst/.../ui/screen/loggedIn/relayauth/RelayAuthSettingsScreen.kt` |
| Global policy setting, persisted local-only | `AccountSettings.defaultRelayAuthPolicy`, `LocalPreferences` key `DEFAULT_RELAY_AUTH_POLICY` |
| Blocked-relay list (kind 10006) | `amethyst/.../model/nip51Lists/blockedRelays/BlockedRelayListState.kt` (`.flow`) |
| Follow checks | `Account.isFollowing(...)`, `Account.allFollows.flow.value.authors`, `FollowListsState.isUserInFollowSets(...)` |
| DM / NIP-65 relay lookups | `DmRelayListState`, `Nip65RelayListState` (+ per-user via `LocalCache`) |
## The gap
`RelayAuthPermissionLedger.decide(relayUrl)` receives **only a relay URL**. It
resolves ALLOW/DENY **silently and immediately**. Three things are missing:
1. **No purpose/"why".** The decision point can't tell a DM-send from a
feed-read from a stranger's random challenge, so it can't explain itself or
attribute the relay to a counterparty.
2. **No interactive ASK.** `RelayAuthDecision` is binary. A DENY silently drops
the auth (and the send fails with no feedback).
3. **No follow-based trust.** `IF_IN_MY_LIST` only checks *my own* relays, never
"this relay belongs to someone I follow."
## Recommended architecture
Four changes, smallest surface first.
### 1. Carry the *purpose* to the decision point — `AuthPurpose` + an intent registry
New (in `commons/.../relayauth/`, KMP-safe, no Android deps):
```kotlin
sealed interface AuthPurpose {
data class SendDM(val recipients: Set<HexKey>) : AuthPurpose // recipient DM inboxes (10050)
data class NotifyInbox(val recipients: Set<HexKey>) : AuthPurpose // recipient NIP-65 read relays
data class ReadOutbox(val author: HexKey?) : AuthPurpose // author write/outbox relays
data object MyOwnRelay : AuthPurpose // relay in my own lists
data object Unknown : AuthPurpose // bare challenge, no attribution
}
```
The auth path is **reactive** (relay pushes the challenge; the lambda only knows
the URL). Most intent is already recoverable from quartz's per-relay pending
events + active filters (see "Where it lives" below), so the registry below is
**minimal** — only for hints quartz can't infer (e.g. the human recipient behind
an encrypted gift wrap). It lives with the coordinator, since `LocalCache`/
`Account` are main-process only:
```kotlin
// amethyst/.../service/relayClient/authCommand/model/RelayAuthIntentRegistry.kt
class RelayAuthIntentRegistry {
fun register(relay: NormalizedRelayUrl, purpose: AuthPurpose) // short TTL entry
fun purposesFor(relay: NormalizedRelayUrl): List<AuthPurpose> // read at decision time
}
```
Representative registration sites (each already computes its target relays):
- NIP-17 DM send → `SendDM(recipients)` on each recipient DM-inbox relay.
- Reply/mention/reaction broadcast → `NotifyInbox(recipients)`.
- Outbox feed subscriptions → `ReadOutbox(author)`.
*Race note:* keying by relay URL means concurrent purposes can collide; store a
small time-bounded **set** per relay and let the resolver consider all live
entries (the prompt can say "to send your DM to Alice and 2 others"). Acceptable
for a UX hint + trust check; the persisted decision is what actually gates.
### 1b. Persist *why* each relay was granted (grant rationale)
The decision stays **relay-based**, but each relay's stored record must also
remember **why** it was granted, so the settings screen can show, per relay,
purpose-grouped lines of counterparty users (with avatars):
> **wss://inbox.example.com** — Allowed
> · To send DMs to: (avatars) Alice, Bob, Carol
> · To download posts from: (avatars) Dave, Erin
Extend the persisted per-relay record from a bare `RelayAuthDecision` to
`decision + rationale`, where the rationale is an accumulated map keyed by
purpose kind:
```kotlin
// commons/.../relayauth/RelayAuthGrant.kt (new)
data class RelayAuthGrant(
val decision: RelayAuthDecision,
// purpose kind -> counterparty pubkeys seen for this relay under that purpose
val rationale: Map<AuthPurposeKind, Set<HexKey>> = emptyMap(),
val lastUsedAt: Long = 0L,
)
enum class AuthPurposeKind { SEND_DM, NOTIFY_INBOX, READ_OUTBOX, MY_OWN_RELAY }
```
The rationale is **updated every time** an auth is granted/re-used for that
relay: merge the current `AuthPurpose` counterparties into the matching kind's
set and refresh `lastUsedAt`. This keeps the "why" current as new
DMs/notifications/feeds route through the relay. Store only pubkeys — names and
avatars are resolved for display from `LocalCache` at render time, so the store
stays privacy-light and small.
### 2. Add an `ASK` outcome and a context-aware resolver
Extend the decision enum and generalize `decide()`:
```kotlin
enum class RelayAuthDecision { ALLOW, DENY, ASK } // ASK added
class RelayAuthContext(val relayUrl: String, val purposes: List<AuthPurpose>)
```
`RelayAuthPermissionLedger.decide(ctx)` precedence (highest → lowest):
1. **Blocked-relay list** (kind 10006) → `DENY`. Never reveal identity to a
blocked relay, whatever the policy.
2. **Explicit per-relay override** (`RelayAuthPermissionStore`) → return it.
3. **Global policy**:
- `NEVER``DENY`
- `ALWAYS``ALLOW`
- `IF_IN_MY_LIST``ALLOW` if relay ∈ my relay lists, else fall through
- `TRUSTED_FOLLOWS` *(new — see idea A below)*`ALLOW` if relay ∈ my lists
**or** any counterparty in `ctx.purposes` is followed (`Account.allFollows`
/ `FollowListsState.isUserInFollowSets`) and the purpose permits it; else
fall through.
4. **Fall-through**: `ASK` if the purpose is attributable (we can show a reason);
otherwise `DENY` silently (don't prompt for anonymous stranger challenges).
Keep the current relay-only `decide(url)` as a thin overload calling
`decide(RelayAuthContext(url, registry.purposesFor(url)))` so existing callers
compile.
Whenever the resolver yields `ALLOW` and an auth is actually sent — regardless
of *how* it was allowed (auto policy, stored override, or a just-approved ASK) —
call `store.recordUse(relayUrl, purpose)` for each attributed purpose so the
grant rationale (§1b) stays current.
### 3. Surface the ASK prompt to the UI and await the answer
The `signWithAllLoggedInUsers` lambda in `AuthCoordinator` is **already a
`suspend` context**, so the resolver can suspend and await a user decision — no
restructuring of the auth send path.
- Add an event stream on the coordinator (or account):
`SharedFlow<RelayAuthRequest>` where
`RelayAuthRequest(relay, purposes, reply: CompletableDeferred<UserAuthChoice>)`.
(Follows the repo's one-shot-event flow pattern — see `kotlin-flow-state-event-modeling`.)
- A composable observer (registered in the logged-in scaffold) collects the flow
and shows a dialog: *"{relay} requires you to log in to {reason}."* with
actions **Allow once / Always allow this relay / Block this relay**. The last
two write through `RelayAuthPermissionLedger.setDecision(...)`.
- The lambda `await`s the deferred (bounded by a timeout consistent with
`RelayAuthStatus`), then proceeds to sign or returns `emptyList()`.
Reason strings are derived from `AuthPurpose` via a small mapper (resolve
recipient pubkeys → display names through `LocalCache`).
### 4. New policy mode + settings
- Add `TRUSTED_FOLLOWS` to `RelayAuthPolicy` (recommended — idea A).
- `RelayAuthSettingsScreen`: add the new mode with an explanatory blurb; the
per-relay override list already supports force-allow/force-block (now
three-state incl. "ask"). No storage-format change if we keep decisions
per-relay (idea B, recommended default).
## Where it lives: quartz (generic mechanism) vs amethyst (policy + UI)
Goal (per the brief): if the auth+resend mechanism can be made **robust and
generic**, it belongs in **quartz**; only the *semantics* (why / follow-trust /
prompt copy / rationale UI) stay in **amethyst**.
### The resend queue already exists in quartz — and is the "intent registry"
`PoolEventOutbox` / `PoolEventOutboxState` already persist outgoing events
per-relay across reconnects, and `NostrClient.syncFilters(relay)` — called on
connect **and after an auth OK** (`RelayAuthenticator.checkAuthResults`) —
already re-sends pending EVENTs, not just REQ subscriptions. So the park-and-
flush half of idea C is largely built; we just need to make it correct.
It also means we mostly **don't need a separate `RelayAuthIntentRegistry`**:
quartz already knows, per relay, the *pending outgoing events*
(`PoolEventOutbox`) and the *active subscription filters* (`activeRequests`).
That set IS the intent. At AUTH time quartz can hand the injected decision
callback this context; amethyst derives purpose from it (a pending kind-1059
gift wrap → `SendDM`; a REQ whose `authors` are followed → `ReadOutbox`). Keep a
tiny registry only for hints quartz can't infer (e.g. the human recipient behind
a gift wrap, which is encrypted) — but drive the common cases off quartz state.
### Generic fixes to land in quartz (`nip01Core/relay/client/`)
1. **Treat `auth-required` as a first-class deferred state, not a burned retry.**
*(Landed — commit 2.)* The resend-after-auth path already works:
`syncFilters` on the auth `OK` re-sends every still-pending EVENT, so the
common single-round case (send → `auth-required` → auth → resend → accepted)
already delivered. The narrow bug: `PoolEventOutboxState.newResponse` sent
`auth-required` down the generic-failure path, so each NAK consumed the
per-relay retry budget (`isDone() = responses.size > 2 || tries.size > 3`).
Budget exhaustion isn't checked on the NAK itself but on the **next
`newTry`** — i.e. the resend `syncFilters` issues after the auth `OK`. So
across *repeated* rounds (slow external NIP-55 signer, reconnect churn, or a
relay that re-challenges) the saved event could be **evicted right as it was
about to be redelivered**. Fix: `auth-required` records no failure and leaves
`relaysRemaining` untouched (mirrors `StandaloneRelayClient`'s
`!msg.message.startsWith("auth-required")`), so the existing resend can
redeliver no matter how many auth rounds elapse first.
2. **Real retry policy instead of a hard count.** Replace the `>2 / >3` cliff
with bounded retries + backoff, and a **terminal "gave up" notification**
(via `RelayConnectionListener` / a publish-result callback) so events are
never *silently* dropped. `NostrClientPublishExt.publishAndConfirmDetailed`
and `pendingPublishRelaysFor` already give higher layers a confirmation
surface to build on.
3. **Enrich the injected auth-decision callback with pending context.** The
`signWithAllLoggedInUsers = (relayUrl, authTemplate) -> …` hook in
`RelayAuthenticator` currently gets only the URL. Pass a generic
`RelayAuthChallengeContext` carrying the relay's pending events + active
filters, and let it return not just "sign or not" but an outcome that can
**suspend for a host decision**. The `AuthPurpose`/`RelayAuthContext` types
move to a quartz-neutral shape (opaque to quartz); amethyst supplies the
resolver.
4. **Expose an "event is blocked on auth for relay X" signal** so a host UI can
show the prompt and reflect "queued, not lost." A `SharedFlow`/listener on the
client, host-agnostic.
### What stays in amethyst
The *policy and meaning*: blocked-list + follow-graph resolver, purpose/
counterparty derivation (needs `LocalCache`/`Account`, main-process only), the
`TRUSTED_FOLLOWS` mode, the ASK prompt UI, and the per-relay **grant rationale**
persistence + settings rows (§1b). These depend on identity/UI and cannot live
in quartz.
## A few ideas / open decisions
These are the knobs where more than one answer is defensible. Recommendation
first.
- **A. Follow-based trust shape.** *(Recommended: new `TRUSTED_FOLLOWS` policy
mode.)* Cleanest extension of the existing enum + settings radio group.
Alternatives: a separate independent "trust relays of people I follow" toggle
that layers on any base mode (more flexible, more UI); or never-automatic —
follow-status only pre-selects the "remember" button in the ASK dialog (most
conservative).
- **B. Decision memory granularity.** *(Decided: per-relay — the decision gate
is one ALLOW/DENY per relay.)* We keep the gate relay-based but enrich the
stored record with the grant rationale (§1b) so the settings screen can
explain each relay. Rejected alternative: making the *gate itself*
per-purpose × per-relay (allow relay X for DMs but keep asking for feed reads)
— richer but more confusing; the rationale display gives the transparency
without splitting the gate.
- **C. In-flight send when auth isn't yet granted.** *(Recommended: fix
quartz's existing outbox so park-and-flush is the default.)* The queue already
exists (`PoolEventOutbox` + `syncFilters`-after-auth); the work is making
`auth-required` a deferred state (not a burned retry) and adding backoff + a
terminal give-up signal — see the quartz section above. This is strictly
better than the amethyst-only best-effort/retry fallback and is generic, so it
belongs in quartz. Best-effort remains the trivial fallback only if we choose
not to touch quartz.
- **D. Which purposes auto-trust covers.** DMs and public inbox notifications are
clear yes. Outbox/feed reads ("maybe" in the brief) could be a sub-toggle
under `TRUSTED_FOLLOWS` so reading is treated more liberally than writing.
## Files to touch
- `commons/.../relayauth/RelayAuthPolicy.kt` — add `TRUSTED_FOLLOWS`, add `ASK`.
- `commons/.../relayauth/AuthPurpose.kt`**new** sealed hierarchy + `RelayAuthContext` + `AuthPurposeKind`.
- `commons/.../relayauth/RelayAuthGrant.kt`**new** per-relay record (decision + rationale, §1b).
- `commons/.../relayauth/RelayAuthPermissionStore.kt` + `amethyst/.../DataStoreRelayAuthPermissionStore.kt`
— store/load `RelayAuthGrant` (decision + rationale) instead of a bare decision; add a
`recordUse(relayUrl, purpose)` merge that updates the rationale + `lastUsedAt`.
- `amethyst/.../authCommand/model/RelayAuthPermissionLedger.kt` — context-aware
`decide(ctx)`, blocked-list + follow-trust inputs, `ASK` fall-through.
- `amethyst/.../authCommand/model/RelayAuthIntentRegistry.kt`**new, minimal**:
only for hints quartz can't infer (e.g. the recipient behind an encrypted gift
wrap). Common purposes are derived from quartz's pending events + active
filters instead.
**Quartz (generic mechanism — see the quartz section):**
- `quartz/.../nip01Core/relay/client/pool/PoolEventOutboxState.kt` +
`PoolEventOutbox.kt``auth-required` as a pending-auth state excluded from
`isDone()`; bounded retry + backoff; terminal give-up notification.
- `quartz/.../nip01Core/relay/client/auth/RelayAuthenticator.kt` — pass a
`RelayAuthChallengeContext` (pending events + active filters) to the injected
decision hook; allow the hook to suspend for a host decision.
- `quartz/.../nip01Core/relay/client/listeners/RelayConnectionListener.kt` (or a
new client `SharedFlow`) — "event blocked on auth for relay X" + "gave up"
signals. Fold the good `StandaloneRelayClient` auth-retry logic into the
production path.
- `amethyst/.../authCommand/model/AuthCoordinator.kt` — build `RelayAuthContext`
from the registry, emit `RelayAuthRequest` on `ASK`, await the reply.
- Wire the ledger's new inputs where it's constructed (blocked-list flow,
follow-check, relay-ownership lookups from `DmRelayListState`/`Nip65RelayListState`).
- Registration calls at the DM sender, reply/reaction broadcaster, and outbox
feed subscription.
- `amethyst/.../ui/screen/loggedIn/relayauth/RelayAuthSettingsScreen.kt` — new
mode; three-state per-relay overrides; **per-relay rationale rows** grouped by
purpose ("To send DMs to: …", "To download posts from: …") rendering
counterparty avatars + names resolved from `LocalCache`.
- New composable dialog + observer for `RelayAuthRequest`, hosted in the
logged-in scaffold.
## Verification
- **Unit (commons/amethyst JVM):** table-test `decide(ctx)` across the
precedence ladder — blocked beats override beats policy; `TRUSTED_FOLLOWS`
allows a followed-counterparty relay and falls to `ASK` for a stranger;
`Unknown` purpose → silent `DENY`.
- **Quartz outbox (JVM unit tests):** an `auth-required` NAK does **not** advance
`isDone()` and the event survives; after a simulated auth OK, `syncFilters`
re-sends it; a non-auth terminal error still discards; retries honor backoff
and emit a give-up signal instead of a silent drop. Include a race test:
repeated `auth-required` NAKs before auth completes must not drop the event.
- **Intent registry:** register/expire, multi-purpose merge on one relay.
- **Grant rationale:** `recordUse` merges new counterparties into the right
purpose kind, dedupes, refreshes `lastUsedAt`; `allDecisions()`/settings query
returns the grouped rationale for rendering.
- **`amy` interop:** drive a NIP-17 send to a recipient whose 10050 relay
requires auth against a local auth-required relay (`amy serve` / geode) and
confirm the AUTH round-trip + delivery once allowed. (Enforces the
verify-don't-guess rule.)
- **Manual:** send a DM to a followed vs non-followed npub on an auth-required
inbox under each policy mode; confirm the prompt copy names the right reason
and that Always/Block persist.
- `./gradlew :commons:test :amethyst:testDebugUnitTest` and `./gradlew spotlessApply`.
@@ -1,538 +0,0 @@
# NIP-29 Relay-Based Groups — Deep Study of Armada + Amethyst Integration Plan
**Date:** 2026-07-07
**Status:** Research / design study (no code yet)
**Reference client studied:** [Armada](https://gitlab.com/soapbox-pub/armada) (Soapbox), commit at HEAD of `main`
**Spec:** [NIP-29](https://github.com/nostr-protocol/nips/blob/master/29.md)
This document is a deep study of how **Armada** — a Discord-style NIP-29 client
by Soapbox — creates events, manages chat, displays information, and handles
invites/joins/leaves, followed by a concrete plan for bringing NIP-29 into
Amethyst. It is written to answer "how does relay-based group chat actually work
in a shipping client, and where does it slot into Amethyst's existing chat
stack."
---
## 0. TL;DR for the impatient
- **NIP-29 groups live on ONE relay.** A group is addressed by the pair
`(relayUrl, groupId)`. The relay is the source of truth — it signs the group's
metadata/membership/roles and enforces who may write. This is fundamentally
different from Nostr's usual "publish everywhere" model.
- **Armada models it as Discord:** a **server = a relay**, a **channel = a
NIP-29 group**. The far-left rail is a list of relays; picking one shows that
relay's channels; picking a channel shows its kind-9 message timeline.
- **The user's home base is kind 10009** (NIP-51 "simple groups list"): it stores
both the relays they've added (`r` tags) and the groups they've joined
(`group` tags), privately (NIP-44 encrypted to self).
- **Amethyst is already ~70% wired for this at the protocol layer.** Quartz has a
`nip29RelayGroups` package (metadata/moderation/request events) **and** a
`nip43RelayMembers` package (the relay-level membership handshake Armada uses).
The main protocol gap is the **kind-9 group chat *message* event** itself.
- **Amethyst also already has the perfect UX analog:** the `ephemChat` feature
(NIP-C7, kind 23333) is a *relay-scoped* chat room identified by a
`RoomId(id, relayUrl)` — the exact shape of a NIP-29 group address — with a
full channel/screen/join/leave UI under
`amethyst/.../ui/screen/loggedIn/chats/publicChannels/ephemChat/`. NIP-29 is a
managed, moderated sibling of that feature.
---
## 1. NIP-29 protocol primer (the parts that matter)
### 1.1 The addressing model
A group is **not** a global object. It exists on a specific relay, and its id is
only unique *within that relay*. So every reference to a group is the pair:
```
(relay websocket URL, group id)
```
The group id is a short opaque string (Armada mints 8 random bytes → 16 hex
chars). Everything that happens "in" the group is tagged with `["h", "<groupId>"]`
and published **only** to that relay.
### 1.2 Event kinds
| Kind | Name | Signed by | Purpose |
|------|------|-----------|---------|
| **9** | group chat message | user | the actual chat line (has `["h", groupId]`) |
| 11 | group thread/forum post | user | long-form/threaded root |
| 1111 | NIP-22 comment | user | threaded reply to a chat message |
| 7 | reaction | user | emoji reaction (scoped by `h`) |
| 1068 | poll (NIP-88) | user | poll posted into the timeline |
| **9000** | put-user | user (admin) | add member / set roles |
| **9001** | remove-user | user (admin) | remove member |
| **9002** | edit-metadata | user (admin) | change name/about/picture/flags |
| 9005 | delete-event | user (admin) | moderator delete a message |
| **9007** | create-group | user (admin) | create a new group |
| 9008 | delete-group | user (admin) | delete a group |
| **9009** | create-invite | user (admin) | mint an invite code |
| **9021** | join-request | user | ask to join (optional `code`) |
| **9022** | leave-request | user | leave the group |
| **39000** | group metadata | **relay** | name, picture, about, flags (addressable, `d`=groupId) |
| **39001** | group admins | **relay** | `["p", pubkey, role…]` list |
| **39002** | group members | **relay** | `["p", pubkey]` list |
| **39003** | group roles | **relay** | `["role", name, desc]` definitions |
| 39004 | live AV participants | **relay** | LiveKit room presence (Armada extension use) |
The **9xxx** events are user-authored **requests/commands**; the relay validates
the author's role and, if accepted, updates the group state and re-emits the
**39xxx** relay-signed snapshots. **Clients read 39xxx, write 9xxx.**
### 1.3 Group metadata flags (kind 39000 tags)
`name`, `picture`, `about` carry display data. Boolean status is presence-based:
- `private` — only members can **read** (relay gates reads behind NIP-42 AUTH).
- `restricted`* — only members can **write**.
- `closed` — join requests are ignored; you need an invite code.
- `hidden` — metadata hidden from non-members.
- `livekit` — group has an AV room.
- `supported_kinds` — whitelist of accepted kinds (absent = all).
The antonyms are `public` / `open` (Armada's `edit-metadata` emits the antonym
tag to *clear* `private`/`closed`; see §2.4).
### 1.4 The `previous` tag (timeline references) — and why Armada drops it
NIP-29 defines an optional `["previous", <id-prefix>, …]` tag: each event should
reference the first-8-chars of several of the group's recent events so a relay
can reject events that were composed against a *forked* view of the timeline
(anti-context-spam). **Armada deliberately does not emit it** — see the
verbatim rationale in `ChatComposer.buildMessageTags`:
> relay29's `CheckPreviousTag` rejects any event whose first `previous` ref isn't
> in the group's in-memory last-50 ring. We can only pick refs from a local (and
> own-excluded) message snapshot, which routinely drifts out of that window —
> especially when replying to older messages — causing the relay to silently drop
> legitimate messages/replies. `previous` is optional in NIP-29 and only guards
> against relay-fork attacks, which don't apply to this single-host-per-group
> deployment.
**Takeaway for Amethyst:** Quartz *has* a `PreviousTag` class, but a
single-host-per-group deployment does not need it, and emitting it naively causes
silent drops. Start without it (like Armada); only add it if targeting a relay
that enforces it.
---
## 2. How Armada creates events
Armada's whole NIP-29 protocol layer is ~770 lines in
`client/src/lib/nip29.ts` (constants, parsers, tag builders), driven by a set of
TanStack-Query hooks in `client/src/hooks/`. Every write goes through a single
`useNostrPublish` mutation whose `relay` option **pins the event to the group's
host relay** — this is the linchpin of the whole design.
### 2.1 The publish chokepoint (`useNostrPublish.ts`)
```ts
// EventTemplate has an optional `relay?: string`.
// When set, publish ONLY to this relay (NIP-29 group traffic must stay on
// the group's host server). When omitted, the event goes to all app relays.
if (relay) {
await nostr.relay(relay).event(event, ); // single-relay send
} else {
await nostr.event(event, ); // fan out to app relays
}
```
It also: adds a NIP-89 `["client", APP_NAME]` tag, adds `published_at` for
replaceable kinds, stores the signed event locally *before* the network call (so
optimistic UI + offline retry work), and normalizes relay rejection strings for
toasts (`relayRejectionMessage`).
**Every NIP-29 write in Armada passes `relay: relayUrl`.** Group/channel events
*never* touch the general app relays. This is the single most important
invariant to replicate.
### 2.2 Create a group (`CreateGroupDialog.tsx` + `useGroupModeration.ts`)
A "create channel" is a **two-event sequence**, then a bookkeeping write:
```ts
// 1. kind 9007 create-group — just the h tag.
await createGroup({ groupId });
// → publishEvent({ kind: 9007, content: "", tags: [["h", groupId]], relay })
// 2. kind 9002 edit-metadata — name/about/visibility.
await editMetadata.mutateAsync({ name, about, isPrivate, isClosed });
// → tags: [["h", groupId], ["name", name], ["about", about],
// [isPrivate ? "private" : "public"], [isClosed ? "closed" : "open"]]
// 3. remember it in the user's kind 10009 list (best-effort).
updateList({ type: "add-group", ref: { id: groupId, relay: relayUrl } });
```
The group id is client-minted (`crypto.getRandomValues(8)` → hex). The creator
becomes admin automatically (the relay assigns the group-creator role). Then the
UI navigates to `/s/<relayParam>/<groupId>`.
> Server note: Armada's own relay (`server/group.go`) **restricts kind 9007 to
> configured admin pubkeys** — on that deployment only operators create channels.
> That's a relay-policy choice, not a NIP-29 requirement.
### 2.3 Send a chat message (`ChatComposer.tsx`)
A message is **kind 9** with `["h", groupId]` plus standard Nostr tags. The tag
builder (`buildMessageTags`) assembles:
- `["h", groupId]` — always first, required.
- `["t", hashtag]` — extracted hashtags.
- `["p", pubkey]` — NIP-27 mentions decoded from `nostr:npub…` in content.
- NIP-10 marked reply tags when replying:
`["e", rootId, relay, "root", rootAuthor]` + `["e", replyId, relay, "reply", replyAuthor]`.
- `["q", …]` — NIP-18 quotes for embedded nevent/naddr.
- NIP-30 custom emoji tags, NIP-92 `imeta` tags for uploads.
- **No `previous` tag** (see §1.4).
Sending is **optimistic**: the event is signed locally, inserted into the
timeline cache with a `pending` status, then sent. Because the id is computed at
sign time, the relay's echo of the same event dedupes automatically against the
optimistic copy (id match) and flips it to confirmed.
### 2.4 Edit metadata / moderation (`useGroupModeration.ts`)
All moderation is one small hook returning mutations, each a single pinned
publish. Exact tag shapes:
| Action | kind | tags |
|--------|------|------|
| putUser | 9000 | `[["h",g], ["p", pubkey, ...roles]]` |
| removeUser | 9001 | `[["h",g], ["p", pubkey]]` (content = reason) |
| deleteEvent | 9005 | `[["h",g], ["e", eventId]]` |
| editMetadata | 9002 | `[["h",g], ...metadataTags(patch)]` |
| deleteGroup | 9008 | `[["h",g]]` |
| createInvite | 9009 | `[["h",g], ["code", code]]` |
`metadataTags` emits antonym tags to clear flags (`public`/`open`) but only
*asserts* `restricted`/`hidden` (no documented antonyms). After a successful
moderation write, the relevant TanStack query keys are invalidated so the
39xxx-derived views refetch.
### 2.5 Invites (`InvitePeopleDialog.tsx`)
Opening the invite dialog **immediately mints an invite and builds a link** — the
"silly-easy part." Two-tier logic:
1. **Prefer a relay-level claim (NIP-43 / zooid).** `useRelayClaim` queries the
relay for a `kind 28935` invite it issues to authed members; if present, its
`claim` tag value is the invite code.
2. **Fall back to a per-group NIP-29 code (kind 9009).** If the relay issues no
claim, mint a random 6-byte code and publish `createInvite({ code })`.
The shareable URL is `…/s/<relayParam>/<groupId>?code=<code>`. Anyone opening it
hits `GroupPage`, which auto-joins using the `code` query param.
### 2.6 Join / leave / membership (`useGroupMembership.ts`, `useRelayMembership.ts`)
**Join (kind 9021)** is a two-step handshake, in this order:
```ts
// 1. Best-effort RELAY-level join first (zooid/Coracle relays gate ALL writes
// behind relay membership, rejecting non-members before group join is even
// considered). Ephemeral kind 28934 carrying the invite as a `claim` tag.
// NEVER throws — no-ops on relays that don't implement it (e.g. relay29).
await joinRelay({ relayUrl, claim: code });
// 2. The real NIP-29 group join. Same invite carried as a `code` tag.
await publishEvent({ kind: 9021, content: reason, tags: [["h",g], ["code",code]], relay });
// "already a member" rejection is treated as success.
```
**Leave (kind 9022):** `publishEvent({ kind: 9022, tags: [["h",g]], relay })`.
The relay auto-issues the corresponding 9001 remove-user.
**Membership state** is derived per NIP-29 by querying the *latest* of
`{kinds:[9000,9001], "#h":[g], "#p":[me]}` on the host relay — 9000 latest ⇒
member, 9001 latest ⇒ not. Polls every 30s; 15s stale time.
**Relay membership (NIP-43)** is its own layer, cleanly separated: `useRelayClaim`
(fetch a 28935 claim), `useJoinRelay` (publish 28934 with the claim),
`useLeaveRelay` (28936). All best-effort and non-throwing — the group join is the
source of truth for the actual outcome.
---
## 3. How Armada displays information
### 3.1 The three-pane Discord layout
```
┌────┬──────────────┬───────────────────────────┐
│rail│ channel list │ message timeline │
│ │ (this relay) │ + composer │
│ 🟦 │ # general │ ...kind 9 messages... │
│ 🟩 │ # random │ │
│ ➕ │ # dev │ [type a message] │
└────┴──────────────┴───────────────────────────┘
servers channels chat
=relays =NIP-29 groups =kind 9
```
- **`ServerRail.tsx`** — the far-left vertical rail. Each icon is a **relay**
(`{ kind: "server", url }`), fed by the pinned `PLATFORM_RELAYS` +
`config.addedRelays`. Supports Discord-style drag-to-reorder and **folders**.
(It also unifies in Concord E2EE communities, which are *not* NIP-29 — ignore
those.) Server order is synced to the kind-10009 `r` tags.
- **`ChannelSidebar`** — for the selected relay, lists its groups via
`useRelayGroups(relayUrl)`.
- **`GroupChat.tsx`** — the timeline + composer for the selected group.
Selecting a relay first, then a room, is *exactly* the "select the relay first
and then pick the rooms in each relay" UX in the ask. Folders are Armada's answer
to "organization to group rooms on" — but note they group **relays**, not rooms;
rooms are grouped implicitly by their host relay.
### 3.2 Fetching a relay's channels (`useRelayGroups.ts`)
Lists a relay's groups by querying `{kinds:[39000]}` on that relay. Key
subtleties:
- **Trust:** kind 39000 must be signed by the relay's own key. Armada reads the
relay's `self`/`pubkey` from its NIP-11 doc and adds `authors:[relaySelf]` so
**forged metadata from other publishers is never trusted**. Until NIP-11
resolves it races a direct fetch (2s) then refetches once when the key lands.
- **Hidden groups:** relays hide closed/private groups from open listings, so the
ids the user *remembers* (their kind-10009 `group` tags for this relay) are
queried explicitly by `#d` and merged in.
- **Provenance scoping:** several relays can share a signing key (zooid ships a
shared identity), so author-scoping alone bleeds channels across relays. Armada
records *which relay actually served* each cached event ("provenance") and
scopes the IndexedDB cache read by it. This is a real, painful edge case worth
remembering.
- **Cache-as-floor:** cached events are merged *under* live ones so a sparse/flaky
relay read can only add, never clear the list. Long stale time (1h), no polling
— channel metadata is the most stable thing in the app.
### 3.3 Fetching a group's roster (`useGroup.ts`)
One query pulls the newest of `{kinds:[39000,39001,39002,39003], "#d":[groupId]}`
(optionally author-scoped to the relay key) and composes
`{ group, admins, members, roles }` (newest event per kind wins). **Local-first:**
the plaintext 39xxx events are mirrored to IndexedDB, so a previously-opened
group renders its roster instantly, with a background relay refresh.
### 3.4 The message timeline (`useGroupMessages.ts`)
The most sophisticated hook. For `(relayUrl, groupId)`:
- **Timeline kinds:** kind 9 + kind 1068 (polls); live sub also watches kind 5
(deletions).
- **Local-first + snapshot-first paint:** seeds from a synchronous localStorage
"last screenful" snapshot → IndexedDB store → background relay page, merged
append-only so nothing already shown is dropped.
- **Scroll-up pagination:** `loadOlder()` walks an `until` cursor with a
gap-guard (Ditto's pattern) so a stale straggler doesn't leap the cursor past
real history.
- **Live subscription:** one `req` with a 5-minute `since` lookback (so a message
that arrived via push before the group was opened still replays); dedupes by id;
processes kind-5 deletions by dropping referenced ids.
- **Optimistic send status** map (`pending`/`failed`) reconciled by relay echo.
- **Resilience:** 60s backstop poll + refetch-on-focus/reconnect to heal
half-dead mobile sockets where the live socket silently died.
### 3.5 Group discovery / "home" (`useUserGroupList.ts`, kind 10009)
The **cross-device source of truth** for a user's memberships is a single kind
10009 event (NIP-51 "simple groups"):
- `group` tags `["group", id, relay]` — joined groups (with host relay).
- `r` tags `["r", relayUrl]` — servers/relays in use.
- Both stored as **NIP-44 private items** (encrypted to self in `.content`);
read-modify-write via `useUpdateUserGroupList`. Armada persists the *decrypted*
list to disk ("folded cache") so boot doesn't pay a signer round-trip, and
refuses to write if it couldn't decrypt the prior list (avoids wiping it).
There is also a `useGroupSearch` (NIP-50 `search` scoped by `#h`, merged with the
local timeline cache) for in-group message search.
### 3.6 What else rides on the `h` tag
Armada extends the group with several kinds, all scoped by `["h", groupId]` so
the relay routes/authorizes them: **pins** (kind 39041, an Armada extension,
addressable `d`=groupId, admin-only), **calendar events** (NIP-52
31922/31923/31925), **reactions** (kind 7), **threaded replies** (kind 1111
NIP-22 comments), and **webxdc mini-apps** (9450/24450). All follow the same
pattern: `["h", groupId]` + pin to host relay. Useful precedent that "anything
can be a group event if it carries `h` and the relay accepts the kind."
---
## 4. Design constraints & gotchas (the expensive lessons)
1. **Relay-scoping is absolute.** Group events go only to the host relay, and
group queries hit only the host relay. Amethyst's relay client fans out to
many relays by default — NIP-29 needs a *single-relay* send/subscribe path.
2. **39xxx is relay-signed; trust it by the relay's own key.** Filter directory
queries by `authors:[relayNip11Pubkey]`. Never trust group metadata otherwise.
3. **Shared relay identities bleed groups.** If you cache 39000 by author only,
two relays sharing a key cross-contaminate. Track per-relay provenance.
4. **`previous` tags cause silent drops** unless you can guarantee refs are in the
relay's last-50 ring. Omit them for single-host groups.
5. **Two membership layers.** NIP-29 group membership (9000/9001) is distinct from
relay-level membership (NIP-43, 28934/28935). Community relays (zooid) gate on
the latter *first*. Do the relay handshake best-effort, treat the group join as
authoritative.
6. **Cache-as-floor everywhere.** A flaky relay returning nothing must never blank
a channel list or roster. Merge cache under live, never overwrite.
7. **Optimistic UI needs local signing.** Sign locally, insert with pending
status, dedupe on relay echo by id.
---
## 5. Amethyst integration plan
### 5.1 What already exists (survey)
**Protocol (Quartz) — largely present.** `quartz/.../nip29RelayGroups/` already
has:
- `metadata/``GroupMetadataEvent` (kind **39000**), `GroupAdminsEvent`
(39001), `GroupMembersEvent` (39002), `SupportedRolesEvent` (39003).
- `moderation/``CreateGroupEvent` (9007), `EditMetadataEvent` (9002),
`PutUserEvent` (9000), `RemoveUserEvent` (9001), `DeleteEventEvent` (9005),
`DeleteGroupEvent` (9008), `CreateInviteEvent` (9009), plus tag helpers.
- `request/``JoinRequestEvent` (9021), `LeaveRequestEvent` (9022).
- `tags/``GroupIdTag` (the `h` tag), `CodeTag`, `GroupAdminTag`, `RoleTag`,
and even a `PreviousTag`.
**Relay membership (Quartz) — present.** `quartz/.../nip43RelayMembers/` has the
full NIP-43 handshake Armada calls "relay membership": `RelayJoinRequestEvent`,
`RelayInviteRequestEvent`, `RelayAddMemberEvent`, `RelayLeaveRequestEvent`,
`RelayMembershipListEvent`, `ClaimTag`, `MemberTag`. There's even an
`amethyst/.../ui/screen/loggedIn/relays/nip43/RelayMembersScreen.kt`.
**The UX analog (commons + amethyst) — present and close.** The `ephemChat`
feature (NIP-C7, kind **23333**) is a *relay-scoped* chat room:
- `quartz/.../experimental/ephemChat/chat/EphemeralChatEvent.kt` — kind 23333,
with `RoomId(room, relayUrl)` — **the same `(relay, id)` shape as a NIP-29
address.**
- `commons/.../model/emphChat/EphemeralChatChannel.kt``EphemeralChatChannel`
with `relays() = setOf(roomId.relayUrl)`, wired into `LocalCache`, `Account`,
`Note`, plus an `EphemeralChatListState`.
- `amethyst/.../ui/screen/loggedIn/chats/publicChannels/ephemChat/` — a full UI:
`EphemeralChatScreen`, `LoadEphemeralChatChannel`, `EphemeralChatChannelHeader`,
`JoinChatButton`/`LeaveChatButton`, a `NewEphemeralChatScreen`, and datasource
sub-assemblers (`FilterMessagesToEphemeralChat`, etc.).
So the Messages/Chats screen already hosts **four** conversation types:
NIP-04/17 DMs, NIP-28 public channels, and NIP-C7 ephemeral chats — all under
`chats/`. **NIP-29 groups become the fourth sibling.**
### 5.2 The main protocol gap
There is **no kind-9 group chat message event** in `nip29RelayGroups/`
(`CreateInviteEvent` at 9009 is the highest kind present; nothing for kind 9).
The 9xxx moderation, 39xxx metadata, and 9021/9022 request events exist, but the
actual message carrier does not. This is the first thing to build:
- `nip29RelayGroups/chat/GroupChatEvent.kt` — kind 9, `["h", groupId]` required,
NIP-10 reply markers, NIP-27 mentions, NIP-92 imeta, NIP-30 emoji — mirror
`ChannelMessageEvent` (NIP-28) which already does all of this, but swap the
channel `e`-root tag for the `h` group tag. Optionally kind 11 (thread) and
reuse NIP-22 `CommentEvent` for replies.
Also verify the existing 3900039003 parsers expose the flag tags
(`private`/`closed`/`restricted`/`hidden`/`livekit`/`supported_kinds`) and roles
per §1.3; extend if not.
### 5.3 Recommended architecture mapping
| Armada (React) | Amethyst target | Notes |
|----------------|-----------------|-------|
| `lib/nip29.ts` | `quartz/.../nip29RelayGroups/` | mostly exists; add kind-9 `GroupChatEvent` + any missing parsers |
| `useNostrPublish({relay})` | a single-relay send in `commons/.../relayClient/` | **critical new capability**: publish/subscribe pinned to one relay |
| `useRelayGroups` | a `RelayGroupsState` / filter assembler | query 39000 on one relay, author-scoped to its NIP-11 key |
| `useGroup` | `GroupChannel` model + roster state | compose newest 3900039003; mirror `EphemeralChatChannel` |
| `useGroupMessages` | a NIP-29 `FeedFilter` + `FeedContentState` | reuse `chats/publicChannels/datasource` sub-assembler pattern |
| `useUserGroupList` (10009) | an `Account` state object (like `ephemeralChatListState`) | NIP-44 private items; StateFlow of joined groups + servers |
| `useGroupMembership` | membership derivation from 9000/9001 | latest-wins per NIP-29 |
| `useRelayMembership` (NIP-43) | already in `nip43RelayMembers` + `RelayMembersScreen` | best-effort handshake before join |
| `ServerRail` + `ChannelSidebar` | Android: a relay picker → channel list inside the Chats tab | see §5.4 |
| `GroupChat` + `ChatComposer` | reuse the ephemChat/NIP-28 chat screen + composer | swap the datasource + send to kind 9 + `h` |
**Placement per CLAUDE.md:** protocol → `quartz/`; the group model, list state,
membership derivation, ViewModels/filters → `commons/` (so Desktop + CLI share);
screen composables + navigation → `amethyst/` (bottom-nav) and `desktopApp/`
(sidebar). The ephemChat feature is the template to copy for all three layers.
### 5.4 Recommended Amethyst UX
The ask floats three options; the study points to a clear answer:
- **Not** a flat list of rooms mixed into the DM inbox. NIP-29 rooms are
relay-scoped and there can be many per relay — mixing them into the DM room
list loses the relay grouping and doesn't scale.
- **Yes** to "select the relay first, then pick rooms in that relay." This is
Armada's model and it matches the protocol's addressing exactly. On Android
(bottom-nav, no room for a permanent Discord rail), the natural shape is:
- A **"Groups"/"Servers" entry inside the existing Chats tab** (alongside DMs,
Public Chats, Ephemeral Chats).
- Level 1: **your relays** (from kind-10009 `r` tags) — an "add relay" affordance
and each row shows unread rollup.
- Level 2: tap a relay → **its channels** (from `useRelayGroups`-equivalent),
with a create-channel action (subject to relay policy).
- Level 3: tap a channel → the **existing chat screen**, re-pointed at a NIP-29
kind-9 datasource.
- "Organization to group rooms on" = the **relay is the grouping**; add
Discord-style relay folders later if desired (Armada's `railLayout`).
- Desktop can render the true three-pane rail (it already uses a sidebar shell).
Deep-linking: adopt Armada's invite-link idea via Nostr-native addressing —
`naddr` to the kind-39000 (kind + relay-key author + `d`=groupId + relay hint),
plus an optional invite `code`. Amethyst already resolves `naddr`; a group `naddr`
should route into the channel and, if a code is present, fire a 9021 join.
### 5.5 Suggested build order
1. **Quartz:** add `GroupChatEvent` (kind 9) + builders; confirm 39000 flag/role
parsing. Unit-test against Armada-produced events (spin up `./start.sh` or use
`chat.soapbox.pub`).
2. **Relay client:** add a single-relay pinned publish + subscription path
(the `relay: relayUrl` equivalent). This unblocks everything else.
3. **commons:** `GroupChannel` model + `UserGroupListState` on `Account` (kind
10009, mirror `EphemeralChatListState`) + membership derivation.
4. **commons:** NIP-29 message `FeedFilter`/`FeedContentState` (copy the
ephemChat/NIP-28 sub-assembler; timeline kinds 9 + 1068 + 5).
5. **amethyst:** relay-picker → channel-list screens in the Chats tab; re-point
the chat screen/composer at the kind-9 datasource; join/leave/create/invite
dialogs (reuse `nip43RelayMembers` for the relay handshake).
6. **Later:** roles/moderation UI, pins, reactions, threads, calendar, LiveKit AV.
### 5.6 Explicitly out of scope
Armada's `concord-v1`/`concord-v2` directories are a **separate** end-to-end
encrypted community protocol (sealed envelopes, rekeying) — *not* NIP-29. They
share the chat *components* via a `ChatTransport` abstraction but nothing else.
Ignore them for NIP-29. (The `ChatTransport` pattern — a presentational chat UI
fed by a capability interface — is itself a nice idea worth borrowing so DMs,
NIP-28, ephemeral, and NIP-29 all render through one component.)
---
## 6. Key file references
**Armada (studied):**
- `client/src/lib/nip29.ts` — constants, parsers, tag builders
- `client/src/hooks/useNostrPublish.ts` — the `relay`-pinned publish chokepoint
- `client/src/hooks/useGroupModeration.ts` — 9000/9001/9002/9005/9007/9008/9009
- `client/src/hooks/useGroupMembership.ts` + `useRelayMembership.ts` — 9021/9022 + NIP-43
- `client/src/hooks/useRelayGroups.ts` / `useGroup.ts` / `useGroupMessages.ts` — display
- `client/src/hooks/useUserGroupList.ts` — kind 10009 home base
- `client/src/components/layout/ServerRail.tsx` — Discord rail (relay = server)
- `client/src/components/chat/ChatComposer.tsx` — kind-9 send + the `previous`-tag rationale
- `client/src/components/dialogs/{CreateGroup,InvitePeople}Dialog.tsx`
- `server/group.go`, `server/invites.go`, `server/unmanaged.go` — relay29 policy
**Amethyst (integration surface):**
- `quartz/.../nip29RelayGroups/**` — existing protocol events (add kind-9 chat)
- `quartz/.../nip43RelayMembers/**` — relay membership handshake
- `quartz/.../experimental/ephemChat/**` + `commons/.../model/emphChat/**` — the relay-scoped chat analog
- `amethyst/.../ui/screen/loggedIn/chats/publicChannels/ephemChat/**` — UI template
- `amethyst/.../ui/screen/loggedIn/relays/nip43/RelayMembersScreen.kt`
- `quartz/.../nip28PublicChat/message/ChannelMessageEvent.kt` — the kind-9 builder to mirror
@@ -1,208 +0,0 @@
# Concord — Mobile Integration Plan (mirroring NIP-29 Relay Groups)
## Context
The Concord protocol engine is complete in `quartz/…/concord/` (CORD-01…07,
~65 tests) and driven end-to-end by the `amy concord` CLI over a commons
`ConcordActions` layer. This plan covers the **Android app integration**, and it
deliberately **mirrors the just-merged NIP-29 relay-groups feature** — that work
used Soapbox's Armada as a study base and established the exact Amethyst touch
points a group-chat protocol should plug into. Wherever possible we clone the
NIP-29 file structure with Concord equivalents rather than inventing parallels.
Naming: user-facing = **"Concord Channels"** (Amethyst reserves "community" for
NIP-72). Protocol-internal code keeps the spec term `community`.
## The one structural difference from NIP-29
NIP-29 group metadata (kind 39000) is **relay-signed and public**, so groups are
browsable. Concord communities are **end-to-end encrypted**: the only public
artifact is the addressable kind-33301 invite **bundle**, whose content is
token-gated. Consequences for the mirror:
- **Addressing** is by *derived stream pubkey* (`group_key.pk` per plane/epoch),
not `(hostRelay, groupId)`. A Concord channel lives at its plane address and
may be mirrored on several relays (the community's relay set), not pinned to
one host. So `ConcordChannel.relays()` = the community relay set.
- **Discovery** cannot preview E2EE content. The discovery feed surfaces **public
invite links** (kind-33301 bundles + links shared in notes), filtered by
author/hashtag — the entry action is *redeem a link*, not *browse contents*.
This is a genuinely thinner surface than NIP-29; documented, not a bug.
- **Membership = key possession**, verified locally from the folded Control Plane
+ banlist (already implemented), not from relay-signed 39001/39002.
## Per-account persistence & subscription model (Concord is between NIP-17 and NIP-28/29)
Separate **addressing** from **encryption/membership** and Concord's place is clear:
| Concern | NIP-28 | NIP-29 | NIP-17 | **Concord** |
|---|---|---|---|---|
| Find messages by | channel id | `(relay, h)` | `#p = me` | **`authors=[derived plane pk]`** |
| Content | public | public | E2EE to you | **E2EE to a shared key** |
| Decrypt with | — | — | your key | **per-channel derived conv key** |
| Membership | open | relay roster | key possession | **key possession** |
| "My rooms" home | follow list | kind-10009 | chatroom set | **kind-13302 (carries secrets)** |
The decisive point: a Concord wrap's `p` tag is **ephemeral**, so you can never
find messages with `#p = me` (the NIP-17 model). You subscribe **by author = the
derived plane pubkey** (NIP-28/29 addressing), a query only a secret-holder can
form, and decrypt with the shared plane key (NIP-17 E2EE).
**Home base = kind-13302 `ConcordCommunityList`** (built in quartz): NIP-44
self-encrypted, replaceable, relay-synced. Unlike NIP-17 (only secret is your
identity key) or NIP-29 (public group tags), **each entry carries the community
secrets** (`community_root`, salt, epoch, private-channel keys). Same trust model
as NIP-17's recoverable giftwrapped history: a leaked nsec exposes them, nothing
worse. `ConcordChannelListState` wraps 13302 exactly like `RelayGroupListState`
wraps 10009 / `EphemeralChatListState` wraps its list — **same wiring, entries
hold keys.**
**In-memory projection (LocalCache):** `ConcordChannel` keyed by
`(communityId, channelId)`, holding the folded Control-Plane state + decrypted
messages — recomputed from events, never persisted as identity (the NIP-28/29
half).
**Subscription = per-plane author REQ, fanned out from the joined list** — not a
single `#p=me` catch-all. `ConcordMyChannelsFilterAssembler` (mirrors NIP-29's
`RelayGroupMyJoinedGroupsFilterAssembler`) walks `account.concordChannelList`,
derives each community's control-plane + channel-plane addresses, and issues
`{kinds:[1059], authors:[planePk]}` per plane across the community's relays.
**Secrets at rest:** relay copy is self-NIP-44-encrypted (13302); the on-device
mirror can be wrapped with `commons/keystorage`.
## Layering (same as NIP-29)
- `quartz/…/concord/` — protocol (done)
- `commons/…/model/concord/``ConcordChannel`, `ConcordChannelListState`,
membership/view-mode enums, discovery constraint (platform-agnostic)
- `amethyst/…/chats/publicChannels/concord/` — screens, feed filters, datasource
subassemblers, navigation
- `commons/…/actions/ConcordActions.kt` — builders/filters/folding (done)
- `cli/…/commands/Concord*Commands.kt` — verbs (done; already matches the
`RelayGroupCommands` route+verb-map pattern)
## Mirror map (NIP-29 file → Concord equivalent)
### commons state
- `model/nip29RelayGroups/RelayGroupChannel.kt`**`model/concord/ConcordChannel.kt`**
— a `Channel` subclass keyed by a `ConcordChannelId(communityId, channelId)`,
holding the folded `ConcordCommunityState` + this channel's messages StateFlow,
`relays()` = community relay set, `membershipOf()` from the authority resolver,
`placeholderNote()`.
- `RelayGroupListState.kt`**`model/concord/ConcordChannelListState.kt`** —
backed by the **kind-13302** joined-communities list (already in quartz:
`ConcordCommunityList`). Exposes `liveCommunities: StateFlow<List<Entry>>` and
`liveServers: StateFlow<Set<communityId>>`. `join(community)`/`leave` do
read-modify-write of the 13302 event. Mirrors `EphemeralChatListState`.
- `RelayGroupMembership.kt`**`ConcordMembership.kt`** (OWNER/ADMIN/MEMBER/BANNED/
NONE) derived from `AuthorityResolver` (rank + banlist).
- `RelayGroupViewMode.kt`**`ConcordViewMode.kt`** (INLINE/GROUPED).
- `model/nip29RelayGroups/GroupDiscoveryConstraint.kt`**`ConcordDiscoveryConstraint.kt`**
(AllPublic / ByPeople / ByHashtags) matching against a public invite bundle.
### Account wiring (`amethyst/…/model/Account.kt`)
Add right after the `relayGroupList` lines (~382): a
`ConcordChannelListState(signer, cache, decryptionCache, scope, settings)` field
+ its decryption cache. Action methods next to `joinRelayGroup` (~1472):
`createConcordCommunity`, `joinConcordFromLink`, `postConcordMessage`,
`createConcordInvite`, `banConcordMember`, `follow/unfollow(ConcordChannel)`
delegate to `ConcordChannelListState`. Writes go through the community relay set.
Add `concordViewMode` to `AccountSettings.kt`.
### LocalCache (`amethyst/…/model/LocalCache.kt`)
Add a `LargeCache<ConcordChannelId, ConcordChannel>` index + `getOrCreateConcordChannel`,
and route inbound kind-1059 wraps on known plane addresses into the fold (decrypt
→ edition/message). Mirrors `getOrCreateRelayGroupChannel`.
### Messages inbox integration (THE key mirror)
- `chats/rooms/dal/ChatroomListKnownFeedFilter.kt` + `ChatroomListNewFeedFilter.kt`
— extend the 5-way `feed()` concatenation to **6-way**: add a `concordChannels`
block reading `account.concordChannelList.liveCommunities`, branching on
`concordViewMode` (INLINE = one row per channel via
`LocalCache.getOrCreateConcordChannel(...).newestChatNote() ?: placeholderNote()`;
GROUPED = one synthetic `ConcordServerRoomNote(communityId, newest)` per
community). Update `applyFilter`/`updateListWith` with a
`filterRelevantConcordMessages(...)` keyed by `concordRowKey()`.
- `chats/rooms/dal/RelayGroupServerRoomNote.kt`**`ConcordServerRoomNote.kt`** —
synthetic event-less Note collapsing a community's channels into one inbox row.
- `chats/rooms/ChatroomHeaderCompose.kt` — add `rendersWithoutEvent` branches for
`ConcordServerRoomNote` and channel placeholders; `ConcordServerRoomCompose`
`Route.ConcordServer(communityId)`; `ConcordRoomCompose` (chip = community name)
`routeFor(channel)`. **This is where the "chip opens the Concord Channel"
requirement lands.**
### Screens (`amethyst/…/chats/publicChannels/concord/`, mirror `relayGroup/`)
- `ConcordServerList.kt` (community rows) · `ConcordChannelListScreen.kt(communityId)`
(a community's channels, from the folded Control Plane) ·
`ConcordChatScreen.kt(communityId, channelId, …)` (top-level route target) ·
`ConcordChannelView.kt` (reuse the NIP-28 `ChannelFeedViewModel`/`ChannelView`
stack via the `ConcordChannel: Channel` subclass) · `ConcordMembersScreen.kt` ·
`ConcordMetadataScreen.kt`/`ViewModel.kt` (create/edit) · `ConcordTopBar.kt`
(name + role badge + Members/Edit/Invite/Ban/Leave menu) · `LoadConcordChannel.kt`.
- Compose composer gated on `membershipOf(me).isMember()`; else a "redeem an
invite to post" notice.
### Discovery feed (GitRepositories-style triad; thinner than NIP-29)
- `concord/dal/ConcordDiscoveryFeedFilter.kt` (`AdditiveFeedFilter<Note>` over
public kind-33301 bundles; "My Communities" branch = the 13302 list) +
`concord/dal/ConcordDiscoveryConstraint.kt` bridge +
`concord/datasource/subassemblies/FilterConcordBundlesBy{Authors,Follows,Hashtag}.kt`.
`ConcordDiscoveryScreen.kt` = `DisappearingScaffold` + `FeedFilterSpinner` +
`RenderFeedContentState` with `ConcordDiscoveryCard` (name + Join button). FAB →
`ConcordBrowse`/redeem-link.
### Navigation (`ui/navigation/routes/Routes.kt` + `AppNavigation.kt`)
`@Serializable` routes: `Concord`(communityId, channelId, +draftId?/inviteToken?),
`ConcordServer`(communityId), `ConcordMembers`, `ConcordCreate`, `ConcordEdit`,
`Concords`(object, bottom-nav → discovery), `ConcordBrowse`. `RouteMaker.routeFor(ConcordChannel)`
+ deep-link: an invite URL/`nostr:`-embedded link → `Route.Concord(..., inviteToken=…)`,
auto-redeeming on open (mirror NIP-29's inviteCode auto-join). Wire through
`BouncingIntentNav.kt`.
### Invite/redeem UI + linkification
- `InviteConcordDialog.kt` (moderator: mint + share link via `ConcordActions.mintInviteLink`)
· `JoinConcordDialog.kt` (paste a link → redeem) · `ui/components/ConcordInviteCard.kt`
(render a link as a preview card; tap → `Route.Concord(inviteToken)`) ·
`ui/components/ClickableConcordInviteLink.kt` (inline linkify shared invite URLs).
### Notifications (your explicit ask)
Route a Concord message notification click to the **channel chat**, not the feed:
in the notification builder + `BouncingIntentNav`, map a Concord message
notification to `Route.Concord(communityId, channelId)`. Mirror how NIP-29
group notifications resolve via `routeFor`.
### Zaps & likes
Because `ConcordChannel` extends `Channel` and messages render through the shared
`ChannelView`, reactions (kind 7) and zaps attach through the existing chat
reaction/zap path — but they must be **wrapped on the channel plane** (kind-7/9735
rumors sealed like messages, bound to channel+epoch), not published in the clear.
Add `ConcordActions.buildReaction`/`buildZapRequest` that wrap on the plane, and
point the shared reaction/zap affordances at them for Concord notes.
## Build order (each a tested, shippable slice)
1. **commons foundation**`ConcordChannel`, `ConcordChannelListState` (13302),
membership/view-mode enums; unit tests. Wire into `Account.kt` + `AccountSettings`.
2. **LocalCache index** + inbound wrap folding.
3. **Messages inbox** 6-way concat + `ConcordServerRoomNote` + header render/nav
(delivers the chip-opens-channel behavior).
4. **Chat screens** (reuse NIP-28 `ChannelView`) + nav routes + create/invite/join.
5. **Discovery feed** triad (public invite bundles).
6. **Notifications routing + zaps/likes on-plane.**
## Verification
- commons: `:commons:jvmTest` unit tests for `ConcordChannelListState` (13302
round-trip/merge) and `ConcordChannel` folding, mirroring
`RelayGroupListDecryptionTest`/`RelayGroupChannelTest`.
- Android: `:amethyst:installDebug`; create a community, see it in Messages with a
chip, tap → channel opens, send/receive between two emulators, redeem an invite
link deep-link, verify a notification click opens the chat. Cross-check against
`amy concord` (same relay) for wire interop, and against Armada for protocol
interop (`Nip29ArmadaInteropTest` is the precedent).
## Gotchas carried from the NIP-29 study
- Membership has two independent layers (Concord authority vs NIP-43 relay
membership); we only implement Concord authority.
- Cache-as-floor + optimistic local signing for snappy UX.
- E2EE means no server-side moderation and no metadata preview — surface state
from the local fold only.
@@ -1,113 +0,0 @@
# Dual-mode replies: inline + "minichat" threads across all chats
## Goal
Give every Amethyst chat two ways to reply, chosen at send time:
- **Inline reply** — a normal chat message that references its parent and stays in
the main timeline (today's behavior). On the wire this is the chat protocol's
native reply: NIP-C7 kind-9 with a `q` quote (Concord), kind-42 reply (NIP-28),
kind-9 `+h` reply (NIP-29), kind-14 reply (NIP-17 DM).
- **Minichat reply** — a **kind-1111 NIP-22 `CommentEvent`** rooted at the parent
message. It is pulled *out* of the main timeline and shown in a separate
**minichat** ("chat within a chat") opened from the parent. This matches Soapbox
Armada exactly (kind-9 `q` = inline quote, kind-1111 = thread).
The rule is uniform and protocol-agnostic: **any kind-1111 whose root is a chat
message opens as that message's minichat.** So the same treatment automatically
covers Concord kind-9, NIP-28 kind-42, NIP-29 kind-9, and (later) NIP-17 kind-14 —
wherever a 1111 lands on a chat message.
## Reuse survey (what already exists — do NOT rebuild)
| Need | Reuse |
|---|---|
| kind-1111 reply builder (NIP-22 `K/E/P`+`k/e/p`) | `quartz/.../nip22Comments/CommentEvent.replyBuilder`; Concord's `ChannelChat.reply` already uses it |
| 1111 → parent wiring | `LocalCache.computeReplyTo` (CommentEvent branch) → `parentNote.replies`; minichat content = `note.replies.filter { it.event is CommentEvent }` |
| "N replies" chip | `observeNoteReplyCount(note, avm)` (EventObservers.kt) — already used by `RelayGroupThreadsScreen` |
| Shared per-row action strip | `ChatMessageCompose.NormalChatNote` `detailRow` `Row` — one place, every chat type |
| Thread rendering | `threadview/ThreadFeedView` + `ThreadAssembler.findThreadFor`; NIP-29 `RelayGroupThreadsScreen` as the chat-adjacent precedent |
| Per-message 1111 REQ (public chats) | `FilterRepliesAndReactionsToNotes` (kinds incl 1111, `#e`) via `EventFinder`; `RelayGroupThreadFeedFilterAssembler` (compose-scoped `#h`+1111 sub) |
| Composer reply state + "replying-to" preview | `*NewMessageViewModel.replyTo` + `chats/utils/DisplayReplyingToNote` |
| NIP-22 comment composer | `note/nip22Comments/CommentPostViewModel` (full-featured) |
Concord already delivers kind-1111 replies through the existing channel-plane
subscription (they're wrapped like every other rumor), so **no new subscription is
needed for Concord** — only the timeline split, the chip, the minichat screen, and
the composer picker.
## Design
### 1. Wire model (settled — matches Armada)
- Inline reply → native chat reply event, native reply tags, stays in timeline.
- Minichat reply → kind-1111 `CommentEvent`: uppercase `K/E/P` at the immutable
thread root (the chat message), lowercase `k/e/p` at the immediate parent, plus
whatever binding the plane requires (Concord: `channel`/`epoch`). One level:
replying inside a minichat roots the new 1111 at the **same** root message
(parent = the message being answered, root = the minichat root), rendered flat —
so minichat messages don't spawn sub-threads. (The wire still permits nesting;
we render flat.)
### 2. Timeline vs minichat split (rendering)
- **Main feed** excludes kind-1111 comments whose root is a chat message — they
live in the minichat, not as flat siblings. Implemented in the shared
`ChannelFeedFilter` / `ChatroomFeedFilter` by dropping `CommentEvent`s that root
onto a message already in the feed (keep everything else).
- Each root message row shows an **"N replies" chip** (from `observeNoteReplyCount`
restricted to CommentEvent replies) in the `detailRow` strip; tap → minichat route.
### 3. Minichat screen
- A thread screen keyed by the **root message id** (+ the channel/room key needed to
re-derive the plane / re-subscribe). Renders the root message pinned at top, then
its kind-1111 replies as a flat mini-timeline (reuse `ChatroomMessageCompose`), with
its own composer that always sends kind-1111 rooted at this message.
- Back it with `ThreadFeedView`/`ThreadAssembler` where possible; for Concord, feed
it from `rootNote.replies` (already populated) + a lifecycle sub that keeps the
plane live.
### 4. Composer mode picker
- Add `replyMode: ReplyMode {INLINE, MINICHAT}` next to `replyTo` in each
`*NewMessageViewModel` (Concord `ConcordNewMessageViewModel`, DM
`ChatNewMessageViewModel`, channels `ChannelNewMessageViewModel`).
- Render a small toggle beside `DisplayReplyingToNote` ("Reply in chat" ⇄ "Reply in
thread"). Default = INLINE (least surprise; user opts into pulling it aside).
- Send branch: `MINICHAT` routes to the kind-1111 builder
(`CommentEvent.replyBuilder` / Concord `buildChannelReply`), `INLINE` keeps the
native reply builder.
### 5. Subscriptions
- **Concord**: none new (1111 arrives via the channel plane). Just ensure the
timeline filter and minichat read `rootNote.replies`.
- **NIP-28 / NIP-29 (phase 2)**: add a compose-scoped assembler (clone
`RelayGroupThreadFeedFilterAssembler`) that REQs `{kinds:[1111], "#e":[<visible
message ids>]}` (and `#E`) off the feed's current message-id set (from
`FeedContentState`). Reuse the same minichat screen/row.
- **NIP-17 DM (phase 3, later)**: kind-1111 replies must be gift-wrapped like the
kind-14s; deferred — needs an encrypted-comment path, more design.
## Phasing
1. **Phase 1 — Concord, full UX + all shared pieces.** ReplyMode enum + composer
toggle; timeline split (drop chat-rooted 1111s); "N replies" chip in the shared
`detailRow`; minichat route + screen; Concord send branch. Delivers the complete
dual-mode experience for Concord and builds every shared component.
2. **Phase 2 — public chats.** Per-message 1111 subscription for NIP-28 + NIP-29;
reuse the Phase-1 chip/screen/composer. NIP-29 already has a thread screen to
reconcile with.
3. **Phase 3 — DMs.** Gift-wrapped kind-1111 minichat for NIP-17. Deferred.
## Decisions (settled)
- **Default mode** when tapping reply: **INLINE**. User opts into MINICHAT via the toggle.
- **Minichat depth**: **flat, one level**. Replying inside a minichat roots at the
same message; no sub-threads.
- **Scope now**: **Phase 1 + 2 together** — Concord AND public chats (NIP-28/NIP-29).
DMs (phase 3) still deferred.
- **Screen styling**: **chat-styled bubbles** (reuse `ChatroomMessageCompose`) so the
minichat reads as "a chat within a chat".
## Verification
- quartz/commons unit tests for the reply-mode builders + the timeline-filter split
(a chat-rooted 1111 is excluded from the feed but present in `rootNote.replies`).
- On-device: in Concord, reply inline (stays in timeline) and reply-in-thread (opens
minichat); confirm Armada shows our minichat replies as a thread and its threads
open as our minichat; confirm the "N replies" chip count.
@@ -1,245 +0,0 @@
# WebSocket Ping Interval Study — 122 Production Relays
**Date:** 2026-07-12
**Question:** Would relays drop Amethyst's connections if the client WebSocket
ping interval were raised (e.g. 120s → 240s on mobile data to save battery)?
Was the long-standing 120s value ever load-bearing, and what is the best
middle ground?
**Answer (TL;DR):** Keep a single **120s** ping interval on every network.
Raising it to 240s saves almost no battery — 90% of surveyed relays send
their *own* pings every 3070s, which OkHttp must answer, so the radio's
wake cadence is set by the relays, not by our interval — and it starts
dropping real relay tiers: 240s pings lose `relay.ditto.pub` (~240s idle
timeout) and every `nostr1.com`-hosted relay (~300s tier); 300s pings even
lose `relay.snort.social` (~600s tier). Lowering below 120s would only
rescue a ~120s tier of 6/122 relays that already cycle today, at 2× the
ping traffic on every other connection. 120s is, by measurement, the sweet
spot it was presumably never designed to be.
---
## 1. Motivation
`OkHttpClientFactoryForRelays` sets `pingInterval(120s)` on every relay
WebSocket. During battery work the interval was tentatively doubled on
mobile data on the theory that each client ping on an otherwise-idle
cellular connection wakes the radio and pays the multi-second tail-energy
cost. The maintainer asked the right question: *do we actually know how
production relays react to different ping intervals?* Nobody had tested
the 120s value. This study answers it empirically.
Two distinct drop mechanisms are in play:
1. **Relay/reverse-proxy idle timeouts** — testable from any vantage.
2. **Carrier NAT idle timeouts** — only testable from a real cellular
network (not from this environment; see §7).
## 2. Relay population
Production relays were harvested by fetching **600 kind:10002 (NIP-65)
relay-list events** from indexer relays (`indexer.coracle.social`,
`user.kindpag.es`) and counting `r`-tag references: **1,468 distinct
relays**, ranked by how many users actually list them. The **top 140**
(plus all Amethyst default relays) formed the test population.
- **122 relays accepted a WebSocket** from the test vantage.
- 18 were unreachable *from a datacenter IP* (Cloudflare 403 challenges:
`nostr.wine`, `relay.0xchat.com`; TCP resets: `relay.nostr.band`,
`nostr.bitcoiner.social`, `relayable.org`, `nostr.fmt.wiz.biz`; plus
ordinary 5xx/410s). These blocks are IP-reputation-based, not
ping-related, and don't affect the conclusions — but they mean the
study cannot speak for those relays.
## 3. Method
Three experiments, all through the same stack (Python `websocket-client`,
TLS, one REQ per connection whose filter matches nothing, so the relay
answers EOSE and the connection then carries zero application traffic).
Server pings were always answered with pongs automatically (as OkHttp
does) and logged.
- **Phase A — idle survival.** 140 relays, **zero client pings**, hold
for **780s (13 min)**. Records: drop time, close code, server-ping
timestamps. A relay surviving 780s of total client-ping silence proves
*any* client interval ≤ 780s is safe for it.
- **Phase B — ping efficacy.** Every Phase A dropper re-tested with
client pings at **55 / 110 / 120 / 180 / 240 / 300s** (one connection
per interval, window = observed idle timeout + 2 ping cycles + margin,
capped at 780s). This distinguishes "pings reset the relay's idle
timer" from "only data frames count".
- **Case study —** `relay.ditto.pub` with 60s pings for 420s (it had
dropped an idle connection at 257s while *its own* ping got our pong at
123s — proving pongs don't reset its timer but client pings do).
## 4. Phase A results — idle survival with zero client pings
**99 of 122 relays (81%) survived 13 minutes of complete client-ping
silence.** For four out of five relays, the client ping interval is
irrelevant to connection survival at any plausible value.
The 23 droppers cluster into clean idle-timeout tiers:
| Tier | Count | Relays |
|---|---|---|
| < 30s (probe rejected / non-idle close) | 2 | `nostr.petrkr.net/strfry`, `next.nsite.run` |
| **~60s** | 8 | `nostr.pareto.space` (47s), `nostr.vps.satsnode.xyz` (×2), `relay.mostro.network`, `nostr.bond/alpha`, `nostr.sgiath.dev`, `nostr.bitcoinplebs.de`, `nostr.schneimi.de` |
| **~120s** | 8 | `cfrelay.snowcait.workers.dev` (118s), `nostr-verified.wellorder.net` (120.7s), `nostr-pub.wellorder.net` (120.8s), `git.shakespeare.diy` (125.7s), `nostr-relay.irgenius.org` (126.0s), `nostr-verif.slothy.win` (126.1s), `nostr.bit4use.com` (126.6s), `sendit.nosflare.com` (131.4s) |
| **~240s** | 1 | `relay.ditto.pub` (240.9s; 257.1s in an earlier run) |
| **~300s** | 2 | `david.nostr1.com` (300.5s), `dkkc.nostr1.com` (300.7s) — i.e. the **nostr1.com / relay.tools hosting tier** |
| **~600s** | 2 | `nos.lol/<haven path>` (600.8s), `relay.snort.social` (601.0s) |
### Server-ping cadence (the finding that reframes the question)
Among the 99 relays that held an idle connection for the full window:
| Server→client ping cadence | Relays |
|---|---|
| ≤ 35s | 45 |
| 3670s | 37 |
| ~300s | 8 |
| no server pings at all | 9 |
**90 of 99 relays ping the client; 82 of them every ≤ 70s.** OkHttp
answers every server ping with a pong regardless of the client-side
`pingInterval`. So on a connected cellular device the radio is being
woken every 3070s *per connection* by the relays themselves. Changing
the client interval from 120s to 240s does not change that cadence at
all — the client ping is a rounding error in the connection's keepalive
traffic. **The claimed battery saving of a longer client ping interval
does not exist in practice.** (Corollary: the real mobile-battery lever
is connected time and connection count in the background — which the
app already minimizes by disconnecting 30s after backgrounding — not
the ping schedule.)
## 5. Phase B results — which client intervals keep the droppers alive
For every idle-dropper, one connection per candidate interval
(`x@T` = dropped at T seconds despite pinging at that interval;
`skip` = interval ≥ observed idle timeout, unsafe by construction):
| relay | idle-drop | 55s | 110s | 120s | 180s | 240s | 300s | max safe |
|---|---|---|---|---|---|---|---|---|
| nostr.pareto.space | 46.9s | skip | skip | skip | skip | skip | skip | none |
| nostr.vps.satsnode.xyz | 51.1s | skip | skip | skip | skip | skip | skip | none |
| nostr.vps.satsnode.xyz/… | 51.2s | skip | skip | skip | skip | skip | skip | none |
| relay.mostro.network | 60.5s | x@60.8 | skip | skip | skip | skip | skip | none |
| nostr.bond/alpha | 60.8s | x@60.9 | skip | skip | skip | skip | skip | none |
| nostr.sgiath.dev | 60.8s | x@60.9 | skip | skip | skip | skip | skip | none |
| nostr.bitcoinplebs.de | 60.9s | x@61.1 | skip | skip | skip | skip | skip | none |
| nostr.schneimi.de | 62.2s | x@61.1 | skip | skip | skip | skip | skip | none |
| cfrelay.snowcait.workers.dev | 118.2s | x@85.0 | x@74.5 | skip | skip | skip | skip | none |
| nostr-verified.wellorder.net | 120.7s | **OK** | x@120.7 | x@120.8 | skip | skip | skip | 55s |
| nostr-pub.wellorder.net | 120.8s | **OK** | x@120.8 | x@120.8 | skip | skip | skip | 55s |
| git.shakespeare.diy/… | 125.7s | **OK** | x@125.9 | x@126.1 | skip | skip | skip | 55s |
| nostr-relay.irgenius.org | 126.0s | **OK** | x@125.8 | x@125.9 | skip | skip | skip | 55s |
| nostr-verif.slothy.win | 126.1s | **OK** | x@126.1 | x@126.3 | skip | skip | skip | 55s |
| nostr.bit4use.com | 126.6s | **OK** | x@126.4 | x@126.5 | skip | skip | skip | 55s |
| sendit.nosflare.com | 131.4s | x@81.7 | x@41.9 | x@7.0 | skip | skip | skip | none |
| **relay.ditto.pub** | 240.9s | OK | OK | **OK** | x@673.5 | **x@609.8** | skip | **120s** |
| **david.nostr1.com** | 300.5s | OK | OK | **OK** | x@300.6 | **x@300.7** | x@300.7 | **120s** |
| **dkkc.nostr1.com/…** | 300.7s | OK | OK | **OK** | x@300.7 | **x@301.1** | x@300.6 | **120s** |
| nos.lol/<haven path> | 600.8s | OK | OK | OK | OK | OK | x@602.1 | 240s |
| **relay.snort.social** | 601.0s | OK | OK | OK | OK | OK | **x@607.7** | 240s |
Key observations:
1. **A client ping interval numerically below the idle timeout is NOT
sufficient.** 180s and 240s pings failed against the ~300s
`nostr1.com` tier, and 300s pings failed against the ~600s
`snort.social` tier, even though each ping "should" have arrived in
time. The empirical rule across every tier: **pings only reliably
reset a relay's idle timer when the interval is at most roughly half
the timeout.** (Likely cause: these stacks check activity in coarse
windows rather than resetting a precise per-frame deadline, so an
interval near the window size loses boundary races.)
2. The **~60s tier is unsalvageable** — even 55s pings didn't help
(their timers count only data frames). These 8 relays drop idle
Amethyst connections *today* under the 120s setting and would under
any setting; the existing reconnect-on-demand path is the correct
handling for them.
3. The **~120s tier is only rescued by ≤55s pings** — meaning
**today's 120s interval never kept them alive either** (110s and
120s pings both failed). They cycle today; they'd cycle at 240s.
No candidate change affects them.
4. The tiers that DO depend on our ping interval are exactly
**ditto (~240s), nostr1.com (~300s), and snort/nos.lol-haven
(~600s)** — and 120s holds all of them, while 240s loses the first
two and 300s loses all three.
Connections kept alive (of 122 reachable), by candidate interval:
**55s → 110 · 120s → 104 · 240s → 101 · 300s → 99.**
The `relay.ditto.pub` case study confirms the mechanism: with an idle
connection its *own* ping at t=123s received our pong and it still
closed at 257s (pongs don't count as activity), but with 60s client
pings it stayed up indefinitely (client pings do count).
## 6. Why not go lower than 120s?
55s pings would rescue the ~120s tier (6 relays). But:
- those relays already cycle today, so the status quo loses nothing;
- 55s pings double the client-ping traffic on all ~100+ connections to
rescue 5% of relays whose operators chose aggressive timeouts;
- the radio is already woken every ≤70s on 82/122 connections by server
pings, so the *incremental* battery cost is modest — but so is the
benefit, and drop/reconnect for those 6 relays is already handled
gracefully by `BasicRelayClient`'s backoff + the keep-alive sweep.
A per-relay adaptive interval (shorten pings only for relays observed to
drop idle connections) is possible future work, but OkHttp's
`pingInterval` is per-client, not per-socket, so it would require
per-relay client instances — not worth the complexity for 6 relays.
## 7. Carrier NAT — the part this study cannot measure
The other purpose of client pings is keeping carrier NAT/firewall
mappings alive on cellular. That is untestable from a datacenter vantage.
Published measurements and platform folklore put aggressive carrier TCP
idle timeouts around 45 minutes (most are 1530 min; FCM survives on
~28 min heartbeats *with OS cooperation Amethyst doesn't get*). 120s
sits comfortably inside even the aggressive bound, so relay-side and
NAT-side constraints agree on the same answer. Anyone wanting to raise
the interval later must first re-run Phase B *and* validate on real
cellular networks — the relay data alone already rules out 240s.
## 8. Decision
- **`WEBSOCKET_PING_INTERVAL_SECS = 120`, one value for wifi and mobile.**
The tentative 240s mobile value was reverted in this same branch after
these measurements: it saved ~nothing (server pings dominate radio
wakes) and dropped the ditto and nostr1.com tiers.
- OkHttp's `pingInterval` doubles as the dead-connection detector (a
missed pong fails the socket within one interval), so 120s also keeps
failure detection twice as fast as 240s would — relevant after silent
network path changes.
## 9. Reproduction
Vantage caveats: datacenter egress IP (18 relays refused it), all
traffic via an HTTP CONNECT proxy. A control connection with 100s pings
survived every window, ruling out proxy-imposed idle limits ≤ 780s.
Sketch (Python `websocket-client`): open `wss://` to each relay, send
one REQ whose filter matches nothing (`{"kinds":[1],"authors":["00…01"],
"limit":1}`), auto-pong server pings, and either never ping (Phase A,
780s window) or ping at the candidate interval (Phase B). Log connect /
EOSE / server-ping / close timestamps. Population: top-N relays by
`r`-tag frequency across kind:10002 events fetched from indexer relays.
## Appendix — Phase A survivor cadences (99 relays)
Server-ping cadence measured over the 13-minute window. `none` means the
relay sent no pings at all and still held the idle connection.
| cadence | relays |
|---|---|
| ~2535s | `articles.layer3.news`, `aegis.relayted.de`, `assistantrelay.rodbishop.nz`, `bots.utxo.one`, `custom.fiatjaf.com`, `dev.calendar-relay.edufeed.org`, `greensoul.space` (×2), `groups.0xchat.com`, `groups.satsdisco.com`, `h.codingarena.top/inbox`, `haven.calva.dev/inbox`, `haven.nostrfreedom.net`, `haven.relayted.de`, `hist.nostr.land`, `lang.relays.land` (×3), `nexus.libernet.app`, `nip17.com`, `nostr-01.uid.ovh`, `nostr-relay.derekross.me` (×2), `nostr.damupi.com/inbox`, `nostr.easydns.ca`, `nostr.kfx.fr` (×2), `nostr.land`, `nostr.nothing.is-lost.org/haven`, and 17 more at ~30s; `nostrelites.org`, `purplepag.es`, `relay.noswhere.com` at ~30s |
| ~5570s | `nostr.thalheim.io`, `nostr.xmr.rocks`, `offchain.pub`, `relay.mostr.pub`, `relay.nostr.net`, `relay.primal.net`, `relay.damus.io`, `indexer.coracle.social`, `directory.yabu.me`, `user.kindpag.es`, `profiles.nostr1.com`, `nostr.oxtr.dev`, and ~25 more |
| ~300s | `nostr-relay.corb.net`, `nostr.001.j5s9.dev`, `nostr.8777.ch`, `nostr.einundzwanzig.space`, `nostr.mikoshi.de`, `nostr.pbfs.io`, `nostr.sectiontwo.org`, `nostr.wild-vibes.ts.net` |
| none | `nos.lol`, `nostr.mom`, `relay.divine.video`, `relay.fountain.fm`, `koru.bitcointxoko.org`, `nostr-pr02.redscrypt.org`, 2 × Cloudflare-Workers relays, 1 other |
Raw JSON for both phases (per-relay timestamps, close codes, server-ping
series) was captured during the study session; the tables above are the
complete decision-relevant summary.
@@ -1,178 +0,0 @@
# Resource Usage Ledger — battery/data accounting, user-visible + NIP-17 reportable
**Date:** 2026-07-12
**Goal:** Let users (and developers) see how much network, connection time, and
background activity the app consumes, per subsystem — and let a user send that
data to the developers over NIP-17, reusing the crash-report consent pattern.
When consumption crosses "something is wrong" thresholds, proactively ask the
user (rate-limited, opt-out-able) whether they'd like to send a report.
Background: the 2026-07-12 ping-interval study (see
`2026-07-12-relay-ping-interval-study.md`) showed the dominant energy proxy is
connection-time (relays server-ping every 3070s while connected) and that
battery bugs are production-only phenomena — so the ledger ships in release,
collects passively, and never transmits anything without an explicit user
action.
## Survey (existing components reused)
- **Send path** — the crash-report pipeline: `DisplayCrashMessages` prefills
the NIP-17 DM composer via `routeToMessage(user = <dev pubkey>, draftMessage,
expiresDays = 30)`; the user taps Send; `Account.sendNip17PrivateMessage`
gift-wraps to the recipient's kind-10050 DM relays. Reused as-is — the
ledger only builds a different draft string.
- **Persistence idiom** — `ScheduledPostStore` (Jackson + Mutex + tmp-rename +
version envelope + StateFlow). Cloned as `ResourceUsageStore`.
- **Relay traffic** — counted by a new `RelayConnectionListener`
(same hook `RelayStats` uses), NOT by modifying quartz.
- **Connection time** — integrated from `INostrClient.connectedRelaysFlow()`
(exact between emissions; no timers).
- **Network class** — `ConnectivityManager.isMobileOrFalse` StateFlow.
- **Foreground** — new tiny `ForegroundTracker` (ActivityLifecycleCallbacks →
StateFlow<Boolean>), registered next to `AppForegroundRecycleHook`;
`MainActivity.isResumed` is not observable and slightly stricter than
process-foreground.
- **HTTP subsystems** — `RoleBasedHttpClientBuilder` already funnels every
role (image/video/uploads/money/nip05/preview/push) through two shared
clients; a cached per-role `newBuilder().addInterceptor(counting)` wrapper
gives per-subsystem byte attribution without touching the shared clients.
- **UI idioms** — `NotificationSettingsScreen` structure (`Scaffold` +
`TopBarWithBackButton` + `SettingsSection` cards), route in `Routes.kt`,
`composableFromEnd` registration, catalog entry via
`SettingsCatalogBuilder.symEntry` (icon: existing `MaterialSymbols.Bolt`
no font regen).
- **App-open dialog** — `DisplayCrashMessages` pattern, mounted in the same
`AppNavigation` block.
## Design
### Counters
Flat `Map<String, Long>` per UTC epoch-day, retained ~30 days. Key grammar:
`<area>...<mobile|wifi>.<fg|bg>[.<rx|tx>]`, e.g.:
- `net.image.mobile.bg.rx` — bytes downloaded by the image subsystem on
cellular while backgrounded (same for video/uploads/money/nip05/preview/push)
- `relay.msg.wifi.fg.rx|tx` — approx relay websocket payload bytes
- `relay.connms.mobile.bg` — relay-connection-milliseconds (Σ relays × time)
- `wakelock.notif.ms` / `wakelock.notif.count`
- `worker.scheduledPost.runs` / `worker.calendarReminder.runs` /
`worker.notificationCatchUp.runs`
- `app.starts` — process starts (detects WorkManager cold-start churn)
- `relay.connects.<net>.<vis>` / `relay.connfails.<net>.<vis>` — completed
(re)connections and failed dials; each connect paid a TCP+TLS handshake,
so high daily counts are the reconnect-churn signature
- `cpu.ms` — whole-process CPU time deltas ([android.os.Process
.getElapsedCpuTime] sampled at flush): the honest aggregate of parsing,
crypto, coroutines, and UI without per-subsystem guesswork
- `app.fgms` — time with UI visible; display power is proportional to it and
it's the denominator for every per-day comparison
- `crypto.verify.count` / `crypto.verify.us` — event signature verifications
(LocalCache.justVerify hook), settling "does Schnorr verify cost matter"
with data
- `net.<role>.<net>.<vis>.reqs` / `.activems` — HTTP request counts and
active-transfer time per subsystem; counting lives on the shared base
client (OkHttpClientFactory) with tag-based role attribution, so untagged
callers land in `other` instead of escaping the ledger
- `net.bursts.<net>.<vis>` — estimated radio wake-ups from HTTP burst
patterns (new activity after >10s of HTTP silence): the battery-relevant
measure that bytes alone can't capture, since scattered small requests
each pay the radio ramp+tail
- `media.playms` — actual media playback time (ExoPlayer isPlaying
segments): decoder + screen + streaming at once, the denominator for
video bytes
- `pow.ms` / `pow.sessions` — NIP-13 mining time (any job mining in the
PoW queue): full-core CPU, the largest attributable CPU consumer
- `tor.ms` / `tor.starts` — in-app (Arti) Tor uptime and bootstraps, from the
raw TorService status (NOT TorManager.status, whose WhileSubscribed
upstream calls service.start() when collected). External Tor (Orbot) is
deliberately untracked — its battery belongs to Orbot
- `service.alwayson.ms` — NotificationRelayService uptime: the mode context
that explains a device's relay connection-time
- `call.ms`/`call.sessions`, `nests.ms`/`nests.sessions` — calls and NIP-53
audio rooms (mic + Opus + live media connection), from the foreground
services' lifecycles
- `location.ms` — time actively listening for GPS updates (geohash tagging);
mostly a tripwire for a leaked location subscription
- `crypto.decrypt.count/us`, `crypto.encrypt.count/us` — NIP-04/44 work via a
MeteringNostrSigner decorator wrapped inside NostrSignerWithClientTag at
account load; durations metered only for local-key signers (external/
remote waits are IPC/network, not CPU)
- `sign.local|nip46|nip55.count` — signatures by signer kind: NIP-46 is a
relay round-trip and NIP-55 an Amber IPC wake, so the kind is the
battery-relevant dimension (this supersedes "signing is negligible", which
is only true for local keys)
- `battery.drain.fg|bg` — measured battery percent while discharging, sampled
at flush from BatteryManager: NOT app-isolated, but the ground truth that
report corpora can correlate the other counters against
- `screen.<Name>.ms` — foreground time per screen, added after the original
privacy review: only the route's base NAME is recorded (screenNameOf strips
every navigation argument before the value leaves the nav layer), so the
ledger can say "Profile" but never whose profile
Deliberately not tracked (v1): per-coroutine or per-dispatcher CPU (needs a
thread registry; `cpu.ms` answers whether CPU matters at all first).
(Two earlier v1 exclusions were later revisited: per-screen time ships with
names-only privacy as above, and signing is now counted per signer kind
because NIP-46/NIP-55 signatures are network/IPC round-trips, not local CPU.)
Flat keys keep the store schema-free: new counters need no migration.
### Components (`amethyst/.../service/resourceusage/`)
- `UsageKeys` — key constants/builders + dimension helpers.
- `ResourceUsageStore` — daily buckets on disk (`resource_usage.json`),
`mergeInto(day, deltas)`, `allDays()`, prune, plus alert state
(lastAlertAtSec, optOut).
- `ResourceUsageAccountant` — in-memory `ConcurrentHashMap<String, LongAdder>`
hot path (`add()` is called per relay frame), debounced flush (30s) into the
store, day-rollover handling, merged read API for UI/report.
- `ForegroundTracker` — startedActivities>0 as StateFlow.
- `RelayUsageListener``RelayConnectionListener` counting sent/received
frame sizes with current network/visibility dims.
- `RelayConnectionTimeIntegrator` — combines connectedRelays × isMobile ×
isForeground; closes an accounting segment on every change and on
`closeOpenSegment()` (called from accountant flush and reads, so multi-hour
stable background sessions still account without any timer).
- `UsageCountingInterceptor` + counting response body — per-role HTTP bytes;
wrapped clients cached per (role, base client identity).
- `ResourceUsageReportAssembler` — Markdown: device/app header (crash-report
style), human summary (today + 7 days), fenced per-day counter dump.
- `ResourceUsageAlerts` — pure threshold logic (see below) + rate limiting.
- `DisplayResourceUsageAlert` — consent dialog (view details / send / not
now / don't ask again).
- UI: `ResourceUsageScreen` under `ui/screen/loggedIn/settings/`.
### Wiring (AppModules / Amethyst / hooks)
- store + accountant + integrator constructed in `AppModules`; listener added
via `client.addConnectionListener`.
- `ForegroundTracker` registered in `Amethyst.onCreate` (main process only).
- `RoleBasedHttpClientBuilder` gains an optional usage meter.
- `EventNotificationConsumer.withWakeLock` gains an optional held-duration
callback (threaded through `NotificationDispatcher`).
- Workers increment their run counters via `Amethyst.instance` (guarded).
- `AppModules.trim()` flushes the accountant (backgrounding = natural flush).
### Alert thresholds (v1, deliberately conservative — tune with real reports)
Evaluated on the last *complete* day, OR today once exceeded:
- background cellular traffic > 50 MB/day
- relay connection time > 12 relay-hours/day while backgrounded on cellular
- notification wakelock held > 30 min/day
- process starts > 75/day
Rate limit: at most one prompt per 7 days; "don't ask again" persisted.
Never auto-sends: every path goes through the DM composer where the user sees
exactly what will be sent and must tap Send.
### Privacy
Counters are sizes, durations, and counts — no URLs, no relay names, no event
content. The report includes device model fields identical to the crash
report. Everything stays on-device until the user explicitly sends the DM
(NIP-40 30-day expiration, same as crash reports).
### Explicitly out of scope (v1)
- Layer 1 (Perfetto/ODPM macrobenchmarks) and Layer 2 (`TrafficStats` socket
tags) — add only if the ledger proves blind somewhere (e.g. WS bytes are
payload-approximate; TrafficStats would give exact on-wire bytes).
- Per-relay attribution in the ledger (RelayStats screens already exist).
- Desktop: accountant/store are Android-module for now; extraction to commons
is mechanical if desktop wants it.
@@ -1,124 +0,0 @@
# Chat feed scroll performance — fast fling through thousands of messages
Status: plan. Owner: chat feed (`amethyst/.../ui/screen/loggedIn/chats/feed/`).
## Problem
A fling through a long chat history composes hundreds of `ChatroomMessageCompose`
rows per second. Each newly composed row currently pays for work that is either
(a) derivable off the composition path, (b) only needed for *recent* messages, or
(c) side-effectful (coroutines, relay-filter updates) and therefore multiplies
into churn under velocity. The redesign added per-row observers (group position,
reaction chips, delivery ticks) that are individually cheap but sum up at 1000+
rows.
## What is already fine (verified, don't re-litigate)
- **Rich text parse is cached**: `TranslatableRichTextViewer`
`CachedRichTextParser.parseText` behind `remember(content, tags)`; re-scrolling
past a message doesn't re-parse.
- **Engagement flows are sampled**: `observeNoteReactions/Zaps/Replies` sample at
200500ms, and the EventFinder assembler folds all subscribed note ids into a
small number of shared REQs (`SingleSubEoseManager`), not one REQ per note.
- **LazyColumn hygiene**: stable `key = idHex`, `contentType = kind`,
`animateItem()` gated by performance mode.
- **Tracker fan-out fixed**: delivery state is one small StateFlow per message.
## Per-row cost inventory (composition of ONE new row during fling)
| # | Cost | Where | Class |
|---|------|-------|-------|
| 1 | 4× `dateFormatter` (ThreadLocal `SimpleDateFormat` for >24h-old messages) for group break checks, + 2 more in `NewDateOrSubjectDivisor` | `ChatGroupPosition.groupsWith`, `NewDateOrSubjectDivisor` | CPU, per row |
| 2 | 3× `collectAsStateWithLifecycle` on metadata flows | `watchChatGroupPosition` | collector churn |
| 3 | `LaunchedEffect``loadAndMarkAsRead(route, createdAt)` coroutine | `NormalChatNote` | 1 coroutine/row; read-marker lock churn |
| 4 | `LaunchedEffect``accountViewModel.decrypt(note)` for the jumbo flag | `NormalChatNote` | 1 coroutine/row, even for plaintext kinds |
| 5 | Reaction observer + zap observer subscriptions (ids added to shared relay filters; filter update per batch) | `ChatReactionChips`, `ObserveZapAmountText` | relay REQ re-issue churn under velocity |
| 6 | 2× flow collectors for delivery ticks on own messages, forever, even for long-settled history | `ChatDeliveryTicks` | collector churn |
| 7 | Per-row animation/gesture state: `animateColorAsState`, press `InteractionSource`, swipe `pointerInput`, highlight `LaunchedEffect` | `ChatBubbleLayout` | allocation, mostly unavoidable |
| 8 | Media/link previews kick Coil requests immediately | `TranslatableRichTextViewer` children | IO churn during fling |
Feed-level:
| # | Cost | Where |
|---|------|-------|
| 9 | `shouldHighlight = highlightedNoteId.value == item.idHex` reads one state in every item lambda → all visible items recompose when it changes (rare; low) | `ChatFeedLoaded` |
| 10 | `onScrollToNote` recreated when `ChatFeedLoaded` recomposes → unstable param defeats item skipping | `ChatFeedLoaded` |
| 11 | Feed invalidation rebuilds the sorted list; every reaction arriving during scroll can reorder/emit | `FeedContentState` (existing infra, sampled) |
## Plan
### Phase 0 — Measure first (do not skip)
1. **Macrobenchmark**: add a `benchmark` scenario that seeds `LocalCache` with
510k synthetic `ChannelMessageEvent`s (mixed: text, links, emoji-only, a few
with reactions/zaps) and flings the public-chat screen. Metrics:
`frameDurationCpuMs` P50/P90/P99, jank %, `frameOverrunMs`.
2. **Composition tracing / recomposition counts** on a seeded room; compose
compiler reports (`composables.txt`) for `ChatroomMessageCompose`,
`NormalChatNote`, `InnerChatBubble`, `ChatReactionChips` — verify skippability
and find unstable params (expect #10).
3. Record baseline numbers in this file before changing anything.
### Phase 1 — Kill per-row CPU and coroutines (expected biggest wins)
4. **Day-stamp grouping (#1)**: replace `dateFormatter` equality in `groupsWith`
with an epoch-local-day integer comparison (`(createdAt + zoneOffsetSeconds) / 86400`),
and give `NewDateOrSubjectDivisor` the same predicate so the break condition
stays mirrored (only its *display* string needs formatting, and only when a
divider actually renders). Zero `SimpleDateFormat` on the scroll path.
5. **Hoist read-marking (#3)**: replace the per-row `LaunchedEffect` with one
feed-level `snapshotFlow { listState.firstVisibleItem createdAt }`-driven
marker update (only the newest visible timestamp matters; the marker is
monotonic). One coroutine per scroll session instead of one per row.
6. **Jumbo without a coroutine (#4)**: only launch the decrypt effect for
encrypted kinds (`PrivateDmEvent`, sealed rumors not yet in the decrypt
cache). Plaintext kinds (public chats, NIP-17 rumors already unwrapped —
the vast majority) take the synchronous path only.
7. **Retire settled delivery ticks (#6)**: once a message is fully accepted (or
untracked and older than the tracker window), render the tick from a
`remember`ed terminal value and drop both collectors. Only in-flight sends
keep live flows.
8. **Group position with fewer collectors (#2)**: collect the three metadata
flows only while any of the three notes is missing `event`/`author`
(the common case for history is all-loaded → pure `remember`, no collectors).
### Phase 2 — Tame side-effect churn under velocity
9. **Defer new relay-filter membership while flinging (#5)**: gate
`EventFinderFilterAssemblerSubscription` enrollment on
`!listState.isScrollInProgress` (or debounce enrollment ~300ms): rows that
fly past never join the reaction/zap filters; rows you settle on subscribe
as today. Needs a small `LocalScrollSettled` composition local (provided by
`ChatFeedLoaded`) so `ChatReactionChips`/`ChatDeliveryTicks` can wait without
threading params. Verify with #1 measurements that filter re-issues drop.
10. **Defer media loads (#8)**: same settled-gate for the image/video preview
composables inside chat bubbles (placeholder immediately, Coil request on
settle). Coil cancels in-flight requests on dispose already, but not
starting them is cheaper than cancel.
11. **Stabilize item lambdas (#10)**: `remember(items.list, listState)` around
`onScrollToNote`; pass `shouldHighlight` via `derivedStateOf` keyed per item
(#9) so only the highlighted row recomposes.
### Phase 3 — Structural (only if Phase 1/2 measurements demand more)
12. **Precomputed row model**: build a lightweight `ChatFeedRow(note, groupPos,
dayStamp, isJumbo, isSystem)` list inside `FeedContentState` (background
thread, once per feed update) so item composition becomes pure rendering.
This subsumes #4/#8 above but is a bigger refactor of shared feed infra —
justify with numbers first.
13. **Finer contentType**: distinguish `bubble / jumbo / system / zap-card`
contentTypes so Lazy slot reuse doesn't rebuild structurally different rows.
14. **Prefetch tuning**: evaluate `LazyListPrefetchStrategy(nestedPrefetchItemCount)`
for the fling case on Compose ≥1.7.
### Phase 4 — Regression guardrails
15. Wire the Phase-0 macrobenchmark into CI (or at least a documented manual
run before releases touching the chat feed), and re-record numbers here.
## Non-goals
- Virtualizing bubble internals (Compose already skips off-screen work).
- Caching parsed rich text more aggressively (already LRU-cached).
- Changing feed sorting/invalidation infra (shared with all feeds; separate plan
if measurements point there).
@@ -1,83 +0,0 @@
# CORD-06 Refounding — real member removal for Concord
## Problem
Concord membership is key possession: a banned member (CORD-04 banlist) still
holds the community's `community_root`, so every client just *declines to show*
their posts — they can still decrypt everything. That is a soft removal. CORD-06
adds the hard removal: rotate the key so a removed member's key stops working for
anything sent afterwards.
The quartz crypto for the kind-3303 rekey blob (`ConcordRekey`, `RekeyBlob`)
already existed and was tested, but nothing in the app called it. This wires the
whole path — build, publish, receive, persist, UI — around a **Refounding**
(whole-community rotation), the removal that matters while Amethyst supports only
public channels (a per-channel rekey needs private channels, not built yet).
## What a Refounding does (CORD-06 §3)
1. Ban the removed members on the current Control Plane (so the compacted snapshot
carries the ban).
2. Roll `community_root` to a fresh random 32 bytes at `rootEpoch + 1`. Public
channels + the Control/Guestbook planes all derive from the root, so rolling it
rotates every plane at once.
3. Republish the **compacted** Control Plane under the new root — keep only each
entity's head edition and re-wrap its *original plaintext seal*, so the original
authors' signatures survive re-encryption (a fresh joiner verifies the slim
state exactly as it verified the full chain).
4. Mint per-recipient kind-3303 rekey blobs delivering the new root to every
retained member, sealed + addressed under the **prior** root on the
`base-rekey-pseudonym(prior_root, community_id, new_epoch)` address — which every
current member precomputes, so they receive it live. A removed member gets no
blob and can never derive the new root.
## Layers
- **quartz** `concord/cord06Rekey/`
- `ConcordKeyDerivation`: `baseRekeyAddress` / `channelRekeyAddress` (the rekey
stream addresses), `epochKeyCommitment` (`prevcommit`, CORD-02 §A.5).
- `ConcordRekey`: signer-based `blobForSigner` / `findNewKeyWithSigner` (bunker
accounts open a blob with one `nip44Decrypt`, no raw key).
- `ConcordRefounding`: `compactControlPlane`, `buildBaseRekeyWraps`, `build`
(whole refounding), `findNewRoot` (receive: verify scope/epoch/continuity, find
my blob). `OpenedStreamEvent` now also carries the inner `seal` so compaction
can re-wrap it. Tests in `ConcordRefoundingTest`.
- **commons**
- `ConcordActions`: `guestbookPlane` / `nextBaseRekeyPlane`, `buildGuestbookJoin`
/ `guestbookMembers`, `buildRefounding`, `openBaseRekey`.
- `ConcordCommunitySession`: folds the Guestbook plane into `members`
(the recipient set), buffers inbound base-rekey wraps (`pendingBaseRekeyWraps`),
exposes `controlPlaneWraps` for compaction, and AUTHs to + subscribes the
Guestbook and next-epoch base-rekey planes (`streamKeys`, `subscribeAddresses`).
- `ConcordSessionRegistry.sync`: rebuilds a session when its entry's root/epoch
changed — the session is a pure function of its entry, so adopting a new root is
just a persisted entry swap.
- `ConcordSubscriptionPlanner.auxiliaryPlaneSubs`: REQs the Guestbook + next
base-rekey planes for every joined community.
- **amethyst**
- `Account`: announces a Guestbook JOIN on create/join (`announceConcordGuestbookJoin`)
so members are visible to a future rotator; `refoundConcordCommunity` (owner /
BAN-holder) bans + rolls + publishes + persists; `drainConcordRekeys` (revision
tick) adopts an inbound rotation from an authorized rotator; `adoptConcordRoot`
persists the new root (prior root kept as a `HeldRoot`) and re-seeds the new
epoch's Guestbook, guarded against double-adopt.
- `AccountViewModel.removeConcordMember`; `ConcordMembersScreen` "Remove from
community" action + confirm dialog, gated exactly like Ban.
## Recipient set
The rotator re-keys **Guestbook membership the privileged roster self**, minus
the removed and the already-banned. The Guestbook is best-effort/off-consensus, so
a member who joined but whose Guestbook JOIN hasn't propagated to the rotator would
be missed and locked out — the accepted trade for a serverless, key-possession
membership model. Adopting a new root re-announces the Guestbook JOIN at the new
epoch so cascading removals keep a live membership.
## Known limitations / follow-ups
- No explicit "you were removed" detection: a removed member simply stops receiving
new content (their old-epoch keys still read history). CORD-06's "held all n
chunks, none is mine ⇒ removed" self-eviction is not implemented.
- Per-channel rekey (single private channel) is not wired — needs private channels.
- Race convergence (two rotators, same epoch, lexicographically-lowest-key wins) is
not implemented; single-rotator (owner/admin) refounding is the supported path.
@@ -1,71 +0,0 @@
# Bitchat geohash chat interop
Status: **Phase 1 (public geohash channels) shipped.** Phase 2 (encrypted DMs)
is designed but not implemented.
## What Bitchat does (verified against `permissionlesstech/bitchat`
iOS + `bitchat-android`)
Bitchat's Nostr side has two chat features. Amethyst is a pure-Nostr client
(no BLE mesh / Noise identity), so only the Nostr halves are in scope.
### Public geohash channels ("location channels") — SHIPPED
- Message = **kind 20000** (ephemeral), content = plain UTF-8 text.
Tags: `["g", geohash]` (required), `["n", nickname]` (optional),
`["t","teleport"]` (optional). Optional NIP-13 `["nonce", …]` PoW, default 8
bits, used to relax per-sender relay rate limits.
- Presence = **kind 20001**, only the `g` tag, empty content.
- Subscribe: `kinds:[20000,20001]`, `#g:[geohash]` (exact cell).
- Precision levels (geohash chars): building 8, block 7, neighborhood 6,
city 5, province 4, region 2.
- Identity = a per-geohash throwaway key `HMAC-SHA256(deviceSeed, geohash)`,
deterministic per (device, cell), unlinkable to the user's npub.
- **Relay routing is geographic and load-bearing:** a cell's traffic goes to the
5 relays nearest the cell center, chosen from the public MIT-licensed
`permissionlesstech/georelays` CSV both clients load. If Amethyst used any
other relay set its messages would not rendezvous with Bitchat clients.
### Private DMs — NOT YET IMPLEMENTED (Phase 2)
- Standard NIP-17/59: rumor kind 14, seal kind 13, gift wrap kind 1059, wrap
under a throwaway key, NIP-44 v2.
- **The kind-14 rumor content is NOT plain text.** It is
`"bitchat1:" + base64url(<binary bitchat packet>)` — a `BitchatPacket`
(TLV + a `NoisePayloadType` byte) carrying the private message, delivery ACKs,
and read receipts. Full DM interop therefore requires porting that binary
framing (`NostrEmbeddedBitChat.swift` / `NostrEmbeddedBitChat.kt`).
- Two DM flavors: geohash DMs (gift-wrapped to a participant's per-geohash
pubkey) and stable-identity DMs (to an npub learned via Bitchat's mesh
`[FAVORITED]:<npub>` handshake — mesh-specific, mostly N/A for a Nostr client).
## What shipped (Phase 1)
- **quartz** `experimental/bitchat/`: `GeohashChatEvent` (20000),
`GeohashPresenceEvent` (20001), `GeohashKeyDerivation` (per-geohash key),
registered in `EventFactory`. PoW reuses the existing `nip13Pow` `PoWTag`.
- **commons** `service/georelay/`: `GeoRelayDirectory` (closest-N by haversine,
host tie-break, `:443` dedup), `GeoRelayCsvLoader` (runtime CSV fetch + fallback).
- **amethyst**: `GeohashChatScreen` + `GeohashChatViewModel` (live subscription +
send), `GeohashChatDeviceSeed` (global encrypted seed store),
`Account.signWithAndSendPrivately`, `Route.GeohashChat`, and a chat action on
the geohash feed screen.
- **cli**: `amy geochat listen|send|keys` — the interop harness. Verified with a
live send→relay→listen round-trip (kind 20000, PoW, `g`/`n` tags intact).
## Follow-ups
1. **Encrypted DMs (Phase 2).** Port the `bitchat1:` binary packet
(`BitchatPacket` TLV + `NoisePayloadType`) into quartz, wrap/unwrap it in the
existing NIP-17 stack (`GiftWrapEvent`/`SealedRumorEvent`/`ChatMessageEvent`),
handle geohash DMs (to a per-geohash pubkey) and delivery/read receipts.
Add `amy geochat dm` for interop testing.
2. **Desktop UI.** The shared pieces (quartz events, `GeoRelayDirectory`) are
already cross-platform; add a desktop `GeohashChatScreen` equivalent.
3. **LocalCache integration (optional).** The current Android screen manages its
own subscription/state rather than routing through `LocalCache`/the chatroom
list. Integrating would give unread badges and a unified chat list, at the
cost of a `Channel`/feed-filter/datasource fork.
4. **Location-driven channel picker.** Use `LocationState` to offer the
region/province/city/neighborhood/block/building cells for the user's current
position, plus a manual/teleport entry.
5. **Presence heartbeats + i18n.** Periodically emit kind 20001 while a channel
is open; extract the hardcoded screen strings into `strings.xml`.
@@ -1,171 +0,0 @@
# Making location (geohash) chats first-class in Amethyst
Builds on `2026-07-15-bitchat-geohash-interop.md` (Phase 1 shipped: protocol,
geo-relay routing, a self-contained chat screen, `amy geochat`). This plan takes
it from a bolt-on screen to a native feature woven into Home, Messages, and the
map.
## The one constraint that shapes everything
Geohash **chat** (kind 20000) is signed with anonymous per-cell throwaway keys,
unlinkable to npubs. So "which of my follows are chatting here" is **not
derivable from the chat stream**. Any "follows are active near you" signal must
come from a *linkable* source:
- **kind-1 geohash notes** (`GeoHashFeedFilter` scans `LocalCache.notes` for
`isTaggedGeoHash`; authors are real npubs) → intersect with
`account.kind3FollowList` = genuine "follows near this place." This is the
template `HomeLiveFilter.followsThatParticipateOn` already uses, just sourced
from notes instead of chat events.
- **kind-10081 geohash follow lists** (`GeohashListEvent`) — the user's own, and
optionally follows' public lists.
- Anonymous **liveliness** (kind-20000/20001 presence counts) — "N people here",
no identities.
The Home bubble is therefore: *anonymous liveliness + follows' geo-note activity
→ tap into the cell's chat.* We will not imply the chat reveals who's there.
## Key architectural decision: reuse what exists
1. **Joined channels = the geohash follow list (kind 10081).** `account.geohashList`
(`model/nip51Lists/geohashLists/GeohashListState.kt`, `flow: StateFlow<Set<String>>`,
`follow`/`unfollow`, NIP-44-private capable) already models "geohashes I care
about." Treat *following a geohash* as *joining its location channel*. No new
list type, and it's private-capable. (Trade-off: today it also drives the
kind-1 notes feed; we're overloading one list for both "notes near here" and
"chat here." Acceptable — it's the same user intent. Alternative if we want
separation: a local, unpublished joined-set matching Bitchat's ephemerality.)
2. **Make geohash chat LocalCache-backed** so it can flow through the same feed
machinery as every other room. This is the crux of "first-class": the Messages
list, Home bubbles, unread counts, and pins all read from `LocalCache`
channels. Model it on the ephemeral-chat feature end to end.
## Phase A — Data model + LocalCache integration (foundation)
- `commons/.../model/geohashChat/GeohashChatChannel.kt` — a `Channel` subtype
keyed by geohash (mirror `model/emphChat/EphemeralChatChannel.kt`).
`toBestDisplayName()` returns the reverse-geocoded place (or `#geohash`).
- `LocalCache`: add a `geohashChannels` map + a consumer that routes
`GeohashChatEvent`/`GeohashPresenceEvent` into the channel (mirror
`ephemeralChannels`/`liveChatChannels` + `getOrCreateEphemeralChannel`).
- A rooms-list **subassembler** that subscribes to the joined geohashes
(`account.geohashList.flow``GeoRelayDirectory.closestRelays(g)`, kinds
20000/20001, `#g`) and feeds LocalCache — mirror
`chats/rooms/datasource/FollowingEphemeralChatSubAssembler.kt` +
`FilterFollowingEphemeralChats.kt`.
- Migrate `GeohashChatViewModel` to read the channel's notes from LocalCache
(via `LocalCache.observeNotes` like `NestLobbyScreen`) instead of its private
subscription — unifies the live view with the cached one and lets the screen
reuse the full `ChatroomMessageCompose` (reactions, replies) later. Keep the
send path (per-geohash signer + PoW + geo relays) as-is.
- Ephemeral caveat: relays don't store kind 20000, so joined-cell rooms only show
messages seen while subscribed. Scope background subscriptions to *joined* cells
(+ the current-location cell) to bound battery/relay load; document the cap.
## Phase B — Messages tab
- `chats/rooms/dal/ChatroomListKnownFeedFilter.kt` — add a 7th family
(`geohashChannels` from `account.geohashList`) to `feed()` + `applyFilter`
(newest message per cell), mirroring `filterRelevantEphemeralChats`.
- `chats/rooms/ChatroomHeaderCompose.kt` — add a `GeohashRoomCompose` branch in
`ChatroomEntry``nav.nav(Route.GeohashChat(geohash))`, with a location-pin
`HeaderPill`, the cell name (`LoadCityName`), and a live participant count (no
avatars — anonymous).
- `chats/rooms/NewConversationScreen.kt` — append one `ConversationType`
("Location chat", geohash icon/accent, pros/cons, `route = Route.NewGeohashChat`)
to the *Relay* section of `conversationSections` (single source of truth).
## Phase C — The builder ("+" → create/join)
- `Route.NewGeohashChat` + `NewGeohashChatScreen.kt` (mirror
`ephemChat/metadata/NewEphemeralChatScreen.kt`). Three ways to pick a cell:
1. **Current location levels.** New `GeohashChannelLevel` mapper
(region=2, province=4, city=5, neighborhood=6, block=7, building=8 chars —
the Bitchat levels; `GeohashPrecision` has the char counts but not the
names). From `LocationState.geohashStateFlow` (raise its hardcoded 5-char
precision to 8 so we can truncate to each level), list the six cells with
`LoadCityName` + a Join/Open button.
2. **Manual geohash** text field (validate against the base32 alphabet).
3. **Teleport** → the map picker (Phase E).
- Join = `account.geohashList.follow(geohash)` then `nav.nav(Route.GeohashChat)`.
- Reuse `LocationAsHash`/`ILocationGrabber` for the permission flow.
### Geohash-list management (the kind-10081 add/remove UI)
Today the **only** way to add to the kind-10081 list is the Follow toggle on the
kind-1 `GeoHashScreen` — you must already be viewing that cell. Followed cells
then appear as read-only feed chips in the Home top-nav (`TopNavFilterState`).
There is **no** screen to view the list, remove entries, or add an *arbitrary*
geohash. This builder is that missing "add" UI (it writes via
`account.geohashList.follow`, the same path). Round it out with a small manage
screen:
- `NewGeohashChatScreen` doubles as the **add** surface (current-location levels /
manual / map).
- Add a lightweight **"My location channels"** list (its own route, or a section
in the builder): render `account.geohashList.flow` with `LoadCityName` per cell,
a remove (`unfollowGeohash`) swipe/menu, and an "add" button into the builder.
This is also what Phase B's Messages rows and Phase D's Home bubble read from,
so it's the one management surface for the whole feature.
## Phase D — Home "live near you" bubble
- New feed state `homeGeohashLive` in `AccountFeedContentStates.kt` (parallel to
`homeLive`; different signal source, so not folded into `HomeLiveFilter`).
Sources: joined cells with recent activity (LocalCache, post Phase A) + the
current-location cell.
- `home/live/RenderGeohashBubble.kt` — a bubble showing the cell name, a
liveliness dot (reuse `LiveStatusIndicator` pattern; "online" = recent presence),
and social proof from **geo-notes**: "N follows posted near · M chatting"
(follows-near count = `GeoHashFeedFilter` authors ∩ `kind3FollowList`).
- `HomeScreen.kt` `DisplayLiveBubbles` — add the `GeohashChatChannel` (or a
synthetic geohash item) case to the type dispatch; click → `Route.GeohashChat`.
## Phase E — Teleport + map
- `LocationPickerMap.kt` — extend the display-only osmdroid `LocationPreviewMap`
with a `MapEventsOverlay`/`MapEventsReceiver` so long-press/tap drops a pin and
yields a coordinate → `GeoHash.encode(lat, lon, level)`. osmdroid (Apache-2.0)
already supports this; only the wiring is new.
- Teleport screen (or a mode in `NewGeohashChatScreen`): pick a point on the map,
show the resulting cell name + level selector, Open → `Route.GeohashChat`.
- **Auto-teleport flag:** in `GeohashChatViewModel`, compare the channel's geohash
to the current-location cell (`LocationState`); when they differ (or no
permission), pass `teleported = true` to the already-plumbed
`sendMessage(..., teleported)`. Add a manual override toggle in the composer.
- Optional: forward geocoding (place-name search) via
`Geocoder.getFromLocationName` — none exists today; small addition for a
"search a place" box.
## Phase F — Privacy, presence, polish
- **"Post as my real account" opt-in** (global or per-channel, with a
location-exposure warning). This is also the *only* way a follow becomes
visible in the chat itself — relevant to the Home social-proof story.
- **Presence heartbeats:** emit kind 20001 periodically while a channel is open
(`GeohashChatViewModel.announcePresence` already exists; schedule it).
- **Privacy note:** subscribing to a cell reveals interest in that location to
its relays (via the `#g` REQ from your IP); Tor mitigates. The kind-10081 list
can stay NIP-44-private.
- Extract hardcoded screen strings to `strings.xml` (`<plurals>` for the counts —
see `res/CLAUDE.md`); desktop `GeohashChatScreen` equivalent.
## Open decisions (need a call)
1. **Joined list:** reuse kind-10081 geohash follow list (recommended, private,
already wired) vs. a separate local ephemeral joined-set (closer to Bitchat).
2. **LocalCache integration depth:** full (Phase A — enables Messages/Home/unread,
bigger) vs. keep the self-contained screen and only add the builder + a
Home bubble fed by geo-notes (smaller, but not truly "in the Messages list").
3. **Default identity in these rooms:** stays anonymous per-cell (recommended);
the real-account opt-in is Phase F.
## Verification
- Unit: `GeohashChannelLevel` bucketing, the joined-list ↔ subscription wiring,
the geo-note follow-intersection count.
- `amy geochat` remains the wire-level interop check against a real Bitchat cell.
- Drive the app: join a cell from the "+" chooser, confirm it appears in Messages,
post/receive, teleport via the map and confirm the `["t","teleport"]` tag, and
confirm the Home bubble reflects a follow's kind-1 geo-note near you.
@@ -1,184 +0,0 @@
# NIP-46 Signer — device verification checklist
Everything below is behavior that JVM unit tests **cannot** exercise: interactive
consent dialogs, the foreground service, real relay traffic, deep links, and
cross-app interop. The protocol/authorization logic underneath is covered by
`quartz` (`NostrConnectSignerServiceTest`) and `commons`
(`Nip46PermissionAuthorizerTest`, `Nip46ConsentIntegrationTest`) unit tests; this
list is the manual pass that earns "first-class" on a real device.
Run as the signer on one device/account ("bunker"); use a second app/account as
the client.
## Pairing
- [ ] **Bunker flow**: Settings → Nostr Signer → turn on → scan/copy the
`bunker://` QR into a client (nsec.app, Coracle, Nostrudel, or a second
Amethyst via `amy login bunker://…`). Client resolves your npub via
`get_public_key`.
- [ ] **NostrConnect flow**: client shows a `nostrconnect://` code → "Scan a
code" on the signer screen pairs it and the signer turns on.
- [ ] **NostrConnect informed consent**: pairing a `nostrconnect://` offer that
carries `perms=` shows a connect sheet listing the app's requested
permissions (e.g. "Sign notes (kind 1)", "Decrypt messages") + a trust
picker BEFORE anything is granted. Approving pre-grants exactly those ops
(unless Paranoid); Cancel/Block declines and nothing is registered. A
re-pair of a known app skips the sheet and keeps prior decisions.
Repro: `amy login --nostrconnect --perms sign_event:1,nip44_encrypt`
prints an offer that carries exactly those perms (see CLI interop driver).
- [ ] **Global scanner**: scan a `nostrconnect://` from the profile/search
camera → lands on the signer screen and pairs.
- [ ] **Deep link**: tap a `nostrconnect://` link (web/other app) → Amethyst
opens the signer screen and pairs (cold start AND already-running).
## Note preview in the sign dialog (device-only)
- [ ] A `sign_event`/publish request renders the unsigned event as a **NoteCompose
preview** (text + media + mentions, authored by the signing account), with
the "Show event" JSON toggle still available below it.
- [ ] Works for both a NIP-46 remote app and a napplet Publish/SignEvent.
- [ ] When the main Activity is gone (app fully backgrounded, only the signer
foreground service alive → `CallSessionBridge.accountViewModel` is null),
the dialog falls back to the plain content quote + JSON without crashing.
- [ ] **Risk to watch:** NoteCompose is feed UI rendered inside a standalone
dialog Activity; if it reads a CompositionLocal only provided by the main
scaffold it could crash at runtime (compiles fine). Verify on device; if it
misbehaves, the JSON fallback path is one boolean away.
## Entry point + connected-apps management (2026-07-16)
- [ ] **Drawer entry**: the signer opens from the left drawer's "You" section, directly
under Wallet (moved out of Settings). It's also available as a bottom-bar favorite.
- [ ] **Dedicated apps screen**: "Manage connected apps" on the signer screen opens a
NIP-46-only list (name, npub, relay count, last-used, trust chip), separate from the
napplet/nsite/browser Connected Apps screen. NIP-46 apps no longer appear there.
- [ ] **Idle auto-forget**: an app left unused for 7 days is dropped on the next signer
start (its background relay subscription goes with it); an app still signing is kept.
## Comes-to-front on a request (2026-07-16)
- [ ] **Backgrounded surfacing**: with Amethyst fully backgrounded (Android 12+), a client
signing/connect request pops the consent dialog — via a full-screen-intent notification
on the high-importance "Signing requests" channel (the `startActivity` fast path is
BAL-blocked when backgrounded). On a locked screen it launches straight to the dialog;
while actively on another app it shows a heads-up prompt to tap.
- [ ] **Foreground**: with Amethyst in the foreground the dialog opens directly (no extra
notification — `SignerConsentNotifier` no-ops when `foregroundTracker.isForeground`).
- [ ] **Android 14+ caveat**: `USE_FULL_SCREEN_INTENT` is restricted for non-calling apps,
so the FSI may degrade to a heads-up rather than auto-launch — verify the prompt still
arrives and is tappable. Requires notification permission (already needed for the
always-on service).
## Consent (Tier 1)
- [ ] **First-connect trust picker**: a bunker-flow connect with a valid secret
shows the trust-level dialog (Full trust / Reasonable / Paranoid) BEFORE any
signing; choosing a level records it in Connected Apps.
- [ ] **Cancel/Block**: dismissing the connect dialog rejects the connection (no
silent grant).
- [ ] **Per-op ASK**: with a REASONABLE app, ask the client to sign a
**kind 0 / kind 3 / delete (5)** or **decrypt a DM** → the per-op dialog
appears (these are excluded from the auto-allowed set).
- [ ] **Remember variants**: "allow for this op" stops re-prompting; "session"
stops until the signer restarts; "24h/30d" expire; "deny for op" sticks.
- [ ] **PARANOID app** prompts on every request; **FULL_TRUST** never prompts.
- [ ] **Timeout**: ignore a per-op dialog for 2 minutes → the request fails
closed (deny) and the signer keeps serving later requests (not wedged).
## Anti-spam rotation (already shipped)
- [ ] "New address" → confirm dialog → old `bunker://` goes dark, connected apps
drop, QR updates; re-pairing a legit app keeps its trust level.
## Visibility (Tier 2)
- [ ] Signer screen shows "Signing as npub1…", a live "Recent activity" feed
(signed kind N / encrypted / decrypted / shared pubkey, green/red dot,
relative time), and per-app history on the Connected-App detail screen.
- [ ] The Connected-App detail screen for a remote client shows its name/url,
not a raw `nip46:` coordinate.
## Reliability (Tier 3)
- [ ] **Relay health**: kill connectivity → status shows "X of N relays
connected"; restore → "all connected".
- [ ] **Boot restart**: enable the signer, reboot the device → the foreground
service comes back and the signer answers a request without reopening the
app. (Same for an app update via `MY_PACKAGE_REPLACED`.)
- [ ] **Doze/background**: after ~30 min idle in Doze, a request still gets
serviced (may lag by a relay reconnect).
## Interop matrix
Pair + sign + nip44 encrypt/decrypt + logout against each:
- [ ] nsec.app
- [ ] Coracle
- [ ] Nostrudel
- [ ] snort / other NIP-46 client
## CLI interop driver (`amy`) — added 2026-07-17
The `cli` module (`amy`) drives the same quartz/commons code, so it plays either
side of every NIP-46 flow for reproducible interop tests without a second phone.
All of it reuses `Nip46PermissionAuthorizer.parsePerms` / `toSignerOp` — no
protocol logic in `cli`. See `amy --help` (the `Remote signing (NIP-46)` block).
Amy as the **client** (Amethyst is the signer):
- `amy login bunker://…` — pair against Amethyst's advertised `bunker://`, then
every `amy` signing verb routes through it. Surfaces `auth_url` challenges to
stderr, so it completes even when Amethyst defers consent.
- `amy login --nostrconnect [--perms sign_event:1,nip44_encrypt,…]` — mint an
offer for Amethyst to scan. `--perms` is what exercises the app's
**informed-consent** sheet (the offer carries the declared ops).
Amy as the **signer** (Amethyst, or any client, is the client):
- `amy bunker` — headless auto-approve signer for the operator's own key.
- `amy bunker --perms sign_event:1,nip44_encrypt` — restricted signer: allows
only the listed ops, **rejects** the rest. Use to test how the app-as-client
handles a signer that says no.
- `amy bunker --interactive` — keeps listening and prompts `y/N` per request on
the terminal (TTY-only, default-deny, prompts serialized). Composes with
`--perms` (auto-allow the listed ops, prompt for the rest = the "Reasonable"
policy on the CLI).
## Audit findings — known limitations (2026-07-16)
An adversarial review of the signer logic surfaced these. The head-of-line
issues below share one root cause: `authorize()`/`onConnect()` run **inline** in
`NostrConnectSignerService`'s single-consumer loop, and relay-set changes restart
that loop via `collectLatest`.
- **FIXED — unbounded first-connect prompt.** `Nip46ConsentBridge.requestConnect`
now has the same 120s `withTimeoutOrNull` as `requestOp`, so an ignored
first-connect dialog can no longer wedge the loop forever.
- **FIXED — consent no longer blocks other clients (needs on-device
validation).** The service now fans each request out into a child coroutine
under a `Semaphore(maxConcurrentHandles=16)`; dedup/staleness/rate-limit stay on
the single consumer, only `handle()` runs concurrently. So a request awaiting a
prompt no longer stalls auto-allowed traffic, and several prompts can be pending
at once. Two guards keep this safe: (1) the identity signer's crypto is
serialized by `BunkerRequestProcessor.cryptoLock` — authorization (the prompt)
runs UNLOCKED, only the sign/encrypt/decrypt holds the lock — so an external
NIP-55 app never sees concurrent IPC ops; (2) first-connect consent is
serialized by `Nip46PermissionAuthorizer.connectLock` so two connects can't stack
dialogs. Per-op prompts batch: the shared `SignerConsentCoordinator.pending`
flow drives one dialog (1 pending) or a checkbox list (>1). Covered by
`BunkerRequestProcessorConcurrencyTest`, but the on-device paths below still need
a real run:
- [ ] **Burst batching:** a client fires several dangerous-kind requests at once
→ one batched sheet with checkboxes + select-all, Allow/Deny selected,
"Remember" toggle. Approving a subset leaves the rest pending.
- [ ] **Auto-allowed keeps flowing:** while a prompt sits open, a REASONABLE
auto-allowed request from another app still gets signed and answered.
- [ ] **No concurrent external-signer ops:** with a NIP-55 external signer, two
approved requests do not drive overlapping IPC (they serialize).
- [ ] **Fail-closed on dismiss:** backing out of the batched sheet denies every
still-open request (not just the selected ones).
- **Relay-set change cancels in-flight work.** A `logout` (or a new nostrconnect
pairing) mutates the listen set → `collectLatest` restarts the service →
cancels the in-flight `handle()`. Practical impact is low (a logout ACK is lost
but the client is leaving; a pairing-time cancel makes other clients retry).
Proper fix: manage subscriptions incrementally (diff add/remove) instead of a
full restart. Deferred (same reason).
- **Low-severity, left as-is:** activity-log records an O(capacity) list copy per
serviced request (negligible under rate-limiting); the per-author rate limiter
evicts by insertion order rather than LRU (the 3-arg `accessOrder`
`LinkedHashMap` isn't in KMP commonMain); first-time transport-key/secret mint
is unsynchronized (practically serialized on the UI thread).
## Deliberately NOT changed
The always-on foreground **notification** was left as-is: it is shared with the
relay/DM always-on service, so retitling it "Signing for N apps" or deep-linking
it to the signer screen would be wrong when the service is up for another reason.
Interactive consent uses its own dedicated dialog Activity, so it needs no
notification actions.
@@ -1,201 +0,0 @@
# NIP-29 group-chat subscriptions: split *state* (always-on) from *content* (paginated)
**Status:** proposed · **Date:** 2026-07-18 · **Module:** `amethyst` (+ `commons` model, reuses `commons`/`quartz` paging)
## Problem
NIP-29 relay-group ("RelayGroup") chat is served today by **six** overlapping
REQ assemblers, each keyed differently and each re-deriving the same two queries:
| Query shape | Emitted by (today) |
|---|---|
| Metadata `#d` (3900039005 + pins) | Warmup, ChannelPublic (open), MyJoinedGroups (roster subset), OnRelay (directory) |
| Content `#h` (kind 9 + poll) | MyJoinedGroups (limit 50), Warmup (limit 50), ChannelPublic-open (limit 200) |
| My-own `#h` (`authors=[me]`) | ChannelFromUser — **redundant for groups** (the all-authors `#h` window already returns my messages; a group is pinned to one host relay) |
| Threads `#h` (11 + 1111) | Warmup, ThreadFeed |
Two concrete defects fall out of this shape:
1. **Slow / partial first load** (the reported bug). Content is fetched in **fixed
windows** (limit 50 / 200) gated by a *shared per-relay* `since`
(`RelayGroupMyJoinedGroupsSubAssembler` is keyed by `Account`, so its `since`
collapses to one map per relay, not per group). A group joined or surfaced
after that relay's `since` advanced never backfills; opening it waits a full
relay round-trip.
2. **We can miss messages.** A fixed `limit=200` window has no way to reach older
history, and no demand-driven paging: scroll up past 200 and there is nothing
behind it. There is also a **serving-relay keying hazard** (see below) where a
referenced group message lands in a channel the UI never reads.
Every *other* chat surface in the app already solved this with a **two-subscription
model** — an always-on live tail + an on-demand backward history pager — and there
is a reusable framework for it. Group chat is the outlier that never adopted it.
## Goal
Split group chat into the same shape every other chat uses, and delete the
duplication:
- **State** (metadata / roster / roles / pins) — small replaceable events →
**one always-on account subscription**, gated on the NIP-29 settings toggle.
The cache is always current; no per-screen metadata re-fetch.
- **Content** (kind 9 chat + polls) — high volume → **live tail + backward history
pager**, exactly like NIP-04 DMs and Concord channels. Gap-proof (`RelayLoadingCursors`
handles cache-prune rewind), demand-driven by the visible feed, reconnect-safe.
No message path that delivers a group message today may be dropped.
## Reused framework (do not reimplement)
Mapped end-to-end from the NIP-04 DM stack and the **Concord channel** stack, which
is the closest existing template (a public group channel already paged this way):
| Piece | Location | Role |
|---|---|---|
| `BackwardRelayPager(name, pageLimit, liveTailSeconds)` | `commons/.../relayClient/paging/` | single-active per-relay backward orchestrator |
| `RelayLoadingCursors` | `quartz/.../relay/client/paging/` | per-relay `until`/`reached`/`done` cursors + `rewindTo` (prune realign) — **one instance per group scope** |
| `WindowLoadTracker` + `trackingListener` | `commons/.../relayClient/paging/` | live-tail "all relays settled" indicator |
| `PagingStatus`, `RelayPagingProgress` | paging pkg / quartz | atomic display snapshot |
| `RelayReachCursor` / `RelayReachSentinels` / `RelayReachMarkers` | `commons/.../ui/feeds/RelayReachMarker.kt` | viewport-driven "load older" markers |
| `DmHistoryLoadingCard`, `RefreshingChatroomFeedView(olderBoundary, markersInGap, sentinels)` | `amethyst/.../chats/feed/ChatFeedView.kt` | shared feed hooks |
| `DmHistoryTuning.recentBoundary()` | `commons/.../model/privateChats/` | shared live-tail floor (7 days) |
**Direct templates to copy:**
`ConcordChannelHistorySubAssembler` + `ConcordChannelHistoryFilterAssembler` +
`ConcordChannelHistorySubscription` + `ConcordChannelScreen`'s
`ConcordBackfillHistoryToWindow`; and `ChatroomNip04SubAssembler` (live tail) /
`ConcordChannelFilterAssembler` (batched always-on live).
## Target architecture
Four concerns, mirroring the DM stack (rooms-list tail + per-conversation tail +
per-conversation history) plus a groups-only always-on state sub.
1. **`RelayGroupStateSubAssembler`** — *always-on*, account-keyed.
Roster `#d` (39000/39001/39002/39003/39005) batched one filter per host relay
across the joined set. Keeps `since` (tiny replaceable events; reconnect just
re-confirms). Mounted at `LoggedInPage` (like `AccountFilterAssemblerSubscription`),
gated on `ChatFeedType.NIP29`. **This is today's `RelayGroupMyJoinedGroups` roster
path, promoted to always-on and stripped of content.**
2. **`RelayGroupPreviewTailSubAssembler`** — *always-on*, account-keyed, batched.
Content `#h` (kind 9 + poll) across **all** `liveRelayGroupList` group ids,
`since = recentBoundary()`, **no per-group limit** (a time floor bounds it, so it
batches into one filter per relay). `WindowLoadTracker`. Drives Messages-list
previews and keeps joined groups' recent chat live app-wide. **Replaces
`RelayGroupMyJoinedGroups` content path (A).** Batching + time-floor `since`
eliminates both the per-group-`since` bug and the reconnect re-download.
3. **`RelayGroupChatTailSubAssembler`** — per-open-`GroupId`, live tail for the
*currently open* group: content `#h` (9 + poll), `since = recentBoundary()`, host
relay. Covers recent + live updates for **any** open group, **including non-joined**
groups opened by link (which the batched preview tail — joined-only — doesn't cover).
Mirrors the DM per-conversation live tail.
4. **`RelayGroupChatHistorySubAssembler`** — per-open-`GroupId`, `BackwardRelayPager`
(`liveTailSeconds` = 7d floor; the tails cover above it), cursors on
`RelayGroupChannel.history`, content `#h` (9 + poll, **all authors**) `until`+`limit`
on the host relay. Demand-driven by the feed markers; eager `advanceAll()` backfill
to a window target on open. **Replaces ChannelPublic-open content (C) and
ChannelFromUser (D).** All-authors, so it re-materializes my own history too.
`ChannelFeedFilter` is unchanged — it reads `channel.notes`, so every path that fills
the cache surfaces. (It has **no `limit()`**, so it already renders whatever is cached.)
## Message-coverage proof (can't-miss-messages checklist)
Every current content-delivery path and what covers it after:
| Path (today) | Kinds / scope | After |
|---|---|---|
| **A** MyJoined content (50) | 9,poll `#h` joined | **Preview tail (batched `#h`, since=window)** for previews + **chat tail** when open |
| **B** Warmup content (50) | 9,poll,11,1111 `#h` card | **KEEP** — non-joined cards/discovery aren't in the joined tail (screen-dependent, per design) |
| **C** ChannelPublic-open content (200) | 9,poll `#h` open | **Chat tail (recent) + history pager (older, gap-proof)** |
| **D** ChannelFromUser (`authors=me`) | 9,poll `#h` me | **History pager (all-authors) + tail + optimistic-send attach + host echo** → redundant |
| **E** ThreadFeed | 11,1111 `#h` | **KEEP** (Threads screen; separate `threadNotes` feed). Pager adoption is a follow-up. |
| **F** Notifications | 7,9,1111,1068,… `#h`+`#p=me` | **KEEP** — always-on, p-tags-me; unchanged bonus |
| §3 by-id (`filterMissingEvents`) | ids | **KEEP** — quotes/replies/mentions; **+ serving-relay fix below** |
| §3 pinned by-id backfill | ids `filterMetadataToRelayGroup` | **KEEP** (host relay; older-than-window pins) |
| §3 replies/reactions `#e/#q` | 1111 etc. | **KEEP** — comments never attach to timeline (by design) |
**Serving-relay keying hazard (real, pre-existing — fix as part of "can't miss").**
`attachToRelayGroupIfScoped` keys the channel by `GroupId(groupId, servingRelay)`.
All subscriptions here are host-pinned, so they're safe. But `filterMissingEvents`
can deliver a referenced group message from a **non-host** relay, filing it under a
different channel object than the host-keyed one the UI reads → cached but invisible.
Fix: when attaching a group-scoped content event, if exactly one existing
`RelayGroupChannel` carries that `groupId` (the joined/host one), attach there
instead of minting a `(groupId, servingRelay)` channel — reusing the existing
`singleOrNull`-by-groupId resolution already used for the `relay == null` optimistic
branch. Ambiguous ids (the relay-wide `_` group joined on several relays) keep
serving-relay keying.
## File-by-file changes
**New (`commons` model):**
- `RelayGroupChannel`: add `val history = RelayLoadingCursors()` (mirror `ConcordChannel.history`).
**New (`amethyst` datasource, per templates):**
- `RelayGroupStateFilterAssembler` (+ SubAssembler) — always-on roster.
- `RelayGroupPreviewTailFilterAssembler` (+ SubAssembler) — batched preview tail.
- `RelayGroupChatTailFilterAssembler` (+ SubAssembler) — per-open live tail.
- `RelayGroupChatHistoryFilterAssembler` (+ SubAssembler) — per-open history pager.
- Subscription composables for each (`*Subscription`), copying the Concord ones.
**Modified:**
- `RelaySubscriptionsCoordinator`: register the four new assemblers; drop the retired ones (see below).
- `LoggedInPage`: mount `RelayGroupStateSubscription` + `RelayGroupPreviewTailSubscription` (always-on, gated).
- `RelayGroupChannelView`: mount chat-tail + history subscriptions; wire
`RefreshingChatroomFeedView(olderBoundary, markersInGap, sentinels)` + a
`BackfillHistoryToWindow` (copy `ConcordBackfillHistoryToWindow`).
- `MessagesSinglePane`/`MessagesTwoPane`: drop `RelayGroupMyJoinedGroupsSubscription`
(its roster role moves to the always-on state sub; previews come from the tail).
- `LocalCache.attachToRelayGroupIfScoped`: host-relay normalization (serving-relay fix).
- `AccountViewModel.dataSources()`: expose the four new assemblers; remove retired handles.
**Retired:**
- `RelayGroupMyJoinedGroupsFilterAssembler` **content path** → deleted; the file's
roster role becomes `RelayGroupStateFilterAssembler` (rename/replace).
- `ChannelPublicFilterSubAssembler` **`RelayGroupChannel` branch** (`filterMessagesToRelayGroup`
+ `filterMetadataToRelayGroup`) → removed; metadata now always-on, content now tail+pager.
(Keep `filterMetadataToRelayGroup`'s **pinned-id backfill** — re-home it on the chat-tail or a
small pin sub so older-than-window pins still resolve.)
- `ChannelFromUserFilterSubAssembler` **`RelayGroupChannel` branch** (`filterMyMessagesToRelayGroup`) → removed.
- Keep `RelayGroupWarmup*` (non-joined cards), `RelayGroupsOnRelay*` (directory),
`RelayGroupsDiscovery*` (discover feed), `RelayGroupThreadFeed*` (threads), and the
notifications path unchanged.
## Rollout order (additive first, retire last — never a window where messages drop)
1. **Additive, no removals:** add `RelayGroupChannel.history`; add the four new
assemblers + subscriptions + coordinator/dataSources handles + `LoggedInPage`
and `RelayGroupChannelView` wiring. New content now flows through tail+pager
**alongside** the old A/C/D (harmless dedup by id). Compile + smoke.
2. **Serving-relay normalization** in `LocalCache` (independent correctness fix).
3. **Retire** A-content, C-relay-group-branch, D-relay-group-branch; move roster to
the always-on state sub; drop the Messages-pane `MyJoinedGroups` mount. Compile.
4. Re-home the pinned-id backfill; delete now-dead code; `spotlessApply`; full suite.
## Edge cases
- **Quiet group** (newest message older than the 7-day tail): won't appear in the
preview tail; its Messages row falls back to cached / placeholder (same as NIP-04).
Opening it → the history pager's eager backfill loads it. Optional: a one-shot
newest-1 per quiet joined group in the state sub's initial snapshot.
- **Non-joined open group:** covered by the per-open chat tail + history pager
(both per-`GroupId`, no joined-list dependency).
- **Reconnect:** tails carry `since=recentBoundary()` (time floor, shared-safe,
incremental); history is `until`-based (position, not reconnect-sensitive);
`FiltersChanged` already ignores `since`-only changes → no full replay.
- **Threads:** unchanged this pass; a follow-up can point `RelayGroupThreadFeed` at
a second `BackwardRelayPager` on `RelayGroupChannel` for kind 11/1111.
## Testing
- Unit: preview-tail filter batches one `#h` filter per relay with
`since=recentBoundary()` and no per-group limit; history filter emits only for
armed relays at their `requestedUntil`; state filter emits roster `#d` per relay.
- Cursor behavior is already covered by `RelayLoadingCursors` tests (reused).
- Manual (amy / device): join a group after session start → open → history backfills;
scroll up past the window → older pages load; reconnect → no full re-download;
quote a group message from a non-host relay → it appears in the group.
@@ -1,177 +0,0 @@
# NIP-29 group-chat loading — test plan (per screen × per assembler)
**For:** an AI validating branch `claude/nip29-group-load-perf-wz4yca` before trusting the
state-vs-content refactor (see `2026-07-18-nip29-group-chat-subscriptions.md`).
**Question this answers:** *does the correct data load on every screen, and can we ever miss a message?*
**Implemented on this branch (all headless-runnable tiers):**
- **Tier B — filter shapes, every assembler.**
- Assemblers 16 + card-warmup joined-skip + reconnect stability (`needsToResendRequest`) + directory: `amethyst/src/test/.../relayGroup/datasource/RelayGroupFilterBuildersTest.kt` (tests the pure `RelayGroupFilterBuilders.kt` the assemblers now delegate to).
- #9 ChannelPublic relay-group branch (state+pins, no message window): `.../publicChannels/datasource/subassemblies/FilterRelayGroupStateTest.kt`.
- #10 group notifications (`#p`+`#h`): `.../service/relayClient/reqCommand/account/nip01Notifications/FilterGroupNotificationsToPubkeyTest.kt`.
- #8 discovery `#p` roster augmentation: `.../relayGroup/datasource/subassemblies/FilterRelayGroupsByAuthorsTest.kt`.
- **Tier C — serves-the-shape, against the in-process `geode` relay** (`quartz/src/jvmAndroidTest/.../nip29RelayGroups/RelayGroupFilterServingRelayTest.kt`): C1 state `#d`, C2 batched preview tail, C5 threads, C6 pinned-body-below-window by id, C7 notification `#p`+`#h`, C8 directory.
- **Tier C3 / E1 — can't-miss + resilience** (`quartz/src/jvmAndroidTest/.../paging/RelayGroupHistoryPagingRelayTest.kt`): backward `#h` walk covers every message once + stops on empty page; same-relay group isolation with overlapping `createdAt`; the **production `RelayLoadingCursors`** driven to the bottom over the wire; short-page-≠-exhaustion.
**Deliberately not duplicated (already covered generically at the unit level):** echo-newest→done and rewind/prune are in `RelayLoadingCursorsTest`; no-EOSE watchdog in `WindowLoadTrackerIdleTest`; auth-CLOSED/stall/cannot-connect in `BackwardRelayPagerTest`. E1's remaining hostile-relay faults (ignore-`since`, out-of-order, AUTH-CLOSE, silence) are those same state-machine paths — re-asserting them under NIP-29 naming adds no coverage since the cursor/pager never sees the `#h` filter, only `onEvent(createdAt)`/`onEose`.
**Not headless-runnable in this environment (flagged for a human):**
- **Tier D** (Android emulator/device, per screen) — needs a device; each row must be run and any unrun row flagged.
- **Tier E2** (conformance against a *real* third-party NIP-29 relay in a container) — non-deterministic + needs a container image; geode/strfry are generic and can't surface a real NIP-29 relay's bugs.
## What is / isn't verifiable headless
| Layer | Harness | Covers |
|---|---|---|
| Filter **shapes** each assembler builds | amethyst JVM unit tests (new) | the REQ is correct for the screen's job |
| Relay **serves** those filters; **ingest** into `LocalCache``RelayGroupChannel.notes`; paging framework | **`amy` + `geode`/`amy serve`** (drives the same quartz+commons client) | the reused machinery + filter shapes work against a real relay |
| **Screen loading** (mount → subscribe → feed renders); UI marker/sentinel→`advance`; always-on mounting; backfill-to-window loop | **Android emulator/device** | the amethyst wiring end-to-end |
The amethyst *assemblers* are Android-module, so `amy` cannot invoke them directly — it validates the
**framework + filter shapes + ingest** they depend on. The **screen** rows below therefore have a
headless part (unit + amy) and a device part; do both, and mark any device row you couldn't run.
## The assemblers under test (all of them)
| # | Assembler | Mounts on | Must load |
|---|---|---|---|
| 1 | `RelayGroupJoinedState` (always-on) | LoggedInPage → every screen | joined groups' 39000/1/2/3/5 → name, roster, roles, pins, my membership |
| 2 | `RelayGroupJoinedChatTail` (always-on) | LoggedInPage → Messages | joined groups' recent chat (`#h` since=window) → true newest-message previews |
| 3 | `RelayGroupOpenChatTail` | open group chat screen | the open group's recent chat + live (incl. **non-joined**) |
| 4 | `RelayGroupOpenChatHistory` | open group chat screen | older chat on demand (`#h` until+limit), gap-proof |
| 5 | `RelayGroupOpenThreads` | Threads tab | kind-11/1111 threads |
| 6 | `RelayGroupCardWarmup` | discovery cards, relay channel-list, members/metadata/parent screens | a **non-joined** card's metadata + preview; **skips joined** groups |
| 7 | `RelayGroupsOnRelay` | relay channel-list, subgroups bar, parent picker | a host relay's whole group directory |
| 8 | `RelayGroupsDiscovery` | Discovery screen | cross-relay discovery feed (by follows / global) |
| 9 | `ChannelPublicFilter` (relay-group branch) | open group chat screen | open group metadata + **pinned-id backfill** (incl. non-joined; pins older than window) |
| 10 | `filterGroupNotificationsToPubkey` (always-on notifications) | account-level | group content that **p-tags me**, even if I never opened the group |
## Harness setup (headless)
```bash
./gradlew :cli:installDist # build amy
RELAY=ws://127.0.0.1:7447
amy serve --port 7447 & # embedded relay (geode); or run :geode directly
# Identities: one "relay/operator" key (signs 39xxx), a few member keys, and "me".
# Seed a group G on the relay:
amy relaygroup create --relay $RELAY --gid G --name "Test" ... # 39000/39001/39002
# Seed chat spanning the live-tail boundary (7d): messages older AND newer than now-7d.
for t in <timestamps old→new>; do amy publish --relay $RELAY --kind 9 --tag h=G --created-at $t "msg $t"; done
# Seed: kind-11 thread + kind-1111 reply (h=G); a pinned kind-9 older than 7d + 39005 pin list;
# one kind-9 that p-tags "me"; a SECOND group G2 on the same relay (batching); a group on a
# second relay R2 (multi-relay); a group with <LIMIT total messages (small-group path).
```
Discover exact flags with `amy <verb> --help` (`fetch`/`subscribe`/`publish`/`relaygroup`).
`amy fetch --json` gives machine-checkable output for assertions.
---
## Tier A — baseline (must stay green)
```bash
./gradlew :amethyst:compilePlayDebugKotlin
./gradlew :quartz:jvmTest :commons:jvmTest :amethyst:testPlayDebugUnitTest :cli:test
./gradlew spotlessCheck
```
## Tier B — new amethyst unit tests (filter shapes per assembler)
Construct a minimal `Account` with `relayGroupList.liveRelayGroupList` = {G@R, G2@R} (+ a mock
`INostrClient`). If wiring a full `Account` is too heavy, **first refactor the filter construction out
of each `updateFilter` into a pure function** (`buildJoinedChatTailFilters(joinedTags, since)`,
`buildOpenChatHistoryFilters(groupId, armed, until, limit)`, …) and test those — this is itself a
worthwhile testability change. Assert, per assembler:
- **1 State:** one `#d` filter per host relay; kinds = 39000/39001/39002/39003/39005; `d` = all joined ids on that relay; `since` = shared per-relay EOSE. Disabled when NIP-29 toggle off / joined empty.
- **2 JoinedChatTail:** one `#h` filter per host relay; kinds = [9,poll]; `h` = all joined ids on that relay; `since = recentBoundary()`; **no per-group `limit`**. Two groups on one relay ⇒ **one** filter.
- **3 OpenChatTail:** one `#h` filter, host relay, kinds [9,poll], `since = recentBoundary()`, the single open group id.
- **4 OpenChatHistory:** with no relay armed ⇒ empty; after `advance(relay)` ⇒ one `#h` filter at `requestedUntilFor(relay)`, `limit = pageLimit`, **all authors** (no `authors`).
- **5 OpenThreads:** `#h`, kinds [11,1111], host relay.
- **6 CardWarmup:** a **joined** group ⇒ `emptyList()`; a **non-joined** group ⇒ metadata (unless contentOnly) + `#h` content (9,poll,11,1111) `limit`.
- **7 OnRelay:** directory `#`-less filter, kinds 39000-39003, `limit 500`, that relay.
- **8 Discovery:** by-follows + host-relay `#p` roster augmentation (see `RelayGroupsDiscoverySubAssembler`); global variant.
- **9 ChannelPublic relay-group branch:** returns **only** `filterRelayGroupState` (metadata + pin ids) — **no** message-window filter.
- **Reconnect stability:** re-run each `updateFilter` after a simulated EOSE; assert `FiltersChanged.needsToResendRequest(old,new)` is **false** for the tails/state (a `since`-only bump) — i.e. no full replay.
## Tier C — `amy` + relay integration (framework, ingest, can't-miss)
Issue the **exact filter shapes** from Tier B against the seeded relay and assert results:
- **C1 State load:** `amy fetch --kind 39000,39001,39002,39003,39005 --tag d=G --json` returns the seeded state. (screen-1)
- **C2 Preview/tail:** `amy fetch --kind 9 --tag h=G --since <now-7d> --json` returns only in-window messages; the newest equals the true newest. Batched: `--tag h=G --tag h=G2` returns both groups' recent in one query. (screens 2,3)
- **C3 History paging (CAN'T-MISS — the crown jewel):** seed **N=120** messages (older than 7d, spread over months). Starting `until=now`, repeatedly `amy fetch --kind 9 --tag h=G --until <cursor> --limit 50 --json`, setting the next `until = oldest.created_at - 1`, until an empty page. Assert the **union of all pages = all 120 ids, no gaps, no infinite loop** (mirror `RelayLoadingCursors.advance/onEose`). Then confirm a relay that returns the same newest events on a repeat page terminates (the `onEose` "not strictly older ⇒ done" guard). (screen-4)
- **C4 Ingest:** drive `amy subscribe`/`fetch` so events flow through the real client, then assert they land in a `RelayGroupChannel` keyed by `GroupId(G, R)` and surface via the `ChannelFeedFilter` predicate (kind 9/poll in, 1111 out). (screens 2,3)
- **C5 Threads:** `--kind 11,1111 --tag h=G` returns thread + reply; confirm 1111 attaches to threads, not the chat timeline. (screen-5)
- **C6 Pins:** a pinned kind-9 older than the window is **not** returned by C2 but **is** by `amy fetch --ids <pinnedId>` — proving the pinned-id backfill path still reaches it. (screen-9)
- **C7 Notifications:** `--kind 9 --tag h=G --tag p=<me>` returns the me-tagged message. (screen-10)
- **C8 Directory / discovery:** `--kind 39000 --limit 500` on R lists G+G2; a `#p=<follow>` roster query on the host relay surfaces a follow's group (the discovery augmentation). (screens 7,8)
- **C9 Multi-relay + reconnect:** repeat C2 against R and R2; drop and re-issue the subscription and confirm (via `--json` counts / relay logs) that a `since`-carrying re-REQ returns only the tail, not a full replay.
## Tier D — Android app, per screen (emulator/device; flag if unrunnable)
Boot `:amethyst:installDebug` against the seeded relay (point the account's relay list at `$RELAY`).
Watch logcat: `adb logcat | grep -E "DMPagination|relayGroup"`.
For **each screen**, the pass criteria:
- **D1 Messages list (1,2):** cold start with app already having joined G → the G row shows its **true newest** message (not a stale/scattered one), and its name/avatar (state). Join **G2 mid-session** (don't restart) → within seconds G2 appears with a real preview — *this is the original bug; it must now pass.*
- **D2 Open joined group (3,4,9 + backfill):** tap G → lands on a populated first screen (~50, the backfill-to-window), name/pins present. **Scroll up** past the window → older pages load, the reach marker advances, the "loading older" card shows then flips to "all caught up" at the bottom. No duplicate rows.
- **D3 Open non-joined group by link (3,4,9):** open a `naddr`/link to a group you have **not** joined → recent chat + live updates load (OpenChatTail) and scroll-up pages (OpenChatHistory), even though it's absent from the joined tail.
- **D4 Threads tab (5):** open Threads → kind-11 threads list; open one → its 1111 replies.
- **D5 Discovery (8,6):** open Discovery → groups list; a card fills name+activity (CardWarmup for non-joined). A **joined** group shown in "My Groups" still renders (from cache) though CardWarmup emits nothing for it.
- **D6 Relay channel-list / browse (7,6):** browse a relay → its directory lists groups; tapping one warms + opens.
- **D7 Members / Metadata screens (6/1):** roster + roles render.
- **D8 Reconnect (2,3,4):** toggle airplane mode on the open group and Messages → on reconnect, logcat shows incremental `since`/`until` REQs, **not** a full page replay; no missing or duplicated messages.
- **D9 Notifications (10):** with G *not* open, have another key post a message p-tagging me → it appears in notifications / unread.
- **D10 Quiet group:** a group whose newest message is older than 7d → Messages row falls back to cached/placeholder (documented limitation), and opening it backfills via the pager.
## Tier E — relay-behavior resilience & third-party conformance
Tiers C/D run against geode/`amy serve` — a **compliant relay we control**. Real NIP-29 groups live on
relays managed by other people, which have bugs and quirks. Two distinct concerns:
### E1 — client resilience to a MISBEHAVING relay (deterministic, mock)
Build a scriptable WebSocket relay (reuse the quartz relay-server + `RelayClientTestFakes`) that injects
one fault per run; assert the tail/pager still **converge** — all messages ingested, no infinite
`advance`, no hang, correct terminal state (`done` vs `stalled`), no duplicate rows:
- ignores `since` (returns everything) → tail must **dedup**, not duplicate.
- ignores / partial `until` → pager makes progress or marks done, **never loops**.
- **short page** (returns < `limit` though more exist) → NOT exhaustion (only an *empty* page ends a relay).
- **echoes the same newest events every page** → `RelayLoadingCursors.onEose` "not strictly older ⇒ done" fires.
- out-of-order / duplicate events → cursor takes `min(createdAt)`; dedup by id.
- EOSE before any event, or **no EOSE at all**`WindowLoadTracker` idle/abs-cap; pager silence watchdog → `stalled`.
- **AUTH-required → CLOSED("auth-required")** → relay `stalled`-but-kept; re-`advance` retries; **not silently dropped**.
- mid-stream socket drop → resubscribe with `since`/`until`, **no full replay, no gap** (`rewindTo` on prune).
- relay result cap below `limit` → treated like a short page.
`UntilLimitPagingRelayTest` + `BackwardRelayPagerTest` already cover the empty-page / until-limit-walk /
echo-newest cases for the **generic** pager. E1 is to (a) re-run them against the NIP-29 `#h` filter
shapes and (b) add the not-yet-covered faults (ignore-since, out-of-order, AUTH-CLOSE, silence, reorder).
### E2 — conformance against REAL NIP-29 relay implementations (surfaces THEIR bugs)
geode / strfry / nostr-rs-relay are **generic** (store+serve by tag; no NIP-29 semantics), so they can't
surface a real NIP-29 relay's bugs. Point the harness (reuse relayBench's `RelayUnderTest` adapter) at an
actual NIP-29 relay (e.g. relay29, chorus, a self-hosted groups relay) in a **container**, seed a group
via `amy relaygroup`, and run C1C9 + E1's corpus. A failure is a **relay** bug or a client/relay
mismatch — a NIP-29 conformance report the operator can act on. Cover the behaviors only a real NIP-29
relay has:
- **AUTH gating** on closed/private groups (39002 membership): does the client AUTH and then receive the `#h` timeline?
- **relay-signed 39xxx** (the relay's own key) → the `isRelaySignedGroupEvent` gate.
- **`previous`-tag fork rejection** on send (a relay rejecting an event whose `previous` refs it doesn't recognise).
- the relay's actual **`since`/`until` inclusivity** and **result caps / rate limits** on the `#h` timeline.
Public relays are non-deterministic (live data): use a containerized instance for CI, a public one only
for exploratory runs. **E1 hardens our client against buggy relays; E2 tells us which third-party relay
is buggy** — both are needed before trusting "loads correctly" in the wild.
## Cross-cutting invariants (assert throughout)
- **No missed messages:** the union of tail + history + pins + notifications = the full timeline; C3 is the decisive test. Also exercise `RelayLoadingCursors.rewindTo` — trim the cache below the window, page again, confirm the pruned band re-loads.
- **No double-download on reconnect** (C9/D8).
- **Retirement left no gap:** with `RelayGroupMyJoinedGroups` deleted and `ChannelPublic`/`ChannelFromUser` relay-group content removed, D1/D2/D3 still load — proving the tail+pager replaced them.
- **CardWarmup joined-skip** (Tier B #6 / D5): a joined card issues no warmup REQ.
- **Serving-relay hazard (FIXED):** a group message fetched from a **non-host** relay (e.g. `filterMissingEvents` quote resolution) used to be filed under `GroupId(G, otherR)` and lost. `LocalCache.attachToRelayGroupIfScoped`/`attachThreadToRelayGroupIfScoped` now redirect a stray (no channel for the serving relay) to the group's single confirmed **host** channel via `redirectStrayRelayGroupContent`, keyed off `RelayGroupChannel.hasRelaySignedState()` (a phantom never has relay-signed state, so the redirect only ever lands on a real host — strictly safe, the common host-pinned path is an untouched O(1) fast path). Covered by `RelayGroupContentRoutingTest`. **Device-untested:** the pure router + channel signal are unit-tested; the LocalCache wiring is a guarded fast-path/slow-path swap that still needs a device pass (Tier D) to confirm end-to-end.
## Exit criteria
- Tier A green; Tier B all assertions pass; Tier C1C9 pass (esp. **C3**).
- Tier D1D9 pass on device, or each unrun row is explicitly flagged for a human.
- Known-failing by design until follow-ups: the serving-relay hazard row and (if unadopted) a threads pager.
@@ -1,190 +0,0 @@
# v1.13.0 release QA — coverage, open findings, and recipes
One extended testing session against v1.13.0 (~2011 commits since v1.12.6). Fixes landed on
`fix/napplet-account-isolation-and-consent` (30 commits); each commit message carries its own
root-cause reasoning and is the better reference for *why* a given change looks the way it does.
This document records what that session **could not** capture in commit messages: what was actually
exercised, what was not, what we chose to leave broken, and how to reproduce the setups.
**Device under test:** Samsung SM-T220 tablet, Android 14, `sw600dp`, `play`/`benchmark`, arm64.
Everything below is that one configuration unless stated.
---
## 1. Coverage
### Exercised on device
| Area | Notes |
|---|---|
| Upgrade path | install over a month-old build; migrations survived, ~880 ms cold start |
| Napplet / web app per-account isolation | leak found and fixed; each account now has its own jar |
| Embedded tab rebuild on account switch | blank-tab bug found and fixed; verified both directions + a forced-failure A/B |
| Launch-account signing binding | desync reproduced end to end, then verified fixed |
| NIP-46 remote signer | 13 checks, all passing (pairing gate, 22242 prompt, decrypt counterparty/plaintext/narrow grant/scoping) |
| Concord | invite consent gate, private/voice rename, role rank gate, revoke gate |
| NIP-29 relay groups | directory browse (crash found), naddr deep-link join, membership resolution |
| Breadth sweep | Messages, NIP-29, Git, Podcasts, Blossom list, Location, Theming |
| Location | map picker + teleport, before/after on 4 symptoms, composer path |
| Notifications | tab showed ~3 items; root-caused to a `since` deadlock and fixed — feed now scrolls back 16 months |
| Concord role grants | picker built and device-verified (rank gating, preselection, survives the fold) |
### Fixed but **only unit-verified** — never run on a device
Concord rollback floor · stranded recovery · member-set gap · invite expiry · moderation head ·
**chain-poisoning fix** · community-list unknown-key preservation · V4V fee clamp · Cashu SSRF
validator · Blossom 402 (cap / re-prompt / double-spend / `X-Reason`) · amountless-invoice display ·
**napplet consent diff dialog** (never seen rendered) · connect-dialog capability disclosure ·
`identity.watch` DENY · DM ciphertext previews and the `User` metadata race · chat date separators ·
podcast duplicate description · Concord leave affordance · the three revocation wirings.
### Never opened at all
Nests / audio rooms (`quic` + MoQ) · Marmot / MLS · **the entire Desktop app** (where Privacy Lock
actually ships) · Blossom "Sync all" (skipped deliberately — uploads to real servers) · podcast
chapters / transcripts / credits · Git branch switching · Messages live-typing and per-type toggles ·
real payments (zaps, V4V streaming, Cashu redeem) · push notifications · search · Calendar, Chess,
Polls, Marketplace, Workouts, Badges, Follow Packs, Emojis, HLS Upload, App Store, Live Streams.
NIP-29 admin: the menu is reachable and renders, but Edit metadata, invite creation, subgroups and
pinned messages were never exercised.
### Platform gaps
- **Android 14 only.** `targetSdk` is 37; Android 15+ forces edge-to-edge and that path is untested.
An emulator makes this cheap and it is the highest-value remaining gap.
- **Tablet only** — no phone layout. **`play` only** — no fdroid. **`benchmark` only** — the real
`release` (full R8) has never been built or run. **arm64 only.**
- **Amber / NIP-55** external signer never tested, including a known decrypt double-prompt risk.
- **Tor-on paths** — Tor was disabled for untrusted relays mid-session and not restored.
---
## 2. Open findings (known, deliberately not fixed)
**Release mechanics**
- `appCode` still `454` and `app` still `1.12.6` — Play hard-rejects a duplicate versionCode.
- Firebase `TransportRuntime` cannot schedule (`JobInfoSchedulerService` missing from the merged
manifest). If this reproduces in `release`, **Crashlytics delivery is broken** and the release
ships blind.
**Correctness / UX**
- Tor settings do not take effect until app restart, with no indication.
- An unreachable relay is reported as "No groups on this relay yet" — indistinguishable from empty.
- NIP-29: a stale "Requested" join state is never reconciled against an arriving 39002 roster.
- Concord: leaving does not unpin from the bottom bar, leaving a dead tab.
- Read-only accounts render nothing for a kind:4 chatroom body (better than ciphertext, still wrong).
- Modal geohash picker header is overdrawn by the MapView (pre-existing).
- `amy relaygroup create` reports success on relays that silently reject it — always verify with `info`.
- `amy login bunker://…` hangs and never delivers a `connect`.
**Security / protocol**
- NIP-46 "Generate a new address" claims to disconnect every app; it rotates the transport key and
revokes nothing.
- WebView storage profiles are never deleted on logout — a removed account's cookies persist.
Requires a broker message so `:napplet` can call `ProfileStore.deleteProfile`.
- Control-plane *edit* paths (`editConcordMetadata`, `grant`, channel edits) still drop unknown JSON
keys; only the community list was fixed.
- **CORD-05: `community_id` does not commit to `community_root`**, so a crafted invite can carry a
real community's identity with an attacker's root. **Armada has the identical gap** — this needs a
spec conversation, not a unilateral fix.
- **CORD-04: the BANLIST is not rank-gated, so any BAN holder can ban anyone — including the owner.**
Role/grant editions are rank-gated (`canActOn`), but a banlist edition is a single *whole-list*
entity, so no client rank-checks its contents; the gate is the author's BAN bit alone. A rank-5
moderator's ban of a rank-1 admin is therefore **accepted** by the fold, and the admin then loses
every permission (`hasPermission` is `!isBanned && …`). **Armada has the identical gap** — its
`banlistGate` calls the rank-blind `isAuthorized(.., Permissions.BAN)` while its role path uses
the rank-aware `canActOnPosition`.
**This is a conformance bug, NOT a spec gap** — an earlier note here said the opposite and was
wrong. CORD-04 §3 is explicit and normative: "One hard rule binds every action: the actor must
hold the required bit **and** *strictly* outrank its target — equal cannot act on equal (an admin
cannot ban a peer admin)", restated as step 3 of §5. Only §4, the section that defines the
Banlist, omits the rank half — and both independent implementations read §4 in isolation and made
the same mistake. Spec: <https://github.com/concord-protocol/concord> (`04.md`).
**FIXED and shipping**`AuthorityResolver` now enforces §3 as a *delta rule* (an edition may only
add/remove npubs its signer strictly outranks; the owner is never a valid target; unpermitted
entries are ignored rather than rejecting the edition, so a bulk-ban survives). The UI and the
ban/unban write path route through it too — `ConcordModeration.currentBanned` now reads the
*honored* banlist via the resolver instead of decoding the raw head, which also closes a
laundering path where our own next ban would re-publish an unauthorized entry under our signature.
**Known consequence: Armada has not shipped this, so banlists can differ between clients**
we ignore a ban Armada honors when the signer did not outrank the target. Deliberate.
Write-up to send upstream: `docs/concord-banlist-rank-conformance.md`.
Still open, both covered in the write-up: a banned member holding BAN can lift their own ban (the
gate reads role-derived permissions, so bans do not stick against any BAN holder — this one is a
genuine fixpoint-ordering question and needs a spec ruling), and a forked ban survives an unban
that does not chain onto it.
- Notification cards whose target note isn't in `LocalCache` render "Event is loading or can't be
found in your relay list" (seen on old zaps). `tagsAnEventByUser` needs the reacted-to note
loaded, so deep history stays partially unresolved. Cosmetic, pre-existing.
---
## 3. Setup recipes
**`amy` with an isolated identity** (never touch the maintainer's real one):
```
AMY=$(pwd)/cli/build/install/amy/bin/amy
H=/tmp/qa-home; mkdir -p $H
HOME=$H $AMY --account qa login <nsec> --secret-backend plaintext
```
`amy` scopes by `$HOME`, not a flag. `init` prompts for a passphrase and hangs without a TTY — use
`--secret-backend plaintext` for throwaway identities.
**NIP-29 test group.** `relaygroup create` on `communities.nos.social` and `relay.groups.nip29.com`
returned success but published nothing; `groups.0xchat.com` worked. Always confirm with
`relaygroup info`. To reach a group in a 1000+ entry directory, skip the UI and deep-link:
`adb shell am start -a android.intent.action.VIEW -d "nostr:<naddr>"`, encoded via
`amy encode naddr --pubkey <relay-nip11-pubkey> --kind 39000 --identifier <groupId> --relay <url>`.
To make a device account an admin, have it join first, read its pubkey from `relaygroup info`, then
`put-user … --role admin`.
**Proving "no network before consent."** Run a local relay (`amy serve`), expose it with
`adb reverse tcp:7777`, and make it the *only* relay in the artefact under test. Count events before
and after the user action — that turns "I didn't see traffic" into an actual measurement.
---
## 4. Patterns worth acting on
These recurred often enough to be process problems rather than individual bugs.
**Tests that assert the bug.** At least five encoded the buggy behaviour as intended — a NIP-46 test
named `getPublicKeyReturnsUserPubKeyWithoutAuthorization`, a V4V invariant only ever run on
well-formed input, a napplet session test that never crossed accounts. *Always verify a new
regression test fails without the fix* — and beware that **Gradle will serve a stale up-to-date
`jvmTest` and report BUILD SUCCESSFUL**, which makes that check silently lie. Use `--rerun-tasks`.
**Implemented-but-unreachable capabilities.** Five found: `leaveConcordCommunity`,
`ConcordInviteBundle.isExpired`, `NappletPermissionLedger.endSession`, `grantConcordRole`, and
`NappletBroker.revokeSessionGrants`. Each made a feature look complete to anyone reading the model
while being unreachable to users, and the first one actually invoked turned out to be **broken as
written**. A lint for "public capability with no caller outside its declaring file" would catch the
whole class cheaply.
**A narrow query window can deadlock against its own paging.** The Notifications tab asked relays
for 7 days, and its backward-paging fallback only armed once the feed held a *full page* — so a
quiet inbox could never fill a page, and therefore never widened the window. The EOSE `since` map
is in-memory, so every cold start re-pinned it. Look for this shape wherever a "load more" boundary
is gated on a full page: the empty state is self-sustaining. Note also that the relay-side `limit`
already bounds these queries, which is what makes dropping the time floor safe.
**Hypotheses need measurement, not plausibility.** Four confident diagnoses were wrong: the "npub in
title" bug was a `User` lazy-init data race, not a display bug; chat date separators were a
`reverseLayout` misconception, not bubble grouping; the map picker had no tile problem at all; and
NIP-29 membership was a *relay rejecting the REQ* (`blocked: it's not allowed to mix metadata kinds
with others`), not membership modelling. Instrument first.
**Check the reference implementation.** Reading Armada changed the answer three times out of three —
it corrected an owner-rotation rule that would have stranded owners, stopped an invite-binding "fix"
that was both interop-breaking and ineffective, and supplied the chain-poisoning design (gate *after*
folding, not before). Armada is **AGPLv3** and Amethyst is MIT: read for semantics, copy nothing.
**Comments encoding constraints are load-bearing.** The synchronous SharedPreferences read looks like
an obvious StrictMode fix; its comment records that an async hydrate reopens a settings-clobber race.
Removing it would have been a confident, review-passing regression.
**Beware concurrent agents and `git add -A`.** Two commits were contaminated, and one silently
committed another worker's temporary revert. Stage explicit paths, always.
@@ -1,380 +0,0 @@
# Push Notification Redesign — beautiful, modern, per-kind tray notifications
**Date:** 2026-07-23
**Goal:** Replace the flat, mostly-`BigTextStyle`, single-accent tray
notifications with a per-kind design system: distinct accent colors, status-bar
icons, notification *styles* (MessagingStyle / BigPictureStyle / colorized zap
cards), smart aggregation, and Conversation-section integration — so every kind
of Nostr notification (zap, reaction, reply, mention, DM, repost, nutzap, media,
git, badge, chess…) reads at a glance and looks native to modern Android
(1215).
This is a **rendering / UX** plan. The delivery/transport layers (FCM,
UnifiedPush, the always-on relay service, gift-wrap unwrap, account matching,
mute/age/self gates) are untouched — everything below happens after
`EventNotificationConsumer.dispatchForAccount()` decides an event is notifiable.
---
## 1. Current state (what we render today)
**Pipeline (unchanged by this plan):**
`PushNotificationReceiverService` (FCM) / `PushMessageReceiver` (UnifiedPush) /
`NotificationRelayService` (always-on) → `PushWrapDecryptor.unwrapAndFeed`
`LocalCache``NotificationDispatcher` (kind + age + `tagsAnEventByUser` gate) →
`EventNotificationConsumer.dispatchForAccount()` → per-kind `notify*()`
`NotificationUtils` (`NotificationCompat.Builder`).
**What the tray looks like now:**
| Channel | Importance | Style used | Accent | Small icon | Actions |
| --- | --- | --- | --- | --- | --- |
| DMs (`PrivateMessagesID`) | HIGH | `MessagingStyle` ✅ | none | amethyst logo | Reply, Mark-read |
| Zaps (`ZapsID`) | DEFAULT | `BigTextStyle` | none | amethyst logo | — |
| Reactions (`ReactionsID`) | DEFAULT | `BigTextStyle` (+emoji avatar badge) | none | amethyst logo | — |
| Replies (`RepliesID`) | DEFAULT | `BigTextStyle` | none | amethyst logo | Reply (inline) |
| Mentions (`MentionsID`) | DEFAULT | `BigTextStyle` | none | amethyst logo | — |
| Chess (`ChessID`) | DEFAULT | `BigTextStyle` | none | amethyst logo | — |
**Problems this plan fixes:**
1. **No color identity.** Every notification uses the same monochrome amethyst
status-bar icon and no `setColor()`. A zap, a like, and a git PR are visually
indistinguishable in the shade until you read the text.
2. **Only DMs get a real style.** Replies, mentions, public-chat, group chat all
fall back to `BigTextStyle` even though they are conversations that
`MessagingStyle` renders far better (avatar + name + threaded messages, and
it's what Android promotes into the Conversations section).
3. **Media notifications are text-only.** A `PictureEvent` / `VideoEvent`
mention says "X mentioned you" with no preview. `BigPictureStyle` should show
the actual image.
4. **A dozen kinds masquerade as "mentions."** Picture, video (×4), poll, git
(×4), highlight, long-form, wiki all route to the identical
`notifyMention()` → "X mentioned you". They deserve distinct titles/icons.
5. **No aggregation.** The in-app feed rolls up "5 people reacted / zapped your
note" into one `MultiSetCard`; the tray posts N separate notifications. This
is the biggest source of notification spam.
6. **Kind gaps vs. the in-app feed.** The push `when(event)` in
`dispatchForAccount` does **not** route `NutzapEvent` (9321), `OnchainZapEvent`
(8333), `RepostEvent`/`GenericRepostEvent` (6/16), `BadgeAwardEvent` (8),
`LiveActivitiesChatMessageEvent` (1311), or NIP-C7 `ChatEvent` (9) — all of
which *do* appear in the in-app Notifications tab. (`NotificationDispatcher`'s
`NOTIFICATION_KINDS` also has to grow for these to arrive at all.)
7. **Zaps look plain.** A zap — the app's signature interaction — renders as
grey `BigTextStyle` text. It should be a colorized gold card with the amount
as the hero.
---
## 2. Design principles ("beautiful & modern" made concrete)
Modern Android notification design (Material 3, API 2635) gives us six levers.
The redesign uses all six, per kind:
- **P1 — Accent color (`setColor`) + status-bar icon per category.** Each
category gets a monochrome small icon and a brand-consistent accent so the
shade is scannable pre-read. Palette anchored on existing tokens
(`Color.kt`): zaps `BitcoinOrange 0xFFF7931A`, reactions a heart-red, replies
& mentions amethyst `Purple200/500`, DMs a messaging blue-green, git a slate,
badges a gold. Reserve `setColorized(true)` (full-surface color) for the two
highest-signal kinds only — **zaps** and **incoming calls** (calls already
effectively do this via `CallStyle`).
- **P2 — Right style per kind.** `MessagingStyle` for anything conversational
(DMs, replies, mentions-that-are-replies, public/group chat); `BigPictureStyle`
for media; a colorized `BigTextStyle` "amount card" for zaps/nutzaps;
`InboxStyle` for aggregated roll-ups; plain for the rest.
- **P3 — Aggregation that mirrors the in-app `MultiSetCard`.** Reactions,
reposts, and zaps on the *same target note* collapse into one updating
notification: "🤙 Alice, Bob & 3 others liked your post." Reuse the exact
bucketing rules from `CardFeedContentState.convertToCard` (per-target,
per-day, chunk-of-30) so tray and feed agree.
- **P4 — Conversations & People (API 30+).** Publish a dynamic
`ShortcutInfoCompat` per chat partner / thread, tag DM & reply notifications
with `setShortcutId` + `addPerson`, and attach `BubbleMetadata`. This moves
chats into the dedicated Conversations section, enables bubbles, and gives
long-press "priority/silence" controls — the single most "modern" upgrade.
- **P5 — Circular avatars + emoji/kind badges.** Circle-crop the large-icon
avatar (today it's the raw square bitmap); keep the existing NIP-30
custom-emoji corner-badge overlay and extend the same overlay helper to stamp
a small kind glyph (bolt/heart/reply) so the avatar itself signals the kind.
- **P6 — Richer, semantic actions.** Reply (have it), **Mark-read** (have it for
DMs — extend to all), plus per-kind: **Like back** / **Zap back** on
reaction & zap notifications, **View thread** / **Mute thread** on replies &
mentions. All via `SEMANTIC_ACTION_*` so Wear/Auto/Assistant render them
correctly.
---
## 3. Proposed architecture — a per-kind renderer registry
Today the logic is split between a large `when(event)` in
`EventNotificationConsumer` and six near-duplicate `send*Notification` builders
in `NotificationUtils`. We refactor to a **spec + one builder** shape (the same
idea the desktop path already uses via `notifKindFor` + `buildSpec` in
`DesktopNotificationAutoDispatcher`).
**New: `NotificationCategory` (enum) —** the visual identity, replacing the six
ad-hoc channels. Each carries: channel id/name/importance, accent color,
small-icon drawable, default style, and default group key.
```
enum class NotificationCategory {
DIRECT_MESSAGE, // DMs, group chat — HIGH, blue-green, MessagingStyle, Conversation
REPLY, // replies to me — DEFAULT, purple, MessagingStyle, per-thread group
MENTION, // text mention/quote — DEFAULT, purple, plain/MessagingStyle
ZAP, // ln + nutzap + onchain — DEFAULT, gold, colorized amount card, aggregated
REACTION, // likes/emoji — LOW, heart-red, aggregated InboxStyle
REPOST, // boosts — LOW, green, aggregated
MEDIA, // picture/video mention — DEFAULT, purple, BigPictureStyle
ARTICLE, // long-form/wiki/highlight mention — DEFAULT, purple, plain
CODE, // git issue/patch/PR — DEFAULT, slate, plain
BADGE, // NIP-58 award — DEFAULT, gold, BigPictureStyle (badge image)
CHESS, // game events — DEFAULT, brown, plain
// calls keep their own CallNotifier / CallStyle path (already modern)
}
```
**New: `NotificationSpec` (data class) —** the fully-resolved, ready-to-render
description of one notification, produced per event:
```
data class NotificationSpec(
val category: NotificationCategory,
val dedupeId: String, // event id (existing id.hashCode() keying)
val title: String,
val body: String,
val timestamp: Long,
val author: PersonRef?, // name + avatar url → Person / large icon
val bigPictureUrl: String?, // media / badge image
val badgeUrl: String?, // NIP-30 emoji / kind glyph overlay
val deepLinkUri: String, // existing nostr:...?account=...&scrollTo=...
val groupKey: String, // aggregation bucket (per-thread / per-target)
val actions: List<NotifAction>,// Reply / ZapBack / Mute / MarkRead …
val aggregatable: Boolean, // reactions/zaps/reposts → roll-up
)
```
**New: `NotificationSpecFactory` —** one `fun specFor(event, account, ctx):
NotificationSpec?` that owns all the per-kind text/style decisions currently
scattered across `notify()/notifyReply()/notifyMention()/notifyZap()`. Returns
`null` when the event shouldn't notify. This is the single place a future kind
gets added.
**Rewritten: `NotificationUtils.render(spec)` —** one builder that reads the
spec, selects the style, applies accent/icon/colorized, attaches actions and
Person/shortcut, and handles aggregation + group summaries. The six
`send*Notification` functions collapse into this.
**Kept as-is:** `EventNotificationConsumer.dispatchForAccount` remains the gate
(mute/age/self/known-room checks) but its tail becomes
`NotificationUtils.render(NotificationSpecFactory.specFor(event, account, ctx))`.
This keeps the *policy* (who gets notified) exactly where it is and isolates the
*presentation* (how it looks) into a testable, table-driven unit.
---
## 4. Per-kind visual specification (the heart of the redesign)
| Kind(s) | Category | Style | Accent / icon | Title → Body | Actions |
| --- | --- | --- | --- | --- | --- |
| NIP-17 `ChatMessageEvent` (14), file (15), NIP-04 `PrivateDmEvent` (4), Marmot group | DIRECT_MESSAGE | `MessagingStyle` + Conversation shortcut + Bubble | blue-green / `chat` | sender → message | Reply, Mark-read |
| `TextNoteEvent`(1)/`CommentEvent`(1111)/`ChannelMessageEvent`(42) **reply to me** | REPLY | `MessagingStyle` (parent as prior message) | purple / `reply` | "X replied" → excerpt (+ quoted parent) | Reply, View thread, Mute thread |
| Same kinds, **mention/quote only** | MENTION | plain (BigText) | purple / `alternate_email` | "X mentioned you" → excerpt | View, Mute thread |
| `LnZapEvent`(9735) | ZAP | **colorized** amount card (BigText) | gold / `bolt` | "⚡ 2,100 sats from X" → zap comment + zapped-note excerpt | Zap back, View |
| `NutzapEvent`(9321) *(new)* | ZAP | colorized amount card | gold / `bolt` (cashu tint) | "🥜 X nutzapped you 2,100 sats" → … | Zap back, View |
| `OnchainZapEvent`(8333) *(new)* | ZAP | colorized amount card | gold / `bolt` | "⛓ X sent an onchain zap" → … | View |
| `ReactionEvent`(7) | REACTION | aggregated `InboxStyle` | heart-red / `favorite` | "🤙 X & N others liked your post" → note excerpt | Like back, View |
| `RepostEvent`(6)/`GenericRepostEvent`(16) *(new)* | REPOST | aggregated | green / `repeat` | "X & N others reposted you" → excerpt | View |
| `PictureEvent`(20)/`VideoNormal`/`Short`/`Vertical`/`Horizontal` | MEDIA | `BigPictureStyle` | purple / `image` | "X shared a photo/video" → caption, image inline | View |
| `PollEvent`/`ZapPollEvent` | MENTION | plain | purple / `ballot` | "X asked a question" → poll title | Vote, View |
| `HighlightEvent`(9802) | ARTICLE | plain | purple / `format_quote` | "X highlighted your article" → highlighted text | View |
| `LongTextNoteEvent`(30023)/`WikiNoteEvent`(30818) | ARTICLE | plain | purple / `article` | "X mentioned you in an article" → title | View |
| `GitIssueEvent`/`GitPatchEvent`/`GitPullRequestEvent`/`…Update` | CODE | plain | slate / `merge` | "X opened an issue/PR" → subject | View |
| `BadgeAwardEvent`(8) *(new)* | BADGE | `BigPictureStyle` (badge art) | gold / `award_star` | "You earned a badge" → badge name, image | View |
| `LiveChessGameAcceptEvent`/`LiveChessMoveEvent` | CHESS | plain | brown / `chess` | "X accepted your challenge" / "your turn" | Open board |
| `CallOfferEvent` | (unchanged — `CallNotifier`/`CallStyle`) | — | — | — | Accept/Reject |
Titles/bodies stay in `strings.xml` (translatable) with plurals for the
aggregate counts (see §7).
---
## 5. Channel restructuring
Channels are a **user-visible, migration-sensitive** surface (users may have
customized importance/sound). Plan:
- **Keep** the six existing channel IDs (DMs, Zaps, Reactions, Replies,
Mentions, Chess) — never rename an ID, it orphans user settings.
- **Add** channels for the newly-rendered categories that don't map cleanly onto
an existing one: **Reposts** (`RepostsID`, LOW), **Media** (`MediaID`,
DEFAULT), **Git/Code** (`CodeID`, DEFAULT), **Badges** (`BadgesID`, DEFAULT).
Nutzaps/onchain fold into the existing **Zaps** channel; articles/highlights
fold into **Mentions**.
- **Group channels** with `NotificationChannelGroup` ("Social", "Payments",
"Messages", "Developer") so the system settings page and our in-app
`NotificationSettingsScreen` read as organized rather than a flat list of 10.
- Register every new channel in `NotificationChannels.contentChannels` (drives
the settings UI) with its Material Symbol + name, matching the existing
`Entry` pattern.
- Reactions/Reposts drop to `IMPORTANCE_LOW` (silent, no heads-up) — they're
ambient; zaps/DMs/replies stay DEFAULT/HIGH.
---
## 6. Capability upgrades (implementation notes)
- **Accent + icon:** add monochrome vector status-bar icons under
`amethyst/src/main/res/drawable/` (they must be white-on-transparent per
Android's small-icon rules — *not* the app logo). `builder.setColor(accent)`;
`setColorized(true)` only for ZAP + CALL. Store accent per category on the
enum.
- **MessagingStyle for replies/mentions/chat:** generalize the existing
`sendDMNotificationStyled` `MessagingStyle` construction into
`render()` for every conversational category. For replies, add the parent note
as an earlier `addMessage(...)` from the original author so the thread shows
context. Reuse the existing `Person` + avatar `IconCompat` code.
- **BigPictureStyle:** in `render()`, when `spec.bigPictureUrl != null`, load it
via the existing Coil `loadBitmap` helper and
`NotificationCompat.BigPictureStyle().bigPicture(bmp)` with the avatar as
large icon; collapse to `BigTextStyle` if the image fails to load.
- **Aggregation:** introduce a small `NotificationAggregator` keyed by
`groupKey` (per-target-note for reactions/zaps/reposts). On each new event it
reads `activeNotifications` for the group, recomputes the roll-up
(participants + count), and re-`notify()`s the same id with an updated
`InboxStyle`/title — mirroring `CardFeedContentState`'s per-target buckets.
Falls back to a single notification below the threshold. Keeps the existing
`sendGroupSummary` behavior for cross-target bundling.
- **Circular avatars:** add a `circleCrop(bitmap)` step in `loadBitmap` (reuse
the `Canvas` approach already in `overlayBadge`). Kind-glyph overlay reuses
`overlayBadge`.
- **Conversations/Bubbles:** publish `ShortcutInfoCompat` (long-lived, with the
partner `Person`) when a DM/reply notification posts; set `shortcutId`,
`addPerson`, and `BubbleMetadata` pointing at a
`MainActivity`/`CallActivity`-style chat entry. Guard by API level.
- **Dedup / dismiss-on-read:** unchanged (`isDuplicate`,
`dismissNotificationForEvent` by `eventId.hashCode()`); aggregation reuses a
stable per-group id so updates replace rather than stack.
---
## 7. Icons & strings work (required side-tasks)
- **Icons (two kinds):**
1. **Status-bar small icons** are Android drawables (white glyphs) — new
vector XML in `res/drawable/`. *Not* Material Symbols font glyphs.
2. **Settings-list icons** in `NotificationChannels` use `MaterialSymbols`. If
any new `MaterialSymbol("\uXXXX")` codepoint is introduced (e.g. `Repeat`,
`Merge`, `AwardStar`, `Ballot`, `Image`) that isn't already referenced,
**regenerate the subset font**: `./tools/material-symbols-subset/subset.sh`
and commit `material_symbols_outlined.ttf` — otherwise it renders as tofu.
- **Strings:** new titles per kind (repost, media, badge, poll, highlight, git,
nutzap, onchain) as translatable resources; aggregate counts become
`<plurals>` (`"%1$s & %2$d others liked your post"`) with the CLDR-category
discipline from `res/CLAUDE.md` (Slavic/Baltic/Arabic `few`/`many`/`zero`).
Use the `pluralStringRes(ctx, …)` helper (non-Compose) in the service path.
---
## 8. Parity gaps to close (behavioral, not just visual)
For these to *render*, two lists must include the kind **and**
`NotificationSpecFactory` must handle it:
- `NotificationDispatcher.NOTIFICATION_KINDS` (Android gate) — add
`NutzapEvent`, `OnchainZapEvent`, `RepostEvent`, `GenericRepostEvent`,
`BadgeAwardEvent`, `LiveActivitiesChatMessageEvent`, NIP-C7 `ChatEvent`.
- `commons/.../NotificationKinds.SUBSCRIPTION_KINDS` (relay REQ list) — must
already carry them so they arrive; verify against
`NotificationKindsContractTest` and extend if needed.
- Keep the in-app `NotificationFeedFilter.NOTIFICATION_KINDS` and the push path
in lockstep — the contract test should assert push-rendered ⊆ in-app-shown.
Decision needed (flagged for the maintainer, §11): reposts/reactions are
*aggregate-only* by design — confirm we want them in the tray at all (many users
consider like/repost notifications noise; hence `IMPORTANCE_LOW` + off-by-default
mirroring the desktop `KindToggles` where reaction/repost default off).
---
## 9. Phased implementation
- **Phase 0 — Refactor to spec+builder (no visual change).** Introduce
`NotificationCategory`, `NotificationSpec`, `NotificationSpecFactory`,
`NotificationUtils.render(spec)`; port the six existing kinds onto it. Prove
parity with existing behavior via unit tests. *No user-visible diff.*
- **Phase 1 — Color & icons.** Accent + status-bar icon per category, circular
avatars, colorized zap card. Highest visual ROI, lowest risk.
- **Phase 2 — Styles.** MessagingStyle for replies/mentions/chat;
BigPictureStyle for media & badges.
- **Phase 3 — Aggregation.** Roll-up for reactions/reposts/zaps via
`NotificationAggregator`; new Reposts channel; LOW importance for ambient
kinds.
- **Phase 4 — Parity kinds.** Route nutzap, onchain zap, repost, badge, live
chat; grow the dispatcher/subscription kind lists; strings + plurals.
- **Phase 5 — Conversations/Bubbles/People + richer actions.** Shortcuts,
bubbles, Zap-back / Like-back / Mute-thread actions.
- **Phase 6 — Channel groups + settings polish.** `NotificationChannelGroup`s;
update `NotificationSettingsScreen`.
Each phase is independently shippable and revertible.
---
## 10. Testing
- **JVM unit tests** for `NotificationSpecFactory.specFor(...)` — table-driven,
one fixture event per kind asserting category/title/body/style/actions. This
is the regression net that lets the six-kind refactor land safely.
- **Contract test** extending `NotificationKindsContractTest`: every kind the
factory produces a spec for is in `NOTIFICATION_KINDS` and `SUBSCRIPTION_KINDS`.
- **Aggregation tests:** feed 1 → 5 reactions on one note, assert a single
updating notification with correct count/participants (mirror
`CardFeedContentState` test fixtures if they exist).
- **Manual matrix** on Android 12 / 14 / 15 (and one heavily-skinned OEM):
screenshot each category light+dark, verify color/icon/heads-up/lockscreen
(public version) and Conversation-section placement for DMs.
- **`amy`**: no CLI surface for tray rendering, but the spec factory is pure
JVM and can be exercised headless.
---
## 11. Risks & decisions for the maintainer
- **Notification volume.** Aggregation + LOW-importance reactions/reposts are
the mitigation; still, **confirm** reactions/reposts belong in the tray
(default-off recommended, matching desktop).
- **Small-icon assets.** Must be true monochrome white glyphs; the current
colored amethyst logo is *not* a valid small icon and is the reason the shade
looks flat today. Needs new drawables.
- **Channel proliferation.** Ten channels risks a cluttered settings page —
hence `NotificationChannelGroup`s; alternatively keep the six and only vary
color/icon/style within them (color/style don't require a new channel; only
independent user importance/sound control does). *Recommend: add only
Reposts + Media + Badges channels, fold the rest.*
- **Font subset drift.** Any new Material Symbol must trigger `subset.sh` or it
renders as tofu — easy to forget; call it out in the PR checklist.
- **Marmot/encrypted content.** MessagingStyle/Bubbles must not leak decrypted
DM/group text to the lockscreen public version — the existing
`setPublicVersion` "New notification arrived" placeholder must be preserved for
all private categories.
---
## Survey (existing components reused vs. new)
- **Reused as-is:** the entire transport + gate layer (`PushWrapDecryptor`,
`NotificationDispatcher` gates, `EventNotificationConsumer` mute/age/self/
known-room checks), Coil `loadBitmap`, `overlayBadge`, `isDuplicate`,
`dismissNotificationForEvent`, `NotificationReplyReceiver` (extended for
new actions), `NotificationChannels`/`NotificationSettingsScreen` structure.
- **Extracted/generalized:** the `MessagingStyle` construction from
`sendDMNotificationStyled` becomes the shared conversational renderer; the six
`send*Notification` builders collapse into `render(spec)`.
- **Genuinely new (Android-only, correct module):** `NotificationCategory`,
`NotificationSpec`, `NotificationSpecFactory`, `NotificationAggregator`,
status-bar drawables, channel groups, shortcut/bubble publishing.
- **Aggregation logic** is a port of `CardFeedContentState.convertToCard`
bucketing — same rules, tray output instead of `Card`s. Consider extracting
the bucketing to `commons` so feed and tray share one implementation.
-199
View File
@@ -1,199 +0,0 @@
# NWC BOLT12 payments (nostr-wallet-connect/nwc#2)
Status: **scoping** — no code yet. Depends on NIP-B1 (this branch) and the
unmerged `nostr-wallet-connect/nwc#2` (adds `pay`/`receive` to NIP-47).
## Ecosystem status (2026-07-25) — the wallet gap for real testing
A real NWC-321 (`pay`/`receive`) service exists —
[benthecarman/nostr-wallet-connect-lnd](https://github.com/benthecarman/nostr-wallet-connect-lnd)
— which confirms the protocol we built (Phase 0) is real and adopted. **But it is
LND-backed:** its `pay` "selects and pays a BOLT11 `lightning` instruction from a
BIP-321 URI" and "BOLT12 `lno` instructions are not supported by this LND backend."
So it returns no BOLT12 invoice and no `payer_proof`, and can't drive the NIP-B1
zap loop. The blocker is the **node backend**, not the protocol: a CLN- or
LDK-backed NWC-321 service (CLN has full BOLT12 and leads the payer-proof draft
bolts#1346) would support `lno` + return `payer_proof`. Until one exists, the
self-consistent interop harness (`cli/tests/bolt12/`, TODO) is the only way to
exercise the full send → verify → count loop.
## What nwc#2 adds
Two generalized methods replace the bolt11-only `pay_invoice`:
- **`pay`** — params `{ payment: "bitcoin:?lno=lno1…", amount?, payer_note?, metadata? }`.
`payment` is a BIP321 URI (so `bitcoin:?lno=` — exactly what
`payViaBolt12Intent` already builds — or `lightning=`/on-chain). `amount`
(msats) is required only when the instruction has no amount. Result:
`{ transaction_id, state, instruction_type, amount, fees_paid, payment_hash,
preimage, payer_proof: "lnp1…", txid, failure_reason, created_at, settled_at }`.
- **`receive`** — params `{ amount?, description?, metadata? }` → result
`{ bip321: "bitcoin:?lightning=lnbc…&lno=lno1…", transaction_id }`. Lets a
wallet mint our own unified offer; ties to the kind-10058 editor (auto-fill
offers) — later phase.
New errors: `UNSUPPORTED_PAYMENT_INSTRUCTION`, `UNSUPPORTED_NETWORK`. Wallets
advertise support in their kind-13194 info event + `get_info.methods`.
## Two capabilities this unlocks
**A. In-app payment of an offer** — the "extra payment instruction" half. Today
`Bolt12PayButton` fires a `bitcoin:?lno=` intent to an external wallet. With `pay`
we can settle the offer over the user's already-configured NWC connection, no app
switch. No Nostr receipt.
**B. Sending real BOLT12 zaps** — the half deferred since the start of the branch.
The `pay` result carries **`payer_proof: "lnp1…"`**, which is precisely the input
`Bolt12ZapEvent.build(signedIntent, payerProof, payerPubKey)` needs. So NWC `pay`
is the payment rail that produces the proof for a kind-9736 zap. This is the
strategic reason to do this now.
## Existing NIP-47 infra we reuse (from the code map)
The send/await/correlate plumbing is **method-agnostic** — it keys on request id
and dispatches the decrypted `Response`, so a new method needs no changes there:
- `NwcSignerState.sendNwcRequestToWallet(uri, request, onResponse)`
(`amethyst/…/model/nip47WalletConnect/NwcSignerState.kt`) — builds the 23194,
synchronous REQ-before-EVENT via `NWCPaymentFilterAssembler`, 60 s timeout,
decrypt-on-arrival.
- `NwcPaymentTracker` (`commons/…/service/nwc/`) — request↔response match with the
author-spoof gate.
- `LocalCache.consume(LnZapPaymentResponseEvent)` — routes 23195 back to the callback.
- Wallet storage: `AccountSettings.nwcWallets` + `defaultPaymentSourceId`;
`PaymentSourceResolver`.
- Pay rail entry: `ZapPaymentHandler.zap()``payViaNWC()` (the `PaymentSource.Nwc`
branch).
Capability discovery exists (`NwcInfoEvent.supportsMethod`, `GetInfoResult.methods`)
but is **not** wired into the pay path — we'd add the gate ourselves.
## Work breakdown
### Phase 0 — quartz protocol (`nip47WalletConnect`)
- `NwcMethod.PAY = "pay"`, `NwcMethod.RECEIVE = "receive"`.
- `rpc/Request.kt`: `PayMethod` + `PayParams(payment, amount, payerNote, metadata)`
with `create(…)`; `ReceiveMethod`/`ReceiveParams`.
- `rpc/Response.kt`: `PaySuccessResponse` (all result fields above, `payerProof`
nullable) and `ReceiveSuccessResponse(bip321, transactionId)`.
- `rpc/NwcErrorCode.kt`: add the two new codes.
- Serializer branches in **both** `Nip47RequestKSerializer` / `Nip47ResponseKSerializer`
**and** the jvmAndroid Jackson variants.
- Optional `Nip47Client.pay(...)` builder.
- Tests: request/response round-trip; a real captured `pay` result fixture.
- No new third-party deps (pure protocol) → licensing clean.
### Phase 1 — in-app offer payment (capability A)
- `Account.sendNwcPayRequest(payment, amount, payerNote, onResponse)` wrapper
(mirrors `sendZapPaymentRequestFor`, reuses `NwcSignerState`).
- In `Bolt12PayButton`'s dialog: when an NWC wallet is configured, add a "Pay with
connected wallet" action (amount-entry sheet, since offers are often amountless)
`pay` with `payment=bitcoin:?lno=<offer>`. Keep the external-intent path as
fallback.
- Surface `UNSUPPORTED_PAYMENT_INSTRUCTION`/`PAYMENT_FAILED`.
### Phase 2 — send BOLT12 zaps (capability B)
- New `Bolt12ZapSender` (or extend `ZapPaymentHandler`): when the recipient has a
kind-10058 offer and the wallet supports `pay`, offer a BOLT12 zap.
1. Build + sign kind-9737 intent (amount, offer, p, e/a/k, zap_id, content).
2. `pay` with `payment=bitcoin:?lno=<offer>`, `amount`,
`payer_note="nostr:nipB1:<intent-id>"`.
3. On success with a `payer_proof`, `Bolt12ZapEvent.build(intent, payerProof,
payerPubKey = own | null for anon)`, sign, publish to the recipient's inbox
relays.
4. Our own `LocalCache` consumes the 9736 and counts it.
- Anonymous vs attributed (`P` tag) toggle, mirroring lightning-zap anonymity.
### Phase 3 — gating + receive (later)
- Read `NwcInfoEvent.supportsMethod("pay")` / `get_info.methods` to show the NWC
BOLT12 options only when supported; else fall back to the intent.
- `receive` to mint the user's own offer and pre-fill the kind-10058 editor.
## Risks / open questions (decide before Phase 2)
1. **`payer_note` → `invreq_payer_note` is NOT guaranteed by nwc#2.** The spec only
says "if `payer_note` is not empty, the selected instruction MUST support
payer-provided messages" — it never states the note lands in the BOLT12
`invreq_payer_note`. NIP-B1 binds the zap to the intent through exactly that
field (`invreq_payer_note == nostr:nipB1:<intent-id>`). If a wallet routes
`payer_note` elsewhere, the returned `payer_proof` fails our validator and the
9736 is worthless. **Phase 2 feasibility hinges on this** — needs confirmation
in the nwc thread / a reference wallet, or a follow-up to nwc#2 to nail it down.
2. **`payer_proof` is best-effort** (`"optional if unavailable"`, no wallet mandate).
A wallet may settle the offer and return no proof → payment succeeds but we
can't publish a zap. Phase 1 is unaffected; Phase 2 must degrade gracefully
("paid, but no zap receipt available").
3. ~~**Our verifier can't check compressed proofs yet.**~~ **Resolved.** The
compressed-proof merkle reconstruction shipped and is validated byte-for-byte
against the lightning/bolts#1346 conformance vectors (see
`quartz/plans/2026-07-23-bolt12-zap-interop-vectors.md`). Real wallet
(selective-disclosure) proofs now reconstruct and verify, so a bound zap counts
locally as `cryptoVerified = true`.
4. **Maturity.** Both nwc#2 and NIP-B1 are unmerged; few/no wallets implement
`pay` today. Gate hard on capability (Phase 3) and keep the intent fallback.
## Resolution of risks #1/#2 (nwc#2 maintainer, 2026-07-24)
Asked on the nwc#2 thread. Maintainer confirmed:
- **`payer_note` → `invreq_payer_note`**: "that is the intention" for BOLT12 (the
field doubles as a general memo). So our zap binding
(`invreq_payer_note == nostr:nipB1:<intent-id>`) is the intended target.
- **`payer_proof`**: "should be returned for successful bolt12 payments"; the
"optional if unavailable" wording only covers non-BOLT12 instruction types.
Both are informal maintainer intent, not yet spec text, so a non-conforming wallet
is still possible. That's fine: after a `pay` returns, we run the returned
`payer_proof` through `Bolt12ZapValidator` **before** publishing a kind:9736. A
wallet that misroutes the note fails the binding check and we publish nothing —
Phase 2 fails safe, never emitting an invalid receipt.
## Recommendation
Phase 0 (done) and Phase 1 (done) shipped. **Phase 2 is now unblocked.** Build it
with the validate-before-publish gate above; degrade to "paid, no zap receipt"
when the proof is absent or fails validation.
## Phase 2 as shipped (full zap-button integration, BOLT12-first)
Chosen: full integration into the zap pipeline, preferring BOLT12 when the
recipient offers it.
- `Account.sendBolt12Zap(...)` — signs a 9737 intent (ephemeral key for anonymous),
pays over NWC with `payer_note = nostr:nipB1:<intent-id>`, and only on a proof
that passes `Bolt12ZapValidator` self-consumes + publishes the 9736 via
`computeRelayListToBroadcast`. No proof / invalid proof → "paid, no receipt".
- `ZapPaymentHandler.zap()` resolves each recipient's kind:10058 offer and
**partitions** recipients into a BOLT12 lane (offer present AND an NWC wallet is
configured) and the existing lightning lane. Split weights are summed across both
lanes (`totalWeight` threaded into `signAllZapRequests`/`assembleAllInvoices`) so
mixed splits stay proportional. Anonymous/public follows the account zap type.
- `Bolt12ZapBuilderTest` proves the send-side assembly round-trips to a
validator-accepted, crypto-verified zap (attributed and anonymous).
## Phase 3 as shipped (capability gate + lightning fallback)
- `Account.defaultWalletSupportsBolt12Pay()` reads the default wallet's cached
kind:13194 info event via `NwcSignerState.infoCache` (added on `main` for
encryption negotiation; it already refreshes on wallet change) and checks
`supportsMethod("pay")`. A missing/unfetched info event reads as false. (An earlier
cut fetched `get_info.methods` into its own state; unified onto the 13194 cache on
the `main` merge to avoid a redundant fetch — the info event is the canonical
capability advertisement.)
- The zap path's
`canBolt12` now requires it, so a recipient with an offer but a wallet that can't
`pay` **falls back to lightning** via the existing partition instead of erroring.
- `AccountViewModel.canPayBolt12ViaNwc()` gates the profile "pay with wallet" action
on the same signal.
Residual (acceptable): capabilities are empty for the first moment after launch
until `get_info` returns, so a very early zap can miss the BOLT12 rail and use
lightning; and a wallet that doesn't populate `get_info.methods` never gets the
BOLT12 rail even if it supports `pay`. Both fail safe toward lightning.
Known limitations (follow-ons, not blockers):
- ~~**Compressed proofs aren't locally counted.**~~ **Resolved.** Compressed
(selective-disclosure) proofs now reconstruct and verify, so `updateZapTotal`
counts a bound zap locally. Validated against the lightning/bolts#1346
conformance vectors — see `quartz/plans/2026-07-23-bolt12-zap-interop-vectors.md`.
File diff suppressed because it is too large Load Diff
@@ -1,808 +0,0 @@
# Location: foreground-gate the listener, trim the request, fix the meter
Date: 2026-07-29
Module: `amethyst`
Origin: Finding 1 of `2026-07-29-resource-report-1.13.0-analysis.md` (1.13.0-PLAY,
Pixel 9a / Android 17)
Revision: 2 — incorporates spec review of 2026-07-29
## Context
The 1.13.0 resource report showed **7.13 h of location listening against 9.1
seconds of app foreground**, and the accompanying analysis called it the leading
suspect for that day's 11 pp of background battery drain. Root cause given: 30
`SharingStarted.Eagerly` top-nav filter states on the account scope
(`Account.kt:843-933`), one of which is always `TopFilter.AroundMe` because
`AccountSettings.kt:256` ships that as the Products default. The `AroundMe`
branch collects `locationFlow()`, and an `Eagerly`-shared subscriber holds
`LocationState`'s `WhileSubscribed(5000)` open for the life of the process.
That chain is real. **The battery conclusion drawn from it is not**, and this
spec is written against the measurement rather than the inference.
### What the device reports
`amethyst/src/main/AndroidManifest.xml:80` declares **only**
`ACCESS_COARSE_LOCATION` — no `ACCESS_FINE_LOCATION`, no
`ACCESS_BACKGROUND_LOCATION` — and no service declares
`foregroundServiceType="location"` (the declared types are `mediaPlayback`,
`microphone`, `camera`, `phoneCall`, `shortService`, `dataSync`, `specialUse`).
`targetSdk = 37`.
`adb shell dumpsys location`, Pixel 9a, 21 d 7 h of uptime. **These are the
`com.vitorpamplona.amethyst` rows** of the per-provider *Historical Aggregate
Location Provider Data* block — not system-wide totals:
| provider | registration held | **active** | **foreground** | fixes |
|---|---|---|---|---|
| passive | 9 d 14 h 20 m | 8 h 26 m 14 s | 8 h 14 m 50 s | 107 |
| network | 9 d 14 h 20 m | 8 h 26 m 13 s | 8 h 14 m 49 s | 95 |
| fused | 9 d 14 h 20 m | 8 h 26 m 12 s | 8 h 14 m 48 s | 95 |
| gps | 9 d 14 h 20 m | 8 h 26 m 11 s | 8 h 14 m 47 s | 98 |
Roughly **11 minutes of background-active location in three weeks**, and about
100 delivered fixes per provider. With the app backgrounded at the time of the
dump: `gps provider: service: ProviderRequest[OFF]`, `gps_hardware:
mStarted=false`.
The cleanest assumption-free comparison: the ledger's **two-day** `location.ms`
total (3.57 h + 7.13 h = 10.70 h) **exceeds the OS's three-week active total**
(8 h 26 m) by 27 %. `location.ms` is not measuring what its label implies.
**One figure does not reconcile, and is left open.** Registration-held is
9 d 14 h over 21 d 7 h ≈ 10.8 h/day, whereas `location.ms` averages 5.35 h/day
across the two ledger days. If `location.ms` measured subscription existence
these should agree; they are ~2× apart. Candidates: segments open at process
death are lost (the pre-flush hook does not run on a kill, and the report shows
6 process starts across the two days); the ledger covers 2 days while dumpsys
spans 21 with different usage. Not investigated. It does not affect the
conclusion below, which rests on `location.ms` versus OS-*active* time, not
versus registration-held.
### How far the "no background location" claim generalises
This matters because the "no behaviour change" argument rests on it, so it is
scoped rather than asserted universally:
- **API 29+ (Android 10 and up):** `ACCESS_BACKGROUND_LOCATION` gates background
access, and for `targetSdk ≥ 29` an FGS additionally needs
`foregroundServiceType="location"`. Amethyst has neither, so background
registrations are suspended. This is the case the Pixel 9a measurement above
covers.
- **API 2628 (Android 89):** `ACCESS_BACKGROUND_LOCATION` does not exist.
Amethyst *can* receive background location there, throttled by the platform to
a few updates per hour. The gate is a genuine, if small, improvement on these
releases rather than a no-op.
So "structural" applies to API 29+; on 2628 the change has real effect.
### What is actually wrong
1. **The meter lies.** `location.ms` measures how long a *subscription* existed,
not how long anything listened. That is what made Finding 1 read as the
top-priority battery bug.
2. **The request is 4× redundant.** `LocationFlow.kt:55` iterates
`locationManager.allProviders` and calls `requestLocationUpdates` on each —
passive, network, fused **and** gps (the last tagged `HIGH_ACCURACY`) — every
one at `@+10s0ms, minUpdateDistance=100.0`, to produce a **5 km** geohash.
This burns during the 8 h 14 m the app genuinely is foreground.
3. **The subscription is held for 45 % of device uptime** doing nothing, because
`Eagerly` never lets go.
4. **Every user is exposed**, since Products defaults to `AroundMe` and needs no
opt-in.
5. **Location may be entirely broken on Android 811** — see Hypothesis H1.
## Hypothesis H1 — location is dead below API 31 (unverified)
Through Android 11, AOSP's `getMinimumPermissionForProvider` required
`ACCESS_FINE_LOCATION` for the `gps`, `passive` and `fused` providers; only
`network` accepted `ACCESS_COARSE_LOCATION`. Approximate-location, which lets a
coarse-only app request any provider and receive a fuzzed result, is an Android
12 (API 31) change.
Amethyst holds coarse only. So on API 2630 today's `allProviders` loop should
throw `SecurityException` on three of the four providers. Two consequences:
- The throw escapes the `callbackFlow` builder → `.catch` in `LocationState`
`LackPermission`. Location would be **non-functional** on Android 811.
- The builder aborting means `awaitClose` never runs, so `removeUpdates` is
never called and any registration made before the throw **leaks** for the life
of the process.
**The leak is iteration-order dependent, and may not occur at all.**
`getLastKnownLocation` is permission-checked per provider too, and
`LocationFlow.kt:56-68` calls it *before* `requestLocationUpdates` on each
iteration. If a fine-only provider comes first in `allProviders``passive`
does, in the common AOSP ordering — the throw lands before any registration
exists: dead, but not leaking. A leak requires `network` to precede a fine-only
provider.
`@SuppressLint("MissingPermission")` at `LocationFlow.kt:40` suppresses the lint
warning, not the runtime check, so this would not have been caught statically.
**Not reproduced.** Verify before implementing, on the existing
`Medium_Phone_API_26_8_` AVD: grant coarse only, open a screen that subscribes,
and capture **both** the `SecurityException` *and* the actual
`locationManager.allProviders` order (log it). The PR should claim only what that
run observed — "location is dead on Android 811" and "registrations leak" are
separate claims and the second may not hold.
The design below is written to be correct either way (§B excludes
permission-incompatible providers by API level, and catches `SecurityException`
per provider). If H1 holds, this change also **fixes location on Android 811**,
which should be called out in the PR.
### H1 verification result (2026-07-30, Pixel 9a, API 37)
Partial. The API-level claim could **not** be tested on this hardware: API 31+
grants coarse-only apps access to every provider, so no `SecurityException` can
appear regardless of whether H1 is true. Only an API ≤ 30 image can settle it.
What *was* settled is the ordering, which decides the leak sub-claim.
`dumpsys location` recent-events shows the same iteration order on every
registration cycle across two days, all four sharing one registration id
(`88A8E679`), confirming a single `LocationFlow` subscription:
```
07-30 07:09:58.282: passive provider +registration .../88A8E679
07-30 07:09:58.291: network provider +registration .../88A8E679
07-30 07:09:58.293: fused provider +registration .../88A8E679
07-30 07:09:58.299: gps provider +registration .../88A8E679
```
`allProviders` yields **passive first**, and `passive` is one of the fine-only
providers below API 31. So on Android 811 the throw would land on the first
iteration, before any registration exists:
- "location is dead on Android 811" — **still unverified**, needs API ≤ 30.
- "registrations leak" — **disproved for this ordering**. Dead, but not leaking.
The PR must not claim the leak. Caveat: the ordering is observed on API 37 and
`getAllProviders()` could order differently on API 26.
**Unrelated but decisive "before" datum, same session:** with
`mWakefulness=Dozing` (screen off, device dozing) and `MainActivity` sitting in
`mLastPausedActivity`, Amethyst held **four** live registrations at
`@+10s0ms / minUpdateDistance=100.0`. That is the state §A's gate exists to
eliminate, captured on the owner's daily-driver device rather than an emulator.
## Goals
- Release the location registration whenever no activity is started.
- Register on one appropriate, permission-compatible provider at an interval
matched to the precision actually needed.
- Make `location.ms` reflect real listening time, correctly, under concurrency.
- No user-visible behaviour change to the "Around Me" feed or geohash chats.
## Non-goals
- Changing the Products `AroundMe` default (`AccountSettings.kt:256`). With the
gate in place its cost is bounded to foreground use. Worth revisiting
separately as a product decision.
- Requesting `ACCESS_FINE_LOCATION` or `ACCESS_BACKGROUND_LOCATION`.
- Findings 26 of the source analysis. Finding 2 (the relay reconnect storm,
1.65 GB/day at a 75 % dial-failure rate) is the more likely explanation for
the background battery drain and should be taken next.
## Design
### A. The gate
`LocationState` gains an `isForeground: StateFlow<Boolean>` parameter, wired in
`AppModules.kt:251` from the existing `foregroundTracker` (`AppModules.kt:333`,
registered at `Amethyst.kt:122`). `locationManager` is `by lazy`, so
initialisation order is safe.
Today's `hasLocationPermission.transformLatest { … }` becomes a three-state gate
over *permission × foreground*, applied identically to `geohashStateFlow` and
`preciseGeohashStateFlow`:
| gate state | behaviour |
|---|---|
| no permission | emit `LackPermission` (unchanged) |
| permitted, foreground | **R1**: emit `Loading` *only if* no `Success` is cached; then `emitAll(locationSource(…))` |
| permitted, backgrounded | emit nothing; the registration is released and the `StateFlow` retains its last value |
**R1 is a requirement, not an improvement.** `AroundMeFeedFlow.convert` collapses
to `geotags = emptySet()` for anything that is not `Success`. Without R1 the gate
would make the "Around Me" feed flash empty on **every** return to foreground — a
new, frequent, user-visible regression introduced by this change. (It also fixes
the same flash on permission grant, which exists today.)
**R1 corollary: the `NoPermission` branch must not clear the cache.** Today's
code emits `LackPermission` without touching `latestLocation`
(`LocationState.kt:94-96`), and that stays. Clearing it is superficially
attractive — a revoked permission arguably should not leave a fix readable — but
consumers already see `LackPermission` from the `StateFlow`; `latestLocation` is
private and its only jobs are seeding `stateIn` and deciding whether `Loading` is
emitted. Clearing it would therefore buy no privacy and would cost an
empty-feed flash on every permission flap, which is precisely what R1 exists to
prevent. The cache is in-memory and dies with the process regardless.
The `.catch` branch **does** clear the cache, and keeps doing so. That asymmetry
looks arbitrary next to the paragraph above, so to be explicit: it is inherited,
not introduced. Both branches preserve today's behaviour exactly
(`LocationState.kt:87-91` clears on failure, `:94-96` does not clear on missing
permission). This corollary argues against *adding* a clear, not for removing
the existing one — changing it would be an unmotivated behaviour change. The
asymmetry is also defensible on its own terms: a source that failed mid-stream
says something about the fix's provenance, whereas a permission known to be
absent says nothing about a fix already taken.
**R2 — grace period on the background edge.** The gate must delay the
`foreground → background` transition by **5 s** before tearing down. Without it a
one-second app switch destroys and rebuilds the registration, including a full
`getLastKnownLocation` sweep, so a user flipping between apps pays more than the
steady state. 5 s matches the existing `WhileSubscribed(5000)` and is the same
intent. The `background → foreground` edge is **not** delayed.
Mechanism, stated because the obvious operator is the wrong one: `debounce(5000)`
delays both edges, and the duration-selector overload that would allow an
asymmetric delay is `@FlowPreview`. Use `transformLatest`, already in this file
and already opted into via `@OptIn(ExperimentalCoroutinesApi::class)`:
```kotlin
isForeground.transformLatest { fg ->
if (!fg) delay(BACKGROUND_GRACE_MS)
emit(fg)
}
```
`transformLatest` cancels the pending `delay` if foreground returns first, which
is exactly the stated semantics, with no preview opt-in.
**R3 — the retained-value contract.** The "emit nothing" branch is what keeps
this behaviour-neutral: `stateIn` holds the last `Success`, so the ~60
synchronous `.value` reads across the feed filters
(`HomeNewThreadFeedFilter.kt`, `VideoFeedFilter.kt`,
`DiscoverLongFormFeedFilter.kt`, …) keep seeing the last known geohash. A 5 km
cell does not meaningfully decay while backgrounded.
**R4 — memory visibility.** `latestLocation` and `latestPreciseLocation`
(`LocationState.kt:63-64`) are plain `var`s today, used only as `stateIn` initial
values. R1 promotes them to control flow, read from a different coroutine than
the `onEach` that writes them. They must become `@Volatile` (or
`MutableStateFlow`).
**Rejected alternative:** switching `FeedTopNavFilterState.flow` from `Eagerly`
to `WhileSubscribed`. Roughly 60 call sites read
`account.live*FollowLists.value` synchronously rather than collecting; under
`WhileSubscribed` those reads would silently serve a stale or initial value
whenever no collector happened to be active. That is a correctness regression,
not a battery fix.
**Rejected alternative:** gating only at the `AppModules` wiring point
(`geolocationFlow = { … }`). Smaller diff, but it leaves the raw
`geohashStateFlow` as a loaded gun for the next eager consumer, does nothing for
`preciseGeohashStateFlow`, and introduces a second `StateFlow` layer over the
same data.
### B. Request shape
`LocationFlow.get` registers on **one** provider, chosen by a ladder over
**provider existence and permission compatibility** — both static facts:
```
chooseProviders(sdkInt, hasFine, exists) -> List<String>:
API 31+ or hasFine → [FUSED, NETWORK, GPS, PASSIVE] filtered by exists
API < 31, coarse → [NETWORK] filtered by exists (see H1)
```
It returns the **ordered candidate list**, not a single choice, because the
per-provider `SecurityException` fall-through below needs somewhere to fall to.
An empty list means no compatible provider exists.
**The ladder deliberately does not consult `isProviderEnabled`.** Today's code
registers regardless of enabled state, and such a registration goes live by
itself when the user enables location — including from the quick-settings shade
without leaving the app, which is exactly what someone does after seeing "Around
Me" empty. A guard evaluated once at subscription start would lose that, and the
foreground-transition restart does not cover the in-app path. Selecting on
existence keeps the property with no `PROVIDERS_CHANGED_ACTION` receiver. If
field reports show dead feeds on devices where the chosen provider exists but is
disabled while another is enabled, adding that receiver is the follow-up.
`requestLocationUpdates` is wrapped in a per-provider `SecurityException` catch
that falls through to the next rung, so H1 cannot abort the builder and leak
registrations regardless of how the AOSP check actually behaves.
**When no provider can be registered** — the candidate list was empty, or every
rung threw — `LocationFlow` **throws** `SecurityException`. It cannot emit
`LackPermission`: the seam is `(Long, Float) -> Flow<Location>`, and
`LackPermission` is a `LocationState.LocationResult`, which `LocationFlow` has no
way to express. Throwing routes it through the `.catch` already present in
`LocationState` (`LocationState.kt:87-91`, `:126-130`), which sets
`latestLocation = LackPermission` and emits it — the existing, unchanged path.
Throwing covers **both** failure cases, and it subsumes R5's `registered`-flag
guard: the throw happens before the acquire, so `onListening(true)` cannot fire
without a live registration and no separate flag is needed. That is only half of
R5's pairing, though — see R5 for the release half, which the throw does **not**
cover and which needs `try`/`finally`.
**Stated decision: `LackPermission` stays conflated with "no usable provider".**
That value renders `R.string.lack_location_permissions` — "No Location
Permissions" — at `DisplayLocationObserver.kt:49` and `FeedFilterSpinner.kt:224`,
which is wrong for a coarse-only pre-31 device that has no `network` provider.
The conflation is accepted rather than introduced: if H1 holds, today's
`SecurityException` already lands in the same `.catch` and shows the same wrong
message. Adding an `Unavailable` state would ripple through four UI `when`s plus
`LocationState` (10 references across 5 files) and belongs with the H1 fix
messaging, not here. Recorded as a follow-up.
The `getLastKnownLocation` seed stays a sweep across all providers, taking the
freshest result. It requires no registration and is what makes the first geohash
appear immediately rather than after a fix.
`MIN_TIME` / `MIN_DISTANCE` split into two profiles, passed per call:
| flow | precision | interval / distance | provider set |
|---|---|---|---|
| `geohashStateFlow` | `KM_5_X_5` | 10 s / 100 m → **60 s / 500 m** | 4 → 1 |
| `preciseGeohashStateFlow` | `BUILDING` (8 chars) | 10 s / 100 m (kept) | 4 → 1 |
Both rows change: the ladder narrows the precise flow's provider set too, and
below API 31 that means `network` only, no GPS. Academic while the app holds
coarse only (see Follow-ups), but it is not "unchanged".
At 120 km/h a 5 km cell takes 2.5 minutes to cross, so 60 s / 500 m has no
observable effect on the feed.
**Rejected alternative — one shared source at the fine profile,** deriving the
coarse geohash by prefix truncation. It halves registrations and removes the need
for `RefCountedSession` entirely, but it upgrades the **common** case — the
"Around Me" feed alone, which is always on via the Products default — from
60 s/500 m to 10 s/100 m. That trades the change's main win for a rarer one.
**Rejected alternative — one shared source whose profile tracks the finest
active subscriber.** Recovers the above and is the best of the three on both
axes, but it is refcounting with the counter moved from the meter into the
request path, for a benefit bounded by how often the two flows overlap. They
overlap only while one of three composable-scoped, foreground-only screens is
open (`GeohashChatScreen`, `NewGeohashChatScreen`,
`GeohashLocationPickerDialog`). Not worth the machinery; revisit if that changes.
### C. The meter
`AppModules.kt:251` hands both flows the same non-refcounted
`SessionTimeIntegrator`, so `setActive(false)` from either closes the segment
while the other is still listening. Both can be live at once — the "Around Me"
feed plus an open geohash chat.
**R5 — the hook moves inside `LocationFlow`, and both edges are paired.** Today
`onListening(true)` is an `onStart` on the flow returned by `LocationFlow.get`,
so it fires on *collection* whether or not anything was registered — meaning a
device with no usable provider accrues `location.ms` with nothing listening,
reintroducing the exact defect this section exists to fix. The hook must instead
fire from inside the `callbackFlow`, after `requestLocationUpdates` returns
without throwing, and again on the way out.
The obvious "way out" is `awaitClose`, and that would introduce a worse bug than
it fixes — twice over. First, `awaitClose` runs on every normal
completion, including one where no rung ever registered, so it would fire an
**unpaired** `onListening(false)`. With R6 that does not merely under-count — it
decrements a holder it never acquired, stealing another flow's. Concretely:
`geohashStateFlow` registers (`holders = 1`), `preciseGeohashStateFlow` fails to
register and closes (`holders = 0`), and the session latches off while the coarse
flow is still listening. `coerceAtLeast(0)` does not help; the count never went
negative.
Second — and this is the one that survives fixing the first — `awaitClose` also
fails to run at all on some paths that *did* register. See below.
The pair therefore has to be guaranteed from **both** ends, and the two ends need
different mechanisms.
*No acquire without a registration* is §B's **throw**: if no rung registers, the
builder throws before reaching the acquire at all.
*No acquire without a release* needs `try`/`finally`, **not** `awaitClose`. This
is the subtlest point in the document, so the justification below is the one that
was **demonstrated**, not the one that sounds most obvious.
Anything between the acquire and `awaitClose` that unwinds skips cleanup parked
inside `awaitClose`, because `awaitClose` is never reached to register it. The
registration then leaks and the refcount sticks at ≥ 1 for the life of the
process, so `location.ms` accrues forever with nothing listening — this exact
defect, arrived at from the other direction, and unrecoverable once hit.
The **proven** path is the seed throwing a non-cancellation exception:
`getLastKnownLocation` is a binder call and can fail. The regression test
`releasesTheRegistrationWhenTheSeedThrows` provokes exactly this and was watched
failing against an `awaitClose`-only implementation
(`expected:<[true, false]> but was:<[true]>`).
A cancellation during the seed is *in principle* a second such path, since `send`
is a suspending call. Recorded honestly: **this one could not be reproduced.**
Two attempts during implementation both produced tests that passed against a
deliberately broken implementation, because `callbackFlow`'s channel is buffered,
so `send` returns without suspending and never observes the cancel. Do not treat
the cancellation story as the reason for the `try`/`finally`; a future reader who
tries to reproduce it, fails, and concludes the guard is unnecessary would
reintroduce the leak.
```kotlin
var registered: String? = null
for (provider in candidates) {
try {
locationManager.requestLocationUpdates(provider, minTimeMs, minDistanceM, callback, Looper.getMainLooper())
registered = provider
break
} catch (e: SecurityException) { /* next rung */ }
}
if (registered == null) throw SecurityException("no usable location provider")
onListening?.invoke(true) // cannot fire without a registration
try {
freshestLastKnownLocation(providers)?.let { send(it) } // suspends — cancellable
awaitClose { } // only to satisfy callbackFlow's contract
} finally {
locationManager.removeUpdates(callback)
onListening?.invoke(false) // cannot be skipped
}
```
`onListening?.invoke(true)` sits immediately before the `try`, with no suspension
between them, so the acquire cannot happen outside the block that guarantees its
release.
`trySend` for the seed would also close this particular hole, being
non-suspending. It is rejected because it leaves the invariant resting on nobody
adding a suspending call to that block later — vigilance rather than
impossibility, which is the standard the rest of R5 is held to.
**R6 — refcounting.** An `AtomicInteger` beside the `setActive` call is not
sufficient: two threads can leave the counter at 1 while the last
`setActive(false)` lands after the `setActive(true)`, latching the session off.
The count and the transition must move under one lock. New class in
`service/resourceusage/`:
```kotlin
class RefCountedSession(private val setSessionActive: (Boolean) -> Unit) {
private val lock = Any()
private var holders = 0
fun setActive(active: Boolean) =
synchronized(lock) {
holders = if (active) holders + 1 else (holders - 1).coerceAtLeast(0)
setSessionActive(holders > 0)
}
}
```
It takes the setter as a lambda rather than a `SessionTimeIntegrator` because
that is all it needs, and because constructing a real integrator in a unit test
would drag in a `ResourceUsageAccountant`, a `ResourceUsageStore` and a temp
file to observe one boolean.
`AppModules` wires `RefCountedSession(locationSession::setActive)` and passes
`onListening = { locationRefCount.setActive(it) }`. The outer
lock serialises entry into `SessionTimeIntegrator.setActive`, whose own lock is
then nested but never acquired in the reverse order, so there is no deadlock.
`coerceAtLeast(0)` guards an unmatched release.
### What `location.ms` means after this change
Stated plainly, because the finding that opened this spec is "the meter lies"
and the next reader should not over-trust the fixed number the way the last one
over-trusted the broken one:
> `location.ms` measures **how long a location registration was held while the
> app was in the foreground**. It is not radio-on time and not an energy
> figure. A `network`-provider registration at 60 s costs close to nothing; a
> `gps` registration at 10 s costs a great deal. The counter cannot tell them
> apart.
Reading it as a battery signal requires knowing which provider was chosen —
which the ledger does not record. Recording the chosen provider as a separate
counter is a possible follow-up.
## Testing
JVM unit tests — JUnit + MockK + `kotlinx-coroutines-test`, no Robolectric,
alongside the existing `service/resourceusage/ResourceUsageLedgerTest.kt`.
**Gate.** `LocationState` gains `locationSource: (Long, Float) -> Flow<Location>`,
defaulting to `LocationFlow(context.getSystemService(…) as LocationManager)::get`
(see *Registration pairing* for why `LocationFlow` now takes the manager rather
than the `Context`). That is the seam:
`Location.toGeoHash` is `GeoHash.encode(lat, lon, chars)` from quartz — pure
Kotlin — and `unitTests.isReturnDefaultValues = true` is already set, so a
`mockk<Location>` with stubbed `latitude`/`longitude` suffices. Against a
counting fake source:
- backgrounded + permitted → source never subscribed
- foreground + permitted → subscribed exactly once
- foreground → background → subscription released after the R2 grace period,
last `Success` still readable via `.value`
- background edge shorter than the grace period → subscription **not** torn down
- return to foreground with a cached `Success`**no** `Loading` emission (R1)
- return to foreground with no cached fix → `Loading` first
- permission revoked → `LackPermission` regardless of foreground state
**Provider ladder.** Extracted as a pure function
`chooseProviders(sdkInt: Int, hasFine: Boolean, exists: (String) -> Boolean):
List<String>` so §B is covered rather than sitting below the seam. Cases: rungs
returned in order; missing rungs filtered out; API < 31 coarse-only yields
`[network]`; API < 31 with fine yields the full ladder; no compatible provider
yields an empty list.
`hasFine` is **always `false` in production** — the non-goals rule out ever
requesting `ACCESS_FINE_LOCATION`. It is a parameter rather than a constant so
that the function is total over the permission axis and the API < 31 branch can
be tested from both sides, not because fine access is anticipated. If that
changes, the ladder is already correct.
R5 sits below the `locationSource` seam, so a fake source never fires it. It gets
its own tests against a mocked `LocationManager` — see *Registration pairing*
below.
**Meter.** `RefCountedSession`: overlapping holders keep the session open;
balanced pairs close it; an unmatched release does not drive the count negative.
Note what this class **cannot** do: it cannot distinguish an unpaired release
from a legitimate one, so `acquire → unpaired release` closes the session even
while another holder is listening. That is precisely the R5 bug, and
`coerceAtLeast(0)` is no defence against it. **The pairing guarantee belongs to
`LocationFlow`, not here** — which is why it needs its own test below.
**Registration pairing (R5).** To make this testable rather than device-only,
`LocationFlow` takes a `LocationManager` instead of a `Context`
(`LocationFlow(context)``LocationFlow(locationManager)`; the caller in
`LocationState` does the `getSystemService` lookup). A `mockk<LocationManager>`
then covers:
- every rung throws `SecurityException` → the flow throws and `onListening` fires
**neither** edge
- `chooseProviders` returns an empty list → same: throws, neither edge
- an earlier rung throws and a later one succeeds → registration falls through,
exactly one `true`
- a successful registration → exactly one `true`, and exactly one `false` plus
`removeUpdates` on cancellation
- **cancellation mid-seed**, while the `getLastKnownLocation` sweep is in flight
→ both edges still fire and `removeUpdates` is still called. This is the case
the `try`/`finally` exists for; without it the test fails by hanging the
refcount at 1 rather than by throwing, so assert on the edges, not on the
absence of an exception.
These cover the *semantics* only — the two-thread interleaving that motivates the
lock is made unobservable by the lock itself and is not reproduced by any test
here.
## Acceptance criteria
On device, re-running the measurement above:
- **Backgrounded:** after the 5 s grace period, `adb shell dumpsys location`
shows no `com.vitorpamplona.amethyst` entry under any provider's `listeners:`,
and a `-registration` in the recent-events log.
- **Foregrounded:** **one registration per actively-collected flow — at most
two**, and one in the steady state where only the "Around Me" feed is live
(§C exists precisely because the two flows may overlap). Not four. The coarse
registration reads `@+60s0ms` / `minUpdateDistance=500.0` rather than
`@+10s0ms` / `100.0`.
- The historical aggregates are cumulative since boot; compare deltas across a
foreground/background cycle, not absolute totals.
- **Invariant:** a subsequent in-app Resource Usage Report shows
```
location.ms ≤ app.fgms + 5 s × (background transitions)
```
Both counters are driven by the same `foregroundTracker.isForeground` flow, so
without R2 this would hold exactly. R2 is deliberately the error term: the
registration really *is* live during the grace period, so counting it is the
honest reading, and a stated fudge factor beats an invariant quietly known to
be false. The term is not negligible — the source report shows 6 process
starts across two days, and app switches are far more frequent than that — so
writing `location.ms ≤ app.fgms` would guarantee that the first person to
check it files a bug against this change.
Second caveat: Finding 4 of the source analysis suspects `app.fgms` of
under-reporting, so a violation beyond the grace term indicts that counter
rather than this one.
- If H1 holds: location works on the API 26 AVD after the change and did not
before.
### Verified on device (2026-07-30, Pixel 9a / Android 17, API 37)
Measured against the `benchmark` variant — `initWith(release)`, so R8-minified with
the shipping proguard rules, installed as `com.vitorpamplona.amethyst.benchmark`
beside the untouched Play install. The Play install was force-stopped for the
duration so its own (unfixed) registrations could not be mistaken for these.
| criterion | before | after |
|---|---|---|
| registrations, foreground | **4** (passive, network, fused, gps) | **1** (fused) |
| request profile | `@+10s0ms HIGH_ACCURACY`, `minUpdateDistance=100.0` | `@+1m0s0ms BALANCED`, `minUpdateDistance=500.0` |
| registrations, backgrounded | **4**, held while `mWakefulness=Dozing` | **0** |
Event trace for one full cycle, process alive throughout (pid 28682):
```
17:57:00.117 +registration fused …/40F5A6D7 @+1m0s0ms BALANCED, minUpdateDistance=500.0
17:57:33.894 -registration fused …/40F5A6D7 ← HOME pressed, released after the grace
17:58:05.798 +registration fused …/091EDA96 @+1m0s0ms BALANCED, minUpdateDistance=500.0
(HOME then reopen within 2 s — no -/+ pair; 091EDA96 survives)
```
- **§A gate** — zero registrations while backgrounded, with the process still
alive. That is the state the change exists to create; before, four
registrations survived screen-off and doze.
- **§B request shape** — one provider, top of the ladder (`fused`), at exactly
`COARSE_MIN_TIME` / `COARSE_MIN_DISTANCE`. The OS tags it `(COARSE)` and
coalesces the effective service request to `@+10m0s0ms LOW_POWER`.
- **R2 grace period** — a sub-grace app switch produced **no** teardown/rebuild
pair, so a brief switch no longer costs a re-registration and a fresh
`getLastKnownLocation` sweep.
**The OS aggregate after four foreground/background cycles is the headline
result**, because it is the same counter shape `location.ms` measures:
```
com.vitorpamplona.amethyst.benchmark:
min/max interval = 60s/60s
total/active/foreground duration = +2m33s542ms / +2m33s456ms / +2m33s531ms
locations = 4
```
Total ≈ active ≈ foreground, all three within 90 ms — against the Play install's
`9d14h20m / 8h26m / 8h14m`, where registration was held for 45 % of uptime while
only 1.7 % was active. The four foreground windows sum to 153.6 s, matching the
aggregate exactly, so nothing is held outside them. Registration-held time now
*equals* foreground time, which is precisely what makes `location.ms` honest: the
counter measures registration lifetime, and that quantity is no longer divorced
from reality.
**A fix arrives within milliseconds of every re-registration**, which bounds the
staleness the coarser profile was feared to introduce:
```
17:57:00.117 +registration → 17:57:00.127 delivered location[1] (10 ms)
17:58:05.798 +registration → 17:58:05.802 delivered location[1] ( 4 ms)
17:59:09.965 +registration → 17:59:09.967 delivered location[1] ( 2 ms)
18:00:09.206 +registration → 18:00:09.213 delivered location[1] ( 7 ms)
```
The `fused` provider hands over its cached fix on registration, so the window in
which a returning user could act on a stale geohash is milliseconds, not the 60 s
poll interval. Caveat: that cache is warm on this device because Maps and GMS
keep it fresh; on a device with no other location consumer it could be colder,
which is what `freshestLastKnownLocation` exists to cover.
**Side-by-side A/B, same device, same instant, both clients backgrounded and
running.** The unmodified release client (1.13.1, installed via Obtainium, pid
5552) and the benchmark build of this branch (pid 28682) were sampled together:
```
com.vitorpamplona.amethyst/B7B299BE {bg, na} (COARSE) Request[PASSIVE, minUpdateDistance=100.0] (inactive)
com.vitorpamplona.amethyst/B7B299BE {bg, na} (COARSE) Request[@+10m LOW_POWER, minUpdateDistance=100.0] (inactive)
com.vitorpamplona.amethyst/B7B299BE {bg, na} (COARSE) Request[@+10m LOW_POWER, minUpdateDistance=100.0] (inactive)
com.vitorpamplona.amethyst/B7B299BE {bg, na} (COARSE) Request[@+10m LOW_POWER, minUpdateDistance=100.0] (inactive)
← com.vitorpamplona.amethyst.benchmark: no rows at all
```
Four held registrations versus zero. Note the release client's rows are all
`{bg, na} … (inactive)`: the OS has throttled the effective interval to 10
minutes and suspended delivery, exactly as §"What the device reports" describes —
but the **registration is still held**, and registration-held time is precisely
what `location.ms` counts. That is the inflation, visible in one frame.
Naming note for anyone re-reading the numbers above: both artifacts are `play`
**flavor** builds and differ only by buildType, so "the Play install" is an
ambiguous label. The unmodified client here is the *release* build, and on this
device it came from Obtainium rather than Google Play.
### Ledger invariant confirmed (2026-07-31, benchmark client, in-app report)
The acceptance criterion `location.ms ≤ app.fgms + 5 s × transitions` now checks
out against accumulated data:
| | `location.ms` | `app.fgms` | ratio |
|---|---|---|---|
| release client 1.13.0, day 20663 (before) | 25,660,172 | 9,147 | **2,805×** |
| benchmark, day 20664 (permission granted mid-day) | 3m7.3s | 11m31.0s | 0.27× |
| benchmark, **day 20665** (granted all day) | **2m10.8s** | **1m56.9s** | **1.12×** |
Day 20665 is the clean case: `location.ms` exceeds `app.fgms` by 13.9 s, which
requires ≥ 3 background transitions to fall inside the grace allowance — met by
the report navigation plus an `am start`. Day 20664 independently reconciles with
the `dumpsys` measurement: 2m33.5s of OS registration-held + 4 × 5 s grace =
~2m53.5s predicted, 3m7.3s actual, the residual being foreground use after the
measurement ended. **The ledger and the OS now agree**, where before they were
irreconcilable (10.7 h ledger vs 8h26m OS-active over three weeks).
**Limitation:** this is not a within-package before/after. `location.ms` is
absent from days 2064820663 because the benchmark client had location permission
*denied* until 2026-07-30; the "before" is no data, not inflated data.
### The same data closes the battery question
Over days 2065920665 on this device: **5m18s** of location listening against
**596 pp** of background battery drain (~85 pp/day). Location cannot be a
meaningful contributor at that ratio — Finding 1 is settled, and not in the
direction the original analysis assumed.
Three consumers visible in the same report, none of them location:
- `service.alwayson.ms` ≈ **23.9 h/day** (148.9 h over 7 days) — an always-on
foreground service running essentially continuously. Largest structural
difference from a stock client; worth confirming it is deliberately enabled.
- **Finding 2, unchanged.** 3,732 relay-hours over 7 days. Day 20664 alone:
9,831 successful dials against 25,133 failures = **71.9 %**, matching the
original report's 75 %.
- **Finding 4, now on cellular.** Day 20664 `net.other.mobile.bg.activems =
52,392,613` — **14.6 h** of background mobile active time with **0 requests and
0 bytes**. The three-moment `isForeground()` sampling, exactly as diagnosed, so
the fg/bg split in this report still cannot be trusted.
Caveat: the benchmark client's round-the-clock always-on service makes its
battery figures non-comparable to a stock install. The relay and `net.other`
figures do match the release client's original report closely.
### Smoke test on a clean install (2026-07-31, benchmark client)
Uninstalled and reinstalled from branch HEAD so the account, ledger and
permission grant all started empty — which is what makes the first-grant and
empty-cache paths reachable. UID changed 10805 → 10806, cleanly separating the
new data.
- **R1 — no `Loading` flash on return to foreground: PASS.** Observed directly:
granted the permission, set a feed to Around Me, backgrounded for 10 s,
reopened — the geohash was present immediately with no blank feed. This was the
last open acceptance criterion on the branch and the only one no test could
cover. `dumpsys` shows why it works: the fix is delivered 16 ms after each
re-registration.
- **Precise profile live and distinct: PASS.** Geohash screens produce
`@+10s0ms BALANCED, minUpdateDistance=100.0`, and the aggregate's `min/max
interval` moved from `60s/60s` to `10s/60s`. Both profiles are real in
production, not just in the unit tests.
- **Refcount under concurrent flows: PASS.** The coarse registration `766C58A4`
came up at 15:49:10 and survived **four complete precise-flow cycles**
untouched (15:49:4146, 15:50:4752, 15:52:0715:53:05, 15:53:1722), with
both live simultaneously during the first. Opening and closing geohash chats
never closes the "Around Me" registration — `RefCountedSession` doing on a real
device what `LocationLedgerCompositionTest` asserts.
- **No hang in the location picker.** Every precise registration received a fix
within ~1 ms; none stuck open. That path does
`preciseGeohashStateFlow.first { it is Success }`, which would hang rather than
crash if the gate failed to yield.
- **Aggregate:** total 6m7.553s / active 6m7.401s (152 ms apart) / foreground
5m49.441s. Foreground trails total by 18.1 s across ~7 background transitions,
≈ 2.6 s each — the grace period, visible at a scale where it is easy to check.
**Measurement note:** counting listeners with `grep -c` on the package name is
unreliable — the `service: ProviderRequest[…]` summary line also names the
package when it is the only requester, inflating the count by one. List the rows
and exclude that line instead. A count of zero is unambiguous either way.
Still not verified: nothing on the gate itself. The `location.ms ≤ app.fgms +
5 s × transitions` invariant was separately confirmed from accumulated ledger
data (see above); the clean install reset that counter, so it will need another
day of use to re-check at scale.
## Follow-ups (not in this change)
- **`preciseGeohashStateFlow` is not actually building-level.** With only
`ACCESS_COARSE_LOCATION`, Android fuzzes every fix to roughly a 3 km grid, so
the 8-char geohash and the location chat channels built on it are far coarser
than they claim (`LocationState.kt:104-141`, `GeohashChatScreen.kt:165`,
`NewGeohashChatScreen.kt:309`). The profile is kept intact here so the intent
survives if the app ever requests `ACCESS_FINE_LOCATION`; whether to request
it, or to stop advertising building-level precision, is a separate decision.
- **`GeohashChatScreen.kt:161-163` is a one-way permission latch** — it calls
`setLocationPermission(true)` inside an `if (isGranted)` rather than passing
the boolean, as every other caller does (`LoggedInPage.kt:144`,
`LocationAsHash.kt:64`, `NewGeohashChatScreen.kt:285`,
`GeohashLocationPickerDialog.kt:270`). Once set, a revoked permission is never
reflected back into the shared `LocationState`. Small, in the blast radius,
and cheap.
- **An `Unavailable` `LocationResult`**, distinct from `LackPermission`, so a
device with no usable provider stops being told "No Location Permissions" when
it has them. Ten references across five files
(`DisplayLocationObserver.kt`, `FeedFilterSpinner.kt`, `HomeScreen.kt`,
`NewGeohashChatScreen.kt`, `LocationState.kt`). Belongs with the H1 fix
messaging — see the stated decision in §B.
- Recording the chosen provider as a ledger counter, so `location.ms` can be
read as a cost signal.
- Whether Products should still default to `TopFilter.AroundMe`.
- Finding 2 — the relay reconnect storm.
-1
View File
@@ -10,7 +10,6 @@ _Audited 2026-06-30. 21 plans: 19 shipped (archived), 1 in-progress, 1 queued, 0
## Queued
| Plan | Summary |
| ---- | ------- |
| [2026-07-23-push-notification-redesign.md](2026-07-23-push-notification-redesign.md) | Per-kind tray notification redesign — accent colors, status-bar icons, MessagingStyle/BigPictureStyle/colorized zap cards, aggregation, Conversations/Bubbles; closes nutzap/onchain/repost/badge parity gaps. |
| [2026-06-20-napplet-inter-applet.md](2026-06-20-napplet-inter-applet.md) | NAP-INC / NAP-INTENT inter-applet messaging — deferred; prerequisites (multi-applet hosting, archetype registry, `MESSAGING` capability) not yet built. |
## Archived (shipped)
@@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst
import android.graphics.Bitmap
import android.graphics.Color
import androidx.core.graphics.createBitmap
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.vitorpamplona.amethyst.model.AccountSettings
@@ -82,7 +81,7 @@ class ImageUploadTesting {
.build()
private fun getBitmap(): ByteArray {
val bitmap = createBitmap(200, 300)
val bitmap = Bitmap.createBitmap(200, 300, Bitmap.Config.ARGB_8888)
for (x in 0 until bitmap.width) {
for (y in 0 until bitmap.height) {
bitmap.setPixel(x, y, Color.rgb(Random.nextInt(), Random.nextInt(), Random.nextInt()))
@@ -21,7 +21,6 @@
package com.vitorpamplona.amethyst.service.images
import android.graphics.Bitmap
import androidx.core.graphics.createBitmap
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.After
@@ -45,7 +44,7 @@ class ThumbnailDiskCacheInstrumentedTest {
cacheDir = File(appContext.cacheDir, "thumbnail-test-${UUID.randomUUID()}")
cache = ThumbnailDiskCache(cacheDir)
sourceFile = File(appContext.cacheDir, "source-${UUID.randomUUID()}.jpg")
val bitmap = createBitmap(64, 64)
val bitmap = Bitmap.createBitmap(64, 64, Bitmap.Config.ARGB_8888)
sourceFile.outputStream().use { bitmap.compress(Bitmap.CompressFormat.JPEG, 90, it) }
bitmap.recycle()
}
@@ -1,250 +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.playback.composable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.material3.ButtonDefaults
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.test.assertHeightIsAtLeast
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.getUnclippedBoundsInRoot
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.stringRes
import io.mockk.mockk
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
/**
* The "open in browser" fallback is the only thing this overlay offers the user, so it has to
* survive whatever box the media layout hands it.
*
* [Column] measures children in declaration order against the remaining height, so the button —
* being last — is what starved when the content was taller than the box. A note in the feed is
* inset under the 55dp author column (screenWidth - 89dp), and with no imeta `dim` the media box
* is 16:9, which on a 411dp phone is 322dp wide and only 181dp tall. That was enough to squeeze
* the button down to 0.38dp — present in the tree, invisible and untappable on screen — while the
* same note opened in the thread (full bleed, screenWidth - 26dp, so a 217dp box) rendered it at
* 35.8dp.
*/
@RunWith(AndroidJUnit4::class)
class PlaybackErrorOverlayFitTest {
@get:Rule val rule = createComposeRule()
private val targetContext = InstrumentationRegistry.getInstrumentation().targetContext
private fun renderInBox(
width: Dp,
height: Dp,
fontScale: Float = 1f,
) {
rule.setContent {
val density = LocalDensity.current.density
CompositionLocalProvider(LocalDensity provides Density(density, fontScale)) {
Box(Modifier.width(width).height(height)) {
RenderPlaybackError(
controllerState =
MediaControllerState(
controller = mockk<Player>(relaxed = true),
playbackError =
mutableStateOf(
PlaybackException(
"Malformed HLS manifest",
null,
PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED,
),
),
),
videoUri = "https://streamstr.net/x/hls/live.m3u8",
)
}
}
}
}
private fun assertButtonUsable() {
rule
.onNodeWithText(stringRes(targetContext, R.string.error_video_open_in_browser))
.assertIsDisplayed()
.assertHeightIsAtLeast(ButtonDefaults.MinHeight)
}
@Test
fun buttonAndTitleSurviveTheFeedsSixteenByNineBox() {
renderInBox(width = 322.dp, height = 181.dp)
assertButtonUsable()
rule
.onNodeWithText(stringRes(targetContext, R.string.error_video_playback_failed))
.assertIsDisplayed()
}
@Test
fun buttonSurvivesTheThreadsSixteenByNineBox() {
renderInBox(width = 385.dp, height = 217.dp)
assertButtonUsable()
}
@Test
fun shortBoxKeepsTheButtonAndDropsTheDescription() {
// A 3:1 banner-ish stream, or a narrow quote card: far less height than 16:9 gives. A
// weighted Text handed less than one line's height draws it clipped through the middle,
// which looks broken — under that much pressure it should not be emitted at all.
renderInBox(width = 322.dp, height = 110.dp)
assertButtonUsable()
rule
.onNodeWithText(
stringRes(
targetContext,
R.string.error_video_playback_failed_description,
"ERROR_CODE_PARSING_MANIFEST_MALFORMED",
),
).assertDoesNotExist()
}
@Test
fun buttonSurvivesLargeFontScale() {
// At fontScale 2 the text wants far more height than the dp thresholds were tuned for.
// The button must still get its intrinsic height, because it is measured before the
// weighted text block — the guarantee is structural, not numeric.
renderInBox(width = 322.dp, height = 195.dp, fontScale = 2f)
assertButtonUsable()
}
@Test
fun shrinkingTheBoxNeverShrinksTheButton() {
// ButtonDefaults.MinHeight alone does not pin the guarantee: a button squeezed from its
// intrinsic 53dp down to 40dp at fontScale 2 still clears that floor. What actually has to
// hold is that the box height cannot influence the button's height at all, because the
// button is measured before the weighted text block that absorbs the shortfall. Measure
// the same button roomy and then at its tightest, and require the two to agree.
val boxHeight = mutableStateOf(400.dp)
rule.setContent {
val density = LocalDensity.current.density
CompositionLocalProvider(LocalDensity provides Density(density, 2f)) {
Box(Modifier.width(322.dp).height(boxHeight.value)) {
RenderPlaybackError(
controllerState =
MediaControllerState(
controller = mockk<Player>(relaxed = true),
playbackError =
mutableStateOf(
PlaybackException(
"Malformed HLS manifest",
null,
PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED,
),
),
),
videoUri = "https://streamstr.net/x/hls/live.m3u8",
)
}
}
}
val roomy = buttonHeight()
// 190dp is the worst case for the old threshold-only layout: just enough to keep the icon,
// not enough to pay for it, so the shortfall landed on the button.
rule.runOnUiThread { boxHeight.value = 190.dp }
rule.waitForIdle()
val tight = buttonHeight()
assertTrue(
"button shrank from $roomy to $tight when the box did",
tight >= roomy - 1.dp,
)
}
@Test
fun theIconNeverCostsTheTitleItsHeight() {
// The icon is non-weighted and declared first, so inside the weighted text block it is
// measured before the title. With a fixed 190dp gate, a box of 190dp to 199dp at fontScale 2
// was just tall enough to keep the icon and not tall enough to pay for it, so the title
// rendered sliced. Decoration must yield before words do.
val boxHeight = mutableStateOf(400.dp)
rule.setContent {
val density = LocalDensity.current.density
CompositionLocalProvider(LocalDensity provides Density(density, 2f)) {
Box(Modifier.width(322.dp).height(boxHeight.value)) {
RenderPlaybackError(
controllerState =
MediaControllerState(
controller = mockk<Player>(relaxed = true),
playbackError =
mutableStateOf(
PlaybackException(
"Malformed HLS manifest",
null,
PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED,
),
),
),
videoUri = "https://streamstr.net/x/hls/live.m3u8",
)
}
}
}
val roomy = titleHeight()
rule.runOnUiThread { boxHeight.value = 195.dp }
rule.waitForIdle()
val tight = titleHeight()
assertTrue(
"title sliced from $roomy to $tight to make room for the decorative icon",
tight >= roomy - 1.dp,
)
}
private fun titleHeight(): Dp {
val bounds =
rule
.onNodeWithText(stringRes(targetContext, R.string.error_video_playback_failed))
.getUnclippedBoundsInRoot()
return bounds.bottom - bounds.top
}
private fun buttonHeight(): Dp {
val bounds =
rule
.onNodeWithText(stringRes(targetContext, R.string.error_video_open_in_browser))
.getUnclippedBoundsInRoot()
return bounds.bottom - bounds.top
}
}
@@ -1,113 +0,0 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.unit.dp
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.service.playback.composable.audioSquare
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
/**
* The inline audio player sizes itself as a square so the visualizer and controls get room, which is
* taller than the 16:9 box [ZoomableContentView] builds for a video of unknown dimensions. That box
* is centre-aligned and nothing between it and NoteComposeLayout clips, so caging the square in it
* makes the player paint over the note header above and the reactions row below.
*
* These tests recreate that enclosure — the real [mediaSizingModifier] over the real
* [unknownMediaAspectRatio], holding the real [audioSquare] — and pin both halves of the contract:
* audio must not be given a ratio, and video must keep the 16:9 assumption that stops live streams
* from letterboxing on first play.
*/
@RunWith(AndroidJUnit4::class)
class AudioPlayerBoxOverflowTest {
@get:Rule val rule = createComposeRule()
private class Bounds {
var top = 0f
var bottom = 0f
var width = 0
var height = 0
fun modifier() =
Modifier.onGloballyPositioned {
top = it.positionInRoot().y
width = it.size.width
height = it.size.height
bottom = top + height
}
}
private fun measure(
url: String,
square: Boolean,
): Pair<Bounds, Bounds> {
val box = Bounds()
val player = Bounds()
rule.setContent {
Box(Modifier.size(width = 400.dp, height = 1200.dp)) {
Box(
modifier = mediaSizingModifier(unknownMediaAspectRatio(null, url), ContentScale.Fit).then(box.modifier()),
contentAlignment = Alignment.Center,
) {
Box((if (square) Modifier.audioSquare() else Modifier).then(player.modifier()))
}
}
}
rule.waitForIdle()
return box to player
}
@Test
fun squareAudioPlayerStaysInsideItsMediaBox() {
val (box, player) = measure("https://haven.sdbitcoiners.com/f28a5a2e.mp3", square = true)
assertTrue(
"player overflows above the media box by ${box.top - player.top}px",
player.top >= box.top,
)
assertTrue(
"player overflows below the media box by ${player.bottom - box.bottom}px",
player.bottom <= box.bottom,
)
assertEquals("the box should wrap the square", player.height, box.height)
}
@Test
fun videoOfUnknownSizeKeeps16by9() {
val (box, _) = measure("https://example.com/a.mp4", square = false)
assertEquals(16f / 9f, box.width.toFloat() / box.height, 0.01f)
}
}
@@ -97,7 +97,7 @@ class EventSyncTest {
RelayAuthenticator(
newClient,
appScope,
signWithAllLoggedInUsers = { _, authTemplate, _ ->
signWithAllLoggedInUsers = { authTemplate ->
listOf(signer.sign(authTemplate))
},
)
@@ -97,8 +97,8 @@ class PushMessageReceiver : MessagingReceiver() {
Log.d(TAG) { "Building okHttpClient, useTor: ${Amethyst.instance.torManager.isSocksReady()}" }
Amethyst.instance.okHttpClients.getHttpClient(Amethyst.instance.torManager.isSocksReady())
}
NotificationCategory.ZAP.ensureChannel(appContext)
NotificationCategory.DIRECT_MESSAGE.ensureChannel(appContext)
NotificationUtils.getOrCreateZapChannel(appContext)
NotificationUtils.getOrCreateDMChannel(appContext)
}
// } else {
Log.d(TAG) { "Same endpoint provided:- ${endpoint.url} for Instance: $instance $sanitizedEndpoint" }
@@ -66,10 +66,3 @@ fun TranslatableRichTextViewer(
accountViewModel: AccountViewModel,
displayText: @Composable (String) -> Unit,
) = displayText(content)
/** No translation service in this flavor, so the content is always its own "translation". */
@Composable
fun rememberTranslation(
content: String,
accountViewModel: AccountViewModel,
): String = content
+2 -148
View File
@@ -54,7 +54,6 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CAMERA" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_PHONE_CALL" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<!-- Phone calls -->
@@ -152,14 +151,6 @@
<data android:scheme="amethyst+walletconnect" />
</intent-filter>
<!-- NIP-46: an app's `nostrconnect://` offer opens the signer screen, which pairs it. -->
<intent-filter android:label="Amethyst">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="nostrconnect" />
</intent-filter>
<intent-filter android:label="New Post">
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
@@ -202,27 +193,6 @@
<data android:host="iris.to" />
</intent-filter>
<!-- Concord community invite links: https://amethyst.social/invite/<naddr>#<fragment> -->
<intent-filter android:label="Amethyst">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
<data android:host="amethyst.social" />
<data android:pathPrefix="/invite/" />
</intent-filter>
<!-- Buzz workspace invite links: https://<team>.communities.buzz.xyz/invite/<token> -->
<intent-filter android:label="Amethyst">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
<data android:host="communities.buzz.xyz" />
<data android:host="*.communities.buzz.xyz" />
<data android:pathPrefix="/invite/" />
</intent-filter>
<intent-filter android:label="zap.stream">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
@@ -295,93 +265,6 @@
</intent-filter>
</activity-alias>
<!-- "New Picture" share target: a gallery (or camera) shares an image and it goes straight
into the picture feed as a NIP-68 kind-20 post instead of a text note with a link. The
android:name simple class ("ShareAsPictureAlias") is matched at runtime by
ShareIntentRouting.SHARE_AS_PICTURE_ALIAS_SIMPLE_NAME; keep the two in sync. -->
<activity-alias
android:name=".ui.ShareAsPictureAlias"
android:exported="true"
android:label="@string/share_target_as_picture"
android:targetActivity=".ui.MainActivity">
<intent-filter android:label="@string/share_target_as_picture">
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
</intent-filter>
<intent-filter android:label="@string/share_target_as_picture">
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
</intent-filter>
</activity-alias>
<!-- "New Short" share target: a video shared here always becomes a NIP-71 kind-22 short so
it lands in the Shorts feed, whatever its orientation. Video-only — a short is a video.
The android:name simple class ("ShareAsShortVideoAlias") is matched at runtime by
ShareIntentRouting.SHARE_AS_SHORT_VIDEO_ALIAS_SIMPLE_NAME; keep the two in sync. -->
<activity-alias
android:name=".ui.ShareAsShortVideoAlias"
android:exported="true"
android:label="@string/share_target_as_short_video"
android:targetActivity=".ui.MainActivity">
<intent-filter android:label="@string/share_target_as_short_video">
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="video/*" />
</intent-filter>
<intent-filter android:label="@string/share_target_as_short_video">
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="video/*" />
</intent-filter>
</activity-alias>
<!-- "New Video" share target: opens the Video feed's media composer, which publishes a
NIP-71 video event (kind 21 or 22 by orientation) instead of a text note. The
android:name simple class ("ShareAsVideoAlias") is matched at runtime by
ShareIntentRouting.SHARE_AS_VIDEO_ALIAS_SIMPLE_NAME; keep the two in sync. -->
<activity-alias
android:name=".ui.ShareAsVideoAlias"
android:exported="true"
android:label="@string/share_target_as_video"
android:targetActivity=".ui.MainActivity">
<intent-filter android:label="@string/share_target_as_video">
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="video/*" />
</intent-filter>
<intent-filter android:label="@string/share_target_as_video">
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="video/*" />
</intent-filter>
</activity-alias>
<!-- "New Highlight" share target: a browser (or reader) shares a selected passage of
text and it opens the NIP-84 highlight composer. Text-only — a highlight is a text
passage, so no image/video filters here. The android:name simple class
("ShareAsHighlightAlias") is matched at runtime by
ShareIntentRouting.SHARE_AS_HIGHLIGHT_ALIAS_SIMPLE_NAME; keep the two in sync. -->
<activity-alias
android:name=".ui.ShareAsHighlightAlias"
android:exported="true"
android:label="@string/share_target_as_highlight"
android:targetActivity=".ui.MainActivity">
<intent-filter android:label="@string/share_target_as_highlight">
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
</activity-alias>
<!-- Health Connect privacy-policy rationale on Android 14+. Without this activity-alias
the permission request fails silently (no dialog appears). The system launches it,
guarded by START_VIEW_PERMISSION_USAGE, to show our privacy policy; it routes into
@@ -463,24 +346,6 @@
android:stopWithTask="true"
android:exported="false" />
<!-- Keeps the NIP-13 mining queue schedulable after the user leaves the
app: shortService gives a ~3 min guaranteed window with no special
permission. Jobs are persisted, so a timeout only defers them. -->
<service
android:name=".service.pow.PowMiningForegroundService"
android:foregroundServiceType="shortService"
android:stopWithTask="false"
android:exported="false" />
<!-- Keeps the "sync all blobs to all servers" (BUD-04) sweep alive while backgrounded.
dataSync is the correct type for an upload/download/sync; it must be started from the
foreground, which "Sync all" (user-initiated in the manager) satisfies. -->
<service
android:name=".service.uploads.blossom.BlossomSyncForegroundService"
android:foregroundServiceType="dataSync"
android:stopWithTask="false"
android:exported="false" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
@@ -500,17 +365,6 @@
android:value="Persistent real-time messaging relay connection for Nostr protocol. Maintains WebSocket connections to user-configured inbox relays for immediate notification delivery of direct messages, zaps, and mentions." />
</service>
<service
android:name=".service.notifications.NotificationServiceTileService"
android:icon="@drawable/amethyst_service"
android:label="@string/always_on_notif_tile_label"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"
android:exported="true">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter>
</service>
<receiver
android:name=".service.notifications.BootCompletedReceiver"
android:exported="false">
@@ -579,14 +433,14 @@
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
<!-- First-connect "Connect to Nostr" dialog. -->
<activity
android:name=".connectedApps.consent.SignerConnectActivity"
android:name=".napplet.NappletConnectActivity"
android:exported="false"
android:excludeFromRecents="true"
android:launchMode="singleTop"
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
<!-- Per-operation signer consent dialog. -->
<activity
android:name=".connectedApps.consent.SignerConsentActivity"
android:name=".napplet.NappletSignerConsentActivity"
android:exported="false"
android:excludeFromRecents="true"
android:launchMode="singleTop"
@@ -32,9 +32,6 @@ import com.vitorpamplona.amethyst.service.nests.AppForegroundRecycleHook
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabHost
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.LogLevel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.io.File
/**
@@ -56,36 +53,11 @@ import java.io.File
*/
class Amethyst : Application() {
init {
Log.minLevel = DEFAULT_LOG_LEVEL
Log.minLevel = if (BuildConfig.DEBUG) LogLevel.DEBUG else LogLevel.ERROR
Log.d("AmethystApp") { "Creating App $this" }
}
companion object {
/**
* Restores the full firehose in debug builds: per-socket relay lifecycle
* (`Connecting…`/`Disconnected`/`OnOpen`), per-second event throughput, per-stream Arti
* SOCKS errors and per-relay census detail.
*
* Off by default. A cold start on the outbox model dials ~400 relays, and those sources
* alone emit ~3.7k of the ~6.2k lines an 80s boot produces — which buries the boot
* narrative and the relay-protocol warnings (`auth-required`, `rate-limited`,
* `unsupported: too many filters`) that are the actionable ones. Flip this, or set
* [Log.minLevel] at runtime, when you need the per-socket detail back.
*/
const val VERBOSE_LOGS = false
/**
* Debug defaults to INFO so a boot reads as a narrative plus warnings; release defaults to
* WARN rather than ERROR so relay-protocol refusals stay visible in the field — they are
* the highest-value-per-line diagnostics we emit and were previously dropped entirely.
*/
val DEFAULT_LOG_LEVEL: LogLevel =
when {
!BuildConfig.DEBUG -> LogLevel.WARN
VERBOSE_LOGS -> LogLevel.DEBUG
else -> LogLevel.INFO
}
lateinit var instance: AppModules
private set
}
@@ -108,15 +80,10 @@ class Amethyst : Application() {
// is self-contained (WebView + IPC), so we skip all app init there and
// leave `instance` unset; any accidental use fails fast.
if (isNappletSandbox) {
// Milestone, not chatter: this is the one line that explains why `instance` is unset
// and `LocalCache` is empty in this process. Without it a napplet-process log looks
// like a broken app rather than a deliberately secret-free sandbox.
Log.i("AmethystApp") { "Napplet sandbox process starting — no account, no AppModules" }
Log.d("AmethystApp") { "Skipping AppModules init in sandbox process" }
return
}
Log.i("AmethystApp") { "Amethyst ${BuildConfig.VERSION_NAME} starting in main process (log level ${Log.minLevel})" }
instance = AppModules(this)
// Hydrate the device-local favorite-apps list (main process only; the sandbox never reads it).
@@ -128,10 +95,6 @@ class Amethyst : Application() {
// Index device-local captured favicons (main process only; decorates favorites + suggestions).
BrowserIconRegistry.init(this)
// 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.
CoroutineScope(Dispatchers.IO).launch { LocalPreferences.warmGlobalSettings() }
// Hydrate the per-web-client Tor routing preferences so a site opted out of Tor (some reject Tor
// exits) starts on the open web without first flashing a failed Tor load.
WebAppNetworkRegistry.init(this)
@@ -147,10 +110,6 @@ class Amethyst : Application() {
// kdoc for the threshold rationale.
registerActivityLifecycleCallbacks(AppForegroundRecycleHook())
// Foreground signal for the resource-usage ledger (fg/bg attribution
// of bytes and connection-time). Main process only.
registerActivityLifecycleCallbacks(instance.foregroundTracker)
if (isDebug) {
Logging.setup()
// Auto-enable the Nests session-trace recorder in debug
@@ -196,11 +155,13 @@ class Amethyst : Application() {
if (isNappletSandbox) return
instance.trim(level)
// Drop warm embedded tab sessions under genuine memory pressure (decision: keep warm until the
// user or Android reclaims them). Since API 34 the OS only delivers UI_HIDDEN and BACKGROUND:
// BACKGROUND means the process is on the system LRU list (real reclaim pressure), while UI_HIDDEN
// fires on every app switch — so evict only at BACKGROUND and above, letting a pinned tab survive
// a plain backgrounding. R+ only.
val pressure = level >= ComponentCallbacks2.TRIM_MEMORY_BACKGROUND
// user or Android reclaims them). Deliberately NOT on UI_HIDDEN/BACKGROUND — those fire on every
// backgrounding, and a pinned tab should survive that. Only on real pressure levels, R+ only.
val pressure =
level == ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW ||
level == ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL ||
level == ComponentCallbacks2.TRIM_MEMORY_MODERATE ||
level == ComponentCallbacks2.TRIM_MEMORY_COMPLETE
if (pressure && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
EmbeddedTabHost.evictAll()
}
@@ -22,22 +22,13 @@ package com.vitorpamplona.amethyst
import android.content.ComponentCallbacks2
import android.content.Context
import android.os.BatteryManager
import androidx.security.crypto.EncryptedSharedPreferences
import coil3.disk.DiskCache
import coil3.memory.MemoryCache
import com.vitorpamplona.amethyst.commons.model.NoteState
import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger
import com.vitorpamplona.amethyst.commons.relayClient.BlockedRelayFilteringClient
import com.vitorpamplona.amethyst.commons.richtext.CachedRichTextParser
import com.vitorpamplona.amethyst.commons.robohash.CachedRobohash
import com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPostStore
import com.vitorpamplona.amethyst.commons.service.lnurl.OkHttpLnurlEndpointResolver
import com.vitorpamplona.amethyst.commons.service.pow.PoWPolicy
import com.vitorpamplona.amethyst.commons.service.pow.PoWPublishQueue
import com.vitorpamplona.amethyst.commons.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.LocalCache
import com.vitorpamplona.amethyst.model.UiSettings
@@ -46,20 +37,16 @@ import com.vitorpamplona.amethyst.model.nip03Timestamp.BitcoinExplorerEndpoint
import com.vitorpamplona.amethyst.model.nip03Timestamp.IncomingOtsEventVerifier
import com.vitorpamplona.amethyst.model.nip03Timestamp.TorAwareOkHttpOtsResolverBuilder
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.NamecoinSharedPreferences
import com.vitorpamplona.amethyst.model.preferences.OtsSharedPreferences
import com.vitorpamplona.amethyst.model.preferences.RelayGroupDeletionPreferences
import com.vitorpamplona.amethyst.model.preferences.TorSharedPreferences
import com.vitorpamplona.amethyst.model.preferences.UiSharedPreferences
import com.vitorpamplona.amethyst.model.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.CalendarReminderWorker
import com.vitorpamplona.amethyst.napplet.DataStoreNostrSignerPermissionStore
import com.vitorpamplona.amethyst.service.CachedRichTextParser
import com.vitorpamplona.amethyst.service.cast.CastRegistry
import com.vitorpamplona.amethyst.service.connectivity.ConnectivityManager
import com.vitorpamplona.amethyst.service.connectivity.ConnectivityStatus
@@ -72,53 +59,31 @@ import com.vitorpamplona.amethyst.service.images.ThumbnailDiskCache
import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.service.notifications.AlwaysOnNotificationServiceManager
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.BlossomReadAuthInterceptor
import com.vitorpamplona.amethyst.service.okhttp.BlossomReadAuthTokenProvider
import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManager
import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManagerForRelays
import com.vitorpamplona.amethyst.service.okhttp.EncryptionKeyCache
import com.vitorpamplona.amethyst.service.okhttp.OkHttpWebSocket
import com.vitorpamplona.amethyst.service.okhttp.OnionLocationCache
import com.vitorpamplona.amethyst.service.okhttp.SurgeDns
import com.vitorpamplona.amethyst.service.okhttp.SurgeDnsStore
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
import com.vitorpamplona.amethyst.service.relayClient.TorCircuitHealthTracker
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.AuthCoordinator
import com.vitorpamplona.amethyst.service.relayClient.diagnostics.BootRelayDiagnostics
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.DataStoreRelayAuthPermissionStore
import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model.NotifyCoordinator
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.RelaySubscriptionsCoordinator
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState
import com.vitorpamplona.amethyst.service.relayClient.speedLogger.RelaySpeedLogger
import com.vitorpamplona.amethyst.service.resourceusage.BatteryDrainSampler
import com.vitorpamplona.amethyst.service.resourceusage.ForegroundTimeIntegrator
import com.vitorpamplona.amethyst.service.resourceusage.ForegroundTracker
import com.vitorpamplona.amethyst.service.resourceusage.HttpUsageMeter
import com.vitorpamplona.amethyst.service.resourceusage.MeteringNostrSigner
import com.vitorpamplona.amethyst.service.resourceusage.ProcessCpuSampler
import com.vitorpamplona.amethyst.service.resourceusage.RadioBurstEstimator
import com.vitorpamplona.amethyst.service.resourceusage.RefCountedSession
import com.vitorpamplona.amethyst.service.resourceusage.RelayConnectionTimeIntegrator
import com.vitorpamplona.amethyst.service.resourceusage.RelayUsageListener
import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageAccountant
import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageStore
import com.vitorpamplona.amethyst.service.resourceusage.ScreenTimeIntegrator
import com.vitorpamplona.amethyst.service.resourceusage.SessionTimeIntegrator
import com.vitorpamplona.amethyst.service.resourceusage.UsageCountingInterceptor
import com.vitorpamplona.amethyst.service.resourceusage.UsageKeys
import com.vitorpamplona.amethyst.service.safeCacheDir
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostWorkGate
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStore
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostWorker
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomMirrorQueue
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomSyncForegroundService
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.BlossomServerResolver
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.LocalBlossomCacheProbe
import com.vitorpamplona.amethyst.service.uploads.nip95.Nip95CacheFactory
@@ -128,20 +93,15 @@ import com.vitorpamplona.amethyst.ui.screen.AccountState
import com.vitorpamplona.amethyst.ui.screen.UiSettingsState
import com.vitorpamplona.amethyst.ui.tor.TorManager
import com.vitorpamplona.amethyst.ui.tor.TorService
import com.vitorpamplona.amethyst.ui.tor.TorServiceStatus
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayLogger
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayOfflineTracker
import com.vitorpamplona.quartz.nip01Core.relay.client.limits.RelayLimitsTracker
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.stats.RelayReqStats
import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStats
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CachingEventDecoder
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.SurgeDns
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.SurgeDnsStore
import com.vitorpamplona.quartz.nip03Timestamp.VerificationStateCache
import com.vitorpamplona.quartz.nip03Timestamp.okhttp.OkHttpBitcoinExplorer
import com.vitorpamplona.quartz.nip03Timestamp.ots.OtsBlockHeightCache
import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Client
@@ -158,15 +118,10 @@ import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinCoreRpcClie
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.TOR_ELECTRUMX_SERVERS
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.tags.RSVPStatusTag
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.CachingOnchainBackend
import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.EsploraBackend
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -179,7 +134,6 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.filterNotNull
@@ -247,17 +201,10 @@ class AppModules(
OtsSharedPreferences(appContext, applicationIOScope)
}
// App services that should be run as soon as there are subscribers to their
// flows. Location additionally releases its OS registration whenever no
// activity is started — see the foreground gate inside LocationState.
// App services that should be run as soon as there are subscribers to their flows
val locationManager by lazy {
Log.d("AppModules", "LocationManager Init")
LocationState(
appContext,
applicationIOScope,
isForeground = foregroundTracker.isForeground,
onListening = { locationRefCount.setActive(it) },
)
LocationState(appContext, applicationIOScope)
}
val connManager = ConnectivityManager(appContext, applicationIOScope)
@@ -266,8 +213,7 @@ class AppModules(
UiSettingsState(uiPrefs.value, connManager.isMobileOrFalse, applicationIOScope)
}
private val torService = TorService(appContext)
val torManager = TorManager(torPrefs, torService, applicationIOScope)
val torManager = TorManager(torPrefs, TorService(appContext), applicationIOScope)
// Network identity change (wifi↔cellular, regained from offline, captive portal
// cleared) — the old network's guards/circuits are dead, and Arti's in-memory
@@ -284,23 +230,6 @@ class AppModules(
}
}
// Restore + persist held NIP-OA attestations across restarts (device-global). Eager (not
// lazy) so it loads before the first Buzz-relay AUTH and mirrors later changes to disk.
val buzzAttestationPrefs = BuzzAttestationPreferences(appContext, applicationIOScope)
// Restore + persist the joined Buzz workspace relays across restarts (device-global). Eager so
// the app knows which relays to sync as workspaces on cold start (Buzz membership is
// server-side; there is no join event to rebuild the set from).
val buzzWorkspacePrefs = BuzzWorkspacePreferences(appContext, applicationIOScope)
// Restore + persist the user's starred Buzz workspace channels across restarts (device-global).
val buzzChannelStarPrefs = BuzzChannelStarPreferences(appContext, applicationIOScope)
// 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)
// Service that will run at all times to receive events from Pokey
val pokeyReceiver = PokeyReceiver()
@@ -317,114 +246,11 @@ class AppModules(
// path on first lookup. Stored in cacheDir — pure perf data, OK if the OS evicts it.
val dnsStore = SurgeDnsStore(File(appContext.safeCacheDir(), SurgeDnsStore.FILE_NAME), surgeDns)
// Network identity change (same trigger as Tor's onNetworkChange above), but for DNS
// the response is deliberately SOFT: most cached answers are still correct on the new
// network, so staleAll() keeps serving every one of them and merely re-verifies —
// positives revalidate in the background on next use, negatives (e.g. hosts that only
// failed because the OLD network / captive portal couldn't resolve them) get re-tried
// on first touch instead of waiting out their TTL. Nothing is dropped, nothing blocks.
init {
applicationIOScope.launch {
connManager.status
.map { (it as? ConnectivityStatus.Active)?.networkId }
.filterNotNull()
.distinctUntilChanged()
.drop(1)
.collect { surgeDns.staleAll() }
}
}
// Shared cache populated by OnionLocationInterceptor from any HTTP/WebSocket
// response carrying an Onion-Location header. Consulted by OnionUrlRewriteInterceptor
// on Tor-enabled clients to transparently redirect to .onion addresses.
val onionLocationCache = OnionLocationCache()
// ---- Resource-usage ledger (battery/data accounting) ----
// Passive on-device counters (bytes per subsystem x network x visibility,
// relay connection-time, wakelock time, worker runs). Never transmitted;
// the user can review them in Settings and explicitly DM a report to the
// developers. See amethyst/plans/2026-07-12-resource-usage-ledger.md.
val foregroundTracker = ForegroundTracker()
val resourceUsageStore = ResourceUsageStore(File(appContext.filesDir, ResourceUsageStore.FILE_NAME))
val resourceUsage = ResourceUsageAccountant(resourceUsageStore, applicationIOScope)
// Estimates radio wake-ups from HTTP burst patterns — bytes alone don't
// predict battery; scattered small requests each pay the radio ramp+tail.
private val radioBurstEstimator =
RadioBurstEstimator(
accountant = resourceUsage,
isMobile = { connManager.isMobileOrFalse.value },
isForeground = { foregroundTracker.isForeground.value },
)
// Single catch-all counter on the shared non-relay HTTP client: role
// wrappers only relabel via request tags, so no HTTP traffic (including
// direct getHttpClient users like the napplet broker) escapes the ledger.
private val httpUsageInterceptor =
UsageCountingInterceptor(
accountant = resourceUsage,
isMobile = { connManager.isMobileOrFalse.value },
isForeground = { foregroundTracker.isForeground.value },
bursts = radioBurstEstimator,
)
private val httpUsageMeter = HttpUsageMeter()
// Session-time counters for the app's long-running battery consumers.
// All timer-free segment integrators: services and status flows flip them
// on/off, so tracking costs one counter write per transition.
val alwaysOnSession = SessionTimeIntegrator(resourceUsage, UsageKeys.ALWAYS_ON_MS, UsageKeys.ALWAYS_ON_STARTS).also { it.registerFlushHook() }
val callSession = SessionTimeIntegrator(resourceUsage, UsageKeys.CALL_MS, UsageKeys.CALL_SESSIONS).also { it.registerFlushHook() }
val nestsSession = SessionTimeIntegrator(resourceUsage, UsageKeys.NESTS_MS, UsageKeys.NESTS_SESSIONS).also { it.registerFlushHook() }
private val powSession = SessionTimeIntegrator(resourceUsage, UsageKeys.POW_MS, UsageKeys.POW_SESSIONS).also { it.registerFlushHook() }
private val torSession = SessionTimeIntegrator(resourceUsage, UsageKeys.TOR_MS, UsageKeys.TOR_STARTS).also { it.registerFlushHook() }
private val locationSession = SessionTimeIntegrator(resourceUsage, UsageKeys.LOCATION_MS).also { it.registerFlushHook() }
// LocationState exposes two independent flows that can both be listening at
// once (the "Around Me" feed plus an open geohash chat). Refcounting keeps
// either one stopping from closing the other's segment.
private val locationRefCount = RefCountedSession(locationSession::setActive)
// Time-per-screen (route base names only — arguments never reach the
// ledger). Fed by the navigation listener in AppNavigation; foreground
// gating means backgrounding on a screen closes its segment.
val screenTime = ScreenTimeIntegrator(resourceUsage)
init {
screenTime.start(applicationIOScope, foregroundTracker.isForeground)
}
// In-app (Arti) Tor uptime. Watches the raw TorService status — NOT
// TorManager.status, whose upstream is WhileSubscribed and calls
// service.start() when collected, so a permanent ledger subscription
// there would keep Tor's control flow alive on its own. External Tor
// (Orbot) is deliberately untracked: its battery belongs to Orbot.
init {
applicationIOScope.launch {
torService.status
.map { it is TorServiceStatus.Active }
.distinctUntilChanged()
.collect { torSession.setActive(it) }
}
}
// Measured battery drain (percent while discharging, fg/bg) — the ground
// truth the app counters get correlated against. One binder read per
// ledger flush, nothing while idle.
init {
val batteryManager = appContext.getSystemService(Context.BATTERY_SERVICE) as? BatteryManager
if (batteryManager != null) {
BatteryDrainSampler(
accountant = resourceUsage,
capacityPct = { batteryManager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY).takeIf { it in 1..100 } },
isCharging = { batteryManager.isCharging },
isForeground = { foregroundTracker.isForeground.value },
).register()
}
}
// manages all the other connections separately from relays.
val okHttpClients: DualHttpClientManager =
DualHttpClientManager(
@@ -444,21 +270,10 @@ class AppModules(
master && !profileOnly && localBlossomCacheProbe.available.value
},
onionCache = onionLocationCache,
usageInterceptor = httpUsageInterceptor,
// Retries auth-gated Blossom blob downloads (e.g. Buzz's private
// media relay) with a BUD-01 read-auth token signed by the current
// account on a 401. Reads the signer at call time so it always
// tracks the logged-in account.
blossomReadAuth =
BlossomReadAuthInterceptor(
BlossomReadAuthTokenProvider(
signerProvider = { sessionManager.loggedInAccount()?.signer },
)::authHeader,
),
)
// Offers easy methods to know when connections are happening through Tor or not
val roleBasedHttpClientBuilder = RoleBasedHttpClientBuilder(okHttpClients, torPrefs.value, httpUsageMeter)
val roleBasedHttpClientBuilder = RoleBasedHttpClientBuilder(okHttpClients, torPrefs.value)
val electrumXClient by lazy {
Log.d("AppModules", "ElectrumXClient Init")
@@ -608,6 +423,12 @@ class AppModules(
)
}
// Application-wide ots verification cache
val otsVerifCache by lazy {
Log.d("AppModules", "OtsCache Init")
VerificationStateCache(otsResolverBuilder)
}
val torEvaluatorFlow =
TorRelayState(
okHttpClients,
@@ -682,23 +503,7 @@ class AppModules(
// Provides a relay pool. The caching decoder skips re-parsing EVENT frames
// that arrive again via another subscription or relay (14-57% of frames in
// production measurements).
//
// Wrapped in BlockedRelayFilteringClient so the active account's NIP-51
// kind:10006 blocked relay list is enforced centrally on every REQ, COUNT
// and publish (relay targeting is otherwise distributed across dozens of
// feed/loader/finder/broadcast sites, most of which don't subtract it).
// The blocked set is read per-call from the logged-in account.
val client: INostrClient =
BlockedRelayFilteringClient(
NostrClient(websocketBuilder, applicationIOScope, CachingEventDecoder()),
blockedRelays = {
sessionManager
.loggedInAccount()
?.blockedRelayList
?.flow
?.value ?: emptySet()
},
)
val client: INostrClient = NostrClient(websocketBuilder, applicationIOScope, CachingEventDecoder())
// Self-heals the "Tor Active but every circuit dead" state the lifecycle watchdogs can't
// see (they only arm while Connecting). Watches Tor-routed relay outcomes and, when enough
@@ -727,52 +532,25 @@ class AppModules(
// Verifies and inserts in the cache from all relays, all subscriptions
val cacheClientConnector = CacheClientConnector(client, cache)
// Show messages from the Relay and controls their dismissal. Attributes each NOTIFY to the
// account whose AUTH the relay rejected (accountsCache is declared below; the lambda reads it
// lazily at NOTIFY time, long after init).
val notifyCoordinator = NotifyCoordinator(client) { pubkey -> accountsCache.accounts.value[pubkey] }
// Show messages from the Relay and controls their dismissal
val notifyCoordinator = NotifyCoordinator(client)
// Per-relay NIP-42 ALLOW/DENY overrides are now per-account (Account.relayAuthPermissions,
// backed by a file under accounts/<pubkey>/), so there is no app-wide store here anymore.
/**
* The account every napplet/web-app grant and byte of storage is scoped to. Read lazily on each
* call (never captured) so an account switch immediately moves embedded apps to the new account's
* namespace: an app authorized by one npub is never authorized under another.
*/
val nappletAccountScope: () -> String = { sessionManager.loggedInAccount()?.pubKey ?: "" }
// Persists per-relay NIP-42 ALLOW/DENY overrides across app restarts.
val relayAuthPermissionStore by lazy {
DataStoreRelayAuthPermissionStore(appContext)
}
// Singleton stores for napplet permissions — DataStore v1 enforces one instance per file.
val nappletPermissionStore by lazy { DataStoreNappletPermissionStore(appContext, nappletAccountScope) }
/**
* The one napplet permission ledger for the main process. Its persistent half is just the store
* above, but it also holds the in-memory ALLOW_SESSION grants — and *those* only work if every
* caller shares this instance. The broker service and the Connected Apps screens used to build
* a ledger each, so a "Forget"/revoke tapped in the UI cleared the screen's own (always empty)
* session map while the grants the broker was actually consulting lived on untouched.
*
* Session lifetime is bounded by [com.vitorpamplona.amethyst.napplet.NappletBrokerService]'s
* onDestroy (all applet/browser surfaces gone), which calls `endSession()`.
*/
val nappletPermissionLedger by lazy { NappletPermissionLedger(nappletPermissionStore, nappletAccountScope) }
// NOT account-scoped here on purpose: this store is shared with NIP-46, whose coordinates already
// 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 nappletPermissionStore by lazy { DataStoreNappletPermissionStore(appContext) }
val signerPermissionStore by lazy { DataStoreNostrSignerPermissionStore(appContext) }
// Display + relay info for connected NIP-46 remote-signer clients.
val nip46ClientStore by lazy { DataStoreNip46ClientStore(appContext) }
// Authenticates with relays.
val authCoordinator = AuthCoordinator(client, applicationIOScope)
// Tries to verify new OTS events when they arrive.
val otsEventVerifier =
IncomingOtsEventVerifier(
otsResolverBuilder = otsResolverBuilder,
otsVerifCache = { otsVerifCache },
cache = cache,
scope = applicationIOScope,
)
@@ -783,36 +561,6 @@ class AppModules(
// Captures statistics about relays
val relayStats = RelayStats(client)
// Caches the latest LIMITS (rights + limits) each relay advertises.
val relayLimits = RelayLimitsTracker(client)
// Resource-usage ledger: relay traffic/reconnect + connection-time,
// foreground-time, process-CPU, and signature-verification collectors.
init {
client.addConnectionListener(
RelayUsageListener(
accountant = resourceUsage,
isMobile = { connManager.isMobileOrFalse.value },
isForeground = { foregroundTracker.isForeground.value },
),
)
RelayConnectionTimeIntegrator(
connectedCount = client.connectedRelaysFlow().map { it.size },
isMobile = connManager.isMobileOrNull,
isForeground = foregroundTracker.isForeground,
accountant = resourceUsage,
).start(applicationIOScope)
ForegroundTimeIntegrator(
isForeground = foregroundTracker.isForeground,
accountant = resourceUsage,
).start(applicationIOScope)
ProcessCpuSampler(resourceUsage).register()
cache.verifyMeter = { elapsedNanos, _ ->
resourceUsage.add(UsageKeys.VERIFY_COUNT, 1)
resourceUsage.add(UsageKeys.VERIFY_US, elapsedNanos / 1_000)
}
}
// Logs debug messages when needed
val detailedLogger = if (isDebug) RelayLogger(client, debugSending = false, debugReceiving = false) else null
val relayReqStats = if (isDebug) RelayReqStats(client) else null
@@ -821,10 +569,6 @@ class AppModules(
// Focused timeline for the DM / gift-wrap loading path (tag: DMPagination).
// val dmDiagnostics = if (isDebug) DmRelayDiagnosticsLogger(client) else null
// Per-relay cold-start census: connection outcome by cause, REQ/EOSE/CLOSED accounting,
// and which relays actually carried the boot (tag: BootRelayDiag).
val bootDiagnostics = if (isDebug) BootRelayDiagnostics(client) else null
// Coordinates all subscriptions for the Nostr Client
val sources: RelaySubscriptionsCoordinator =
RelaySubscriptionsCoordinator(
@@ -835,50 +579,6 @@ class AppModules(
applicationIOScope,
)
// fire-and-forget NIP-13 mining: posts queue here and publish when mined.
// One job mines at a time, racing half the cores over disjoint nonce
// slices — same total CPU budget as the old 2-job pool, but each post
// finishes ~minerThreads× sooner and the other half of the cores stays
// free for the UI. Template jobs checkpoint to disk (restored on login)
// 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)
}
val powPublishQueue by lazy {
PoWPublishQueue(
scope = applicationIOScope,
maxConcurrent = 1,
minerThreads = PoWPolicy.minerWorkers(Runtime.getRuntime().availableProcessors()),
persistence = powJobStore,
onQueueActive = { PowMiningForegroundService.start(appContext) },
).also { queue ->
// Resource ledger: mining burns half the cores flat-out for as
// long as it runs — without this, PoW shows up in cpu.ms as an
// unattributed mystery. Wired inside the lazy so the ledger never
// forces the queue to initialize.
applicationIOScope.launch {
queue.jobs
.map { jobs -> jobs.any { it.isMining } }
.distinctUntilChanged()
.collect { powSession.setActive(it) }
}
}
}
/** App-level BUD-04 mirror sweep, so "sync all" keeps running as the user navigates. */
val blossomMirrorQueue by lazy {
BlossomMirrorQueue(
scope = applicationIOScope,
onActive = { BlossomSyncForegroundService.start(appContext) },
)
}
val powJobRestorer by lazy {
PowJobRestorer(powPublishQueue, powJobStore, scheduledPostStore)
}
// keeps all accounts live
val accountsCache =
AccountCacheState(
@@ -892,10 +592,6 @@ class AppModules(
cache = cache,
client = client,
rootFilesDir = { appContext.filesDir },
powQueue = { powPublishQueue },
meterSigner = { MeteringNostrSigner(it, resourceUsage) },
signerPermissionStore = signerPermissionStore,
nip46ClientStore = nip46ClientStore,
)
val sessionManager =
@@ -907,17 +603,6 @@ class AppModules(
scope = applicationIOScope,
)
// Surfaces non-zap Lightning payments reported by the logged-in account's NWC
// wallet(s) as tray notifications (zaps are already shown via ZapNotification).
// The relay subscription lives in the always-on AccountFilterAssembler; this
// only drains the decoded-payment flow into an OS notification.
val nwcPaymentNotificationWatcher =
NwcPaymentNotificationWatcher(
context = appContext,
scope = applicationIOScope,
accountFlow = sessionManager.accountContent.map { (it as? AccountState.LoggedIn)?.account },
).also { it.start() }
fun subscribedFlow(
address: Address,
account: Account,
@@ -985,11 +670,7 @@ class AppModules(
// Observes LocalCache for notification-relevant events and routes them to
// EventNotificationConsumer. Sources: FCM, UnifiedPush, Pokey, active relay
// subscriptions, and NotificationRelayService.
val notificationDispatcher =
NotificationDispatcher(appContext, applicationIOScope) { heldMs ->
resourceUsage.add(UsageKeys.WAKELOCK_NOTIF_MS, heldMs)
resourceUsage.add(UsageKeys.WAKELOCK_NOTIF_COUNT, 1)
}
val notificationDispatcher = NotificationDispatcher(appContext, applicationIOScope)
// Local store for posts the user has scheduled to publish later. Backed by a
// single JSON file under the app's private filesDir; read by ScheduledPostWorker.
@@ -1062,25 +743,17 @@ class AppModules(
diskCache = { diskCache },
memoryCache = { memoryCache },
blossomServerResolver = { blossomResolver },
// Through the role builder (not raw getHttpClient) so Coil's image
// traffic carries the "image" ledger tag. Same Tor decision inside.
callFactory = { roleBasedHttpClientBuilder.okHttpClientForImage(it) },
callFactory = { okHttpClients.getHttpClient(roleBasedHttpClientBuilder.shouldUseTorForImageDownload(it)) },
thumbnailCache = thumbnailDiskCache,
backgroundScope = applicationIOScope,
)
}
// androidx.security.crypto's EncryptedSharedPreferences is deprecated with no drop-in successor.
@Suppress("DEPRECATION")
fun encryptedStorage(npub: String? = null): EncryptedSharedPreferences = EncryptedStorage.preferences(appContext, npub)
fun initiate(appContext: Context) {
Thread.setDefaultUncaughtExceptionHandler(UnexpectedCrashSaver(crashReportCache, applicationIOScope))
// Ledger: count process starts — high counts reveal WorkManager/restart
// churn that cold-starts the whole app graph repeatedly.
resourceUsage.add(UsageKeys.APP_STARTS, 1)
// Restore the persisted DNS cache before any networking starts. Lookups that fire
// before this completes fall through to the sync resolver path (existing behavior);
// once restored, every previously-seen host hits the stale-while-revalidate path
@@ -1130,11 +803,9 @@ class AppModules(
uiState
}
// LRUCache should not be instanciated in the Main thread due to blocking.
// trimToSize is a no-op on the empty cache; it just forces the object's
// (blocking) initialization to happen off the main thread.
// LRUCache should not be instanciated in the Main thread due to blocking
applicationIOScope.launch {
CachedRobohash.trimToSize(100)
CachedRobohash
resourceCacheInit()
}
@@ -1151,83 +822,29 @@ class AppModules(
// starts observing LocalCache for notification-worthy events
notificationDispatcher.start()
// Keep the scheduled-posts worker (15-min periodic + one-time catch-up)
// enqueued exactly while the store holds a PENDING post — see
// ScheduledPostWorkGate. Runs independently of the always-on
// notification setting so scheduled posts still fire when always-on
// notifications are disabled.
ScheduledPostWorkGate(
store = scheduledPostStore,
scope = applicationIOScope,
onPendingWork = {
ScheduledPostWorker.schedule(appContext)
ScheduledPostWorker.scheduleCatchUp(appContext)
},
onNoPendingWork = { ScheduledPostWorker.cancelPeriodic(appContext) },
).start()
// Schedule the scheduled-posts worker (periodic + one-time catch-up).
// Runs independently of the always-on notification setting so scheduled
// posts still fire when always-on notifications are disabled.
ScheduledPostWorker.schedule(appContext)
ScheduledPostWorker.scheduleCatchUp(appContext)
// "Starting soon" reminders for NIP-52 appointments the user RSVP'd to as
// ACCEPTED. The 15-min periodic scanner is only scheduled while it can
// plausibly fire: this observer enqueues it when an accepted RSVP lands in
// LocalCache, and the worker cancels its own chain when the cache holds
// nothing that could still start. LocalCache is memory-only, so the
// unconditional schedule this replaces could never fire from a WorkManager
// cold start anyway — it only cost battery.
applicationIOScope.launch {
LocalCache
.observeNewEvents<CalendarRSVPEvent>(Filter(kinds = listOf(CalendarRSVPEvent.KIND)))
.collect { rsvp ->
if (rsvp.status() == RSVPStatusTag.STATUS.ACCEPTED) {
CalendarReminderWorker.schedule(appContext)
}
}
}
// Periodic scan that posts "starting soon" notifications for NIP-52 appointments the
// user has RSVP'd to as ACCEPTED. 15-minute cadence matches both the WorkManager
// periodic minimum and the lead-time window.
com.vitorpamplona.amethyst.service.calendar.CalendarReminderWorker
.schedule(appContext)
// A rescheduled appointment must also re-arm the chain: the worker
// cancels itself when every known target is in the past, and a
// kind-31922/31923 update (the organizer moving the event) arrives
// WITHOUT any new RSVP — the user's existing RSVP still points at the
// same address, and observeNewEvents never re-fires for it. conflate +
// delay bounds the cache rescan to one per 30s while event feeds
// stream slot events; the scan only runs for users with reminders on.
applicationIOScope.launch {
LocalCache
.observeNewEvents<Event>(
Filter(kinds = listOf(CalendarDateSlotEvent.KIND, CalendarTimeSlotEvent.KIND)),
).conflate()
.collect {
if (CalendarReminderPrefs(appContext).isEnabled() &&
CalendarReminderWorker.couldStillFire(CalendarReminderWorker.acceptedRsvpsInCache(), TimeUtils.now())
) {
CalendarReminderWorker.schedule(appContext)
}
delay(30_000)
}
}
// Watch for account login and start/stop always-on notification service.
// The manager gates on the global master switch + each account's participation
// (not the active account), so it only needs to run while someone is logged in.
// Watch for account login and start/stop always-on notification service
applicationIOScope.launch {
sessionManager.accountContent.collectLatest { state ->
if (state is AccountState.LoggedIn) {
alwaysOnNotificationServiceManager.start()
alwaysOnNotificationServiceManager.watchAccount(state.account)
} else {
alwaysOnNotificationServiceManager.stop()
}
}
}
// Resume PoW mining jobs that were checkpointed before a process death,
// for EVERY loaded account (the always-on service preloads non-active
// accounts, whose pending posts must not stay stranded on disk).
// Idempotent (the queue dedupes by job id), so re-emissions are safe.
applicationIOScope.launch {
accountsCache.accounts.collect { loaded ->
loaded.values.forEach { powJobRestorer.restore(it) }
}
}
// Evict the BlossomServerResolver URL cache whenever either local-cache
// toggle flips or the probe transitions up/down so stale entries don't
// outlive the underlying decision.
@@ -1243,14 +860,6 @@ class AppModules(
blossomResolver.uriToUrlCache.evictAll()
blossomResolver.blossomHitCache.cache.evictAll()
localBlossomCacheProbe.invalidate()
// Re-probe immediately so enabling the feature activates it
// this session. Otherwise `available` only advances when a
// `blossom:` URI is resolved, and the common feed-image path
// never routes through the resolver until `available` is
// already true — so a freshly-enabled toggle (or a cache that
// came up after launch) would stay dormant and the settings
// "detected" chip would read stale.
localBlossomCacheProbe.isAvailable()
}
}
}
@@ -1296,8 +905,6 @@ class AppModules(
fun trim(level: Int) {
_trimLevelEvents.tryEmit(level)
// Backgrounding is a natural moment to flush the usage ledger too.
resourceUsage.flushAsync()
applicationIOScope.launch {
// Backgrounding is a natural moment to flush the DNS cache.
dnsStore.save()
@@ -1305,36 +912,61 @@ class AppModules(
trimmingService.run(loggedIn, LocalPreferences.allSavedAccounts(), level)
// Trim in-process caches proportional to OS memory pressure.
//
// Since API 34 the OS only ever delivers two trim levels (the foreground
// RUNNING_* levels and the deeper MODERATE/COMPLETE background tiers were
// deprecated because apps are no longer notified of them):
// BACKGROUND(40) — process is on the system LRU list: real reclaim
// pressure, and the strongest signal we still get.
// UI_HIDDEN (20) — just backgrounded, no pressure yet. Fires on EVERY
// app switch.
// Background levels (app not visible, ordered highest-first so the when
// chain short-circuits at the right tier):
// COMPLETE (80) — at the bottom of the LRU list, kill imminent
// MODERATE (60) — system is hurting, neighbouring apps being killed
// BACKGROUND(40) — backgrounded, mild system pressure
// UI_HIDDEN (20) — just backgrounded, no pressure yet
//
// So we key off exactly those two. UI_HIDDEN is frequent, so it only trims
// images (bitmaps are the largest allocations) and keeps the CPU-heavy
// caches (Robohash SVG assembly, rich-text parsing) warm — clearing them
// would force a full rebuild on every resume and cause visible jank.
// BACKGROUND trims hard but keeps a small working set: it means "on the LRU
// list" (real reclaim pressure), not the imminent kill that COMPLETE used to
// signal — so leave just enough warm to redraw the screen the user left on.
// Foreground levels (app is active but system is low):
// RUNNING_CRITICAL (15), RUNNING_LOW (10)
//
// UI_HIDDEN fires on EVERY app switch. Don't clear CPU-heavy caches
// (Robohash SVG assembly, rich-text parsing) there — clearing them
// forces a full rebuild on every resume and causes visible jank.
when {
level >= ComponentCallbacks2.TRIM_MEMORY_COMPLETE -> {
// Kill imminent: free everything.
memoryCache.trimToSize(0)
CachedRichTextParser.trimToSize(0)
CachedRobohash.trimToSize(0)
nip11Cache.trimToSize(0)
}
level >= ComponentCallbacks2.TRIM_MEMORY_MODERATE -> {
// System under real pressure: clear images and most parsed state.
memoryCache.trimToSize(0)
CachedRichTextParser.trimToSize(50)
CachedRobohash.trimToSize(10)
nip11Cache.trimToSize(100)
}
level >= ComponentCallbacks2.TRIM_MEMORY_BACKGROUND -> {
// On the LRU list under real pressure: trim hard, but keep a small
// working set so a returning user doesn't rebuild the visible screen
// from scratch. memoryCache is byte-sized (Coil), the rest are entry counts.
memoryCache.trimToSize(memoryCache.maxSize / 10)
CachedRichTextParser.trimToSize(10)
// Backgrounded with mild pressure: trim significantly.
memoryCache.trimToSize(memoryCache.maxSize / 4)
CachedRichTextParser.trimToSize(100)
CachedRobohash.trimToSize(20)
nip11Cache.trimToSize(10)
nip11Cache.trimToSize(200)
}
level >= ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN -> {
// Just backgrounded, no pressure yet: trim images but keep the
// parsed-text and avatar caches warm so resuming is instant.
// Just backgrounded, no pressure yet: trim images (bitmaps are the
// largest allocations) but keep parsed-text and avatar caches warm
// so resuming is instant.
memoryCache.trimToSize(memoryCache.maxSize / 2)
}
level >= ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL -> {
// Foreground, critically low memory.
memoryCache.trimToSize(memoryCache.maxSize / 4)
CachedRichTextParser.trimToSize(100)
CachedRobohash.trimToSize(20)
nip11Cache.trimToSize(200)
}
level >= ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW -> {
// Foreground, low memory.
memoryCache.trimToSize(memoryCache.maxSize / 2)
CachedRichTextParser.trimToSize(250)
CachedRobohash.trimToSize(50)
nip11Cache.trimToSize(500)
}
}
}
}
@@ -31,9 +31,6 @@ class EncryptedStorage {
// returns the preferences for each account or a global file if null.
fun prefsFileName(npub: String? = null): String = if (npub == null) PREFERENCES_NAME else "${PREFERENCES_NAME}_$npub"
// androidx.security.crypto is deprecated with no drop-in successor; migrating the
// on-disk key store is a separate, security-sensitive effort.
@Suppress("DEPRECATION")
fun preferences(
applicationContext: Context,
npub: String? = null,
@@ -25,24 +25,18 @@ import android.content.Context
import android.content.SharedPreferences
import androidx.compose.runtime.Immutable
import androidx.core.content.edit
import com.vitorpamplona.amethyst.commons.model.chats.ChatFeedType
import com.vitorpamplona.amethyst.commons.model.clink.ClinkDebitWalletEntry
import com.vitorpamplona.amethyst.commons.model.concord.ConcordViewMode
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupViewMode
import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntry
import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntryNorm
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.HomeFeedType
import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.model.UiSettings
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
@@ -58,7 +52,6 @@ import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent
import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListEvent
import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent
import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent
import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent
@@ -66,19 +59,15 @@ import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.SimpleGroupListEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListEvent
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
@@ -102,11 +91,6 @@ data class AccountInfo(
private object PrefKeys {
const val CURRENT_ACCOUNT = "currently_logged_in_account"
// Global (non-account) master switch for the always-on notification service.
// When off, the service is suppressed for every account regardless of each
// account's own participation flag. Persisted so it survives restarts/crashes.
const val NOTIFICATION_SERVICE_ENABLED = "notification_service_enabled"
const val SAVED_ACCOUNTS = "all_saved_accounts"
const val NOSTR_PRIVKEY = "nostr_privkey"
const val NOSTR_PUBKEY = "nostr_pubkey"
@@ -115,25 +99,17 @@ private object PrefKeys {
const val STRIP_LOCATION_ON_UPLOAD = "stripLocationOnUpload"
const val USE_LOCAL_BLOSSOM_CACHE = "useLocalBlossomCache"
const val LOCAL_BLOSSOM_CACHE_PROFILE_PICTURES_ONLY = "localBlossomCacheProfilePicturesOnly"
const val MIRROR_UPLOADS_TO_ALL_SERVERS = "mirrorUploadsToAllServers"
const val OPTIMIZE_MEDIA_ON_UPLOAD = "optimizeMediaOnUpload"
const val HIDE_COMMUNITY_RULES_VIOLATIONS = "hideCommunityRulesViolations"
const val NIP46_SIGNER_ENABLED = "nip46SignerEnabled"
const val NIP46_BUNKER_SECRET = "nip46BunkerSecret"
const val NIP46_TRANSPORT_KEY = "nip46TransportKey"
const val NIP46_SEEN_IDS = "nip46SeenRequestIds"
const val DEFAULT_HOME_FOLLOW_LIST = "defaultHomeFollowList"
const val DEFAULT_STORIES_FOLLOW_LIST = "defaultStoriesFollowList"
const val DEFAULT_NOTIFICATION_FOLLOW_LIST = "defaultNotificationFollowList"
const val DEFAULT_DISCOVERY_FOLLOW_LIST = "defaultDiscoveryFollowList"
const val DEFAULT_POLLS_FOLLOW_LIST = "defaultPollsFollowList"
const val DEFAULT_PICTURES_FOLLOW_LIST = "defaultPicturesFollowList"
const val DEFAULT_RELAY_GROUPS_DISCOVERY_FOLLOW_LIST = "defaultRelayGroupsDiscoveryFollowList"
const val DEFAULT_NAPPLETS_FOLLOW_LIST = "defaultNappletsFollowList"
const val DEFAULT_NSITES_FOLLOW_LIST = "defaultNsitesFollowList"
const val DEFAULT_WORKOUTS_FOLLOW_LIST = "defaultWorkoutsFollowList"
const val DEFAULT_GIT_REPOSITORIES_FOLLOW_LIST = "defaultGitRepositoriesFollowList"
const val DEFAULT_HIGHLIGHTS_FOLLOW_LIST = "defaultHighlightsFollowList"
const val DEFAULT_CALENDARS_FOLLOW_LIST = "defaultCalendarsFollowList"
const val DEFAULT_PRODUCTS_FOLLOW_LIST = "defaultProductsFollowList"
const val DEFAULT_SHORTS_FOLLOW_LIST = "defaultShortsFollowList"
@@ -174,31 +150,13 @@ private object PrefKeys {
const val LATEST_HASHTAG_LIST = "latestHashtagList"
const val LATEST_GEOHASH_LIST = "latestGeohashList"
const val LATEST_EPHEMERAL_LIST = "latestEphemeralChatList"
const val LATEST_RELAY_GROUP_LIST = "latestRelayGroupList"
const val LATEST_CONCORD_LIST = "latestConcordList"
const val LATEST_TRUST_PROVIDER_LIST = "latestTrustProviderList"
const val LATEST_KEY_PACKAGE_RELAY_LIST = "latestKeyPackageRelayList"
const val LATEST_FAVORITE_ALGO_FEEDS_LIST = "latestFavoriteAlgoFeedsList"
const val CALLS_ENABLED = "calls_enabled"
const val HIDE_DELETE_REQUEST_DIALOG = "hide_delete_request_dialog"
const val HIDE_BLOCK_ALERT_DIALOG = "hide_block_alert_dialog"
const val HIDE_NIP_17_WARNING_DIALOG = "hide_nip24_warning_dialog" // delete later
const val ALWAYS_ON_NOTIFICATION_SERVICE = "always_on_notification_service"
const val DEFAULT_RELAY_AUTH_POLICY = "default_relay_auth_policy"
const val RELAY_GROUP_VIEW_MODE = "relay_group_view_mode"
const val CONCORD_VIEW_MODE = "concord_view_mode"
// Stores the DISABLED chat feed types (comma-joined codes) so absence = all-on and any newly
// added type defaults enabled for accounts that customized before it existed.
const val DISABLED_CHAT_FEEDS = "disabled_chat_feeds"
// Same convention as DISABLED_CHAT_FEEDS but for the Home feed's event-kind groups: stores the
// DISABLED codes so absence = all-on and any newly added group defaults enabled.
const val DISABLED_HOME_FEED_TYPES = "disabled_home_feed_types"
const val RELAY_AUTH_TRUST_MY_RELAYS = "relay_auth_trust_my_relays_and_venues"
const val RELAY_AUTH_TRUST_READ_FOLLOWS = "relay_auth_trust_read_follows"
const val RELAY_AUTH_TRUST_MESSAGE_FOLLOWS = "relay_auth_trust_message_follows"
const val RELAY_AUTH_TRUST_MESSAGE_STRANGERS = "relay_auth_trust_message_strangers"
const val SPLIT_NOTIFICATIONS_ENABLED = "split_notifications_enabled"
const val SHOW_MESSAGES_IN_NOTIFICATIONS = "show_messages_in_notifications"
@@ -213,14 +171,12 @@ private object PrefKeys {
const val SIGNER_PACKAGE_NAME = "signer_package_name"
const val HAS_DONATED_IN_VERSION = "has_donated_in_version"
const val DISMISSED_POLL_NOTE_IDS = "dismissed_poll_note_ids"
const val DISMISSED_CHANNEL_INVITES = "dismissed_channel_invites"
const val VIEWED_POLL_RESULT_NOTE_IDS = "viewed_poll_result_note_ids"
const val PENDING_ATTESTATIONS = "pending_attestations"
const val ALL_ACCOUNT_INFO = "all_saved_accounts_info"
const val SHARED_SETTINGS = "shared_settings"
const val LATEST_PAYMENT_TARGETS = "latestPaymentTargets"
const val LATEST_BOLT12_OFFERS = "latestBolt12Offers"
const val LATEST_CASHU_WALLET = "latestCashuWallet"
const val LATEST_NUTZAP_INFO = "latestNutzapInfo"
}
@@ -238,49 +194,6 @@ object LocalPreferences {
private val savedAccountsMutex = Mutex()
private val cachedAccounts: MutableMap<String, AccountSettings?> = mutableMapOf()
// Global master switch for the always-on notification service ("Background
// notification service"). Default ON: existing users keep current behavior, and
// per-account participation decides who actually stays active.
//
// Stored in PLAIN (non-encrypted) SharedPreferences on purpose. It is a non-sensitive
// global boolean, and — unlike encryptedPreferences(), which asserts non-main — plain
// prefs can be read synchronously on ANY thread. The restart-layer gate
// (NotificationRelayService.isEnabled) is synchronous and runs in fresh processes (boot
// receiver, WorkManager), so it MUST read the persisted value without a suspend hop;
// otherwise a saved OFF would be missed on cold boot and the service would resurrect.
// The flow is lazily seeded from disk once (synchronous, main-safe) and is thereafter
// the source of truth, so there is no async hydrate that could clobber a user toggle.
private fun globalSettingsPrefs(): SharedPreferences = Amethyst.instance.appContext.getSharedPreferences("amethyst_global_settings", Context.MODE_PRIVATE)
/**
* Loads the global-settings prefs file into SharedPreferences' in-memory cache, off the main
* thread, so the first synchronous read below hits memory rather than disk.
*
* The read itself is deliberately synchronous — see [setNotificationServiceEnabled]: an async
* hydrate reintroduces a window where a late disk read clobbers a user's toggle. So this warms
* the cache instead of deferring the read. Best-effort: if a main-thread reader wins the race it
* simply pays the disk hit once, exactly as before.
*/
fun warmGlobalSettings() {
globalSettingsPrefs().getBoolean(PrefKeys.NOTIFICATION_SERVICE_ENABLED, true)
}
private val notificationServiceEnabled: MutableStateFlow<Boolean> by lazy {
MutableStateFlow(globalSettingsPrefs().getBoolean(PrefKeys.NOTIFICATION_SERVICE_ENABLED, true))
}
fun notificationServiceEnabledFlow(): StateFlow<Boolean> = notificationServiceEnabled
fun isNotificationServiceEnabled(): Boolean = notificationServiceEnabled.value
fun setNotificationServiceEnabled(enabled: Boolean) {
// In-memory update is the source of truth (main-safe); plain-prefs edit{} persists
// asynchronously via apply(), also main-safe. No suspend/hydrate hop, so no window
// where a late disk read can clobber this write.
notificationServiceEnabled.value = enabled
globalSettingsPrefs().edit { putBoolean(PrefKeys.NOTIFICATION_SERVICE_ENABLED, enabled) }
}
suspend fun currentAccount(): String? {
if (currentAccount == null) {
currentAccount =
@@ -326,9 +239,6 @@ object LocalPreferences {
}
if (!newSystemOfAccounts.isNullOrEmpty()) {
// How many accounts are in play is the first thing you need when reading any
// boot log: nearly every per-account subsystem below multiplies by this number.
Log.i("LocalPreferences") { "Found ${newSystemOfAccounts.size} saved account(s)" }
newSystemOfAccounts
} else {
val oldAccounts = getString(PrefKeys.SAVED_ACCOUNTS, null)?.split(COMMA) ?: listOf()
@@ -507,13 +417,7 @@ object LocalPreferences {
putBoolean(PrefKeys.STRIP_LOCATION_ON_UPLOAD, settings.stripLocationOnUpload)
putBoolean(PrefKeys.USE_LOCAL_BLOSSOM_CACHE, settings.useLocalBlossomCache.value)
putBoolean(PrefKeys.LOCAL_BLOSSOM_CACHE_PROFILE_PICTURES_ONLY, settings.localBlossomCacheProfilePicturesOnly.value)
putBoolean(PrefKeys.MIRROR_UPLOADS_TO_ALL_SERVERS, settings.mirrorUploadsToAllServers.value)
putBoolean(PrefKeys.OPTIMIZE_MEDIA_ON_UPLOAD, settings.optimizeMediaOnUpload.value)
putBoolean(PrefKeys.HIDE_COMMUNITY_RULES_VIOLATIONS, settings.hideCommunityRulesViolations.value)
putBoolean(PrefKeys.NIP46_SIGNER_ENABLED, settings.nip46SignerEnabled.value)
putString(PrefKeys.NIP46_BUNKER_SECRET, settings.nip46BunkerSecret.value)
putString(PrefKeys.NIP46_TRANSPORT_KEY, settings.nip46TransportKey.value)
putStringSet(PrefKeys.NIP46_SEEN_IDS, settings.nip46SeenRequestIds.value)
putString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, JsonMapper.toJson(settings.defaultHomeFollowList.value))
putString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultStoriesFollowList.value))
@@ -522,12 +426,10 @@ object LocalPreferences {
putString(PrefKeys.DEFAULT_POLLS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultPollsFollowList.value))
putString(PrefKeys.DEFAULT_PICTURES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultPicturesFollowList.value))
putString(PrefKeys.DEFAULT_RELAY_GROUPS_DISCOVERY_FOLLOW_LIST, JsonMapper.toJson(settings.defaultRelayGroupsDiscoveryFollowList.value))
putString(PrefKeys.DEFAULT_NAPPLETS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultNappletsFollowList.value))
putString(PrefKeys.DEFAULT_NSITES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultNsitesFollowList.value))
putString(PrefKeys.DEFAULT_WORKOUTS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultWorkoutsFollowList.value))
putString(PrefKeys.DEFAULT_GIT_REPOSITORIES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultGitRepositoriesFollowList.value))
putString(PrefKeys.DEFAULT_HIGHLIGHTS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultHighlightsFollowList.value))
putString(PrefKeys.DEFAULT_CALENDARS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultCalendarsFollowList.value))
putString(PrefKeys.DEFAULT_PRODUCTS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultProductsFollowList.value))
putString(PrefKeys.DEFAULT_SHORTS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultShortsFollowList.value))
@@ -596,13 +498,8 @@ object LocalPreferences {
putOrRemove(PrefKeys.LATEST_HASHTAG_LIST, settings.backupHashtagList)
putOrRemove(PrefKeys.LATEST_GEOHASH_LIST, settings.backupGeohashList)
putOrRemove(PrefKeys.LATEST_EPHEMERAL_LIST, settings.backupEphemeralChatList)
putOrRemove(PrefKeys.LATEST_RELAY_GROUP_LIST, settings.backupRelayGroupList)
putOrRemove(PrefKeys.LATEST_CONCORD_LIST, settings.backupConcordList)
putOrRemove(PrefKeys.LATEST_TRUST_PROVIDER_LIST, settings.backupTrustProviderList)
putOrRemove(PrefKeys.LATEST_KEY_PACKAGE_RELAY_LIST, settings.backupKeyPackageRelayList)
putOrRemove(PrefKeys.LATEST_FAVORITE_ALGO_FEEDS_LIST, settings.backupFavoriteAlgoFeedsList)
putOrRemove(PrefKeys.LATEST_PAYMENT_TARGETS, settings.backupNipA3PaymentTargets)
putOrRemove(PrefKeys.LATEST_BOLT12_OFFERS, settings.backupBolt12Offers)
putOrRemove(PrefKeys.LATEST_CASHU_WALLET, settings.backupCashuWallet)
putOrRemove(PrefKeys.LATEST_NUTZAP_INFO, settings.backupNutzapInfo)
@@ -612,14 +509,6 @@ object LocalPreferences {
putBoolean(PrefKeys.CALLS_ENABLED, settings.callsEnabled.value)
putBoolean(PrefKeys.ALWAYS_ON_NOTIFICATION_SERVICE, settings.alwaysOnNotificationService.value)
putString(PrefKeys.DEFAULT_RELAY_AUTH_POLICY, settings.defaultRelayAuthPolicy.value.name)
putString(PrefKeys.RELAY_GROUP_VIEW_MODE, settings.relayGroupViewMode.value.name)
putString(PrefKeys.CONCORD_VIEW_MODE, settings.concordViewMode.value.name)
putString(PrefKeys.DISABLED_CHAT_FEEDS, ChatFeedType.encode(ChatFeedType.ALL - settings.enabledChatFeeds.value))
putString(PrefKeys.DISABLED_HOME_FEED_TYPES, HomeFeedType.encode(HomeFeedType.ALL - settings.enabledHomeFeedTypes.value))
putBoolean(PrefKeys.RELAY_AUTH_TRUST_MY_RELAYS, settings.relayAuthTrustMyRelaysAndVenues.value)
putBoolean(PrefKeys.RELAY_AUTH_TRUST_READ_FOLLOWS, settings.relayAuthTrustReadFollows.value)
putBoolean(PrefKeys.RELAY_AUTH_TRUST_MESSAGE_FOLLOWS, settings.relayAuthTrustMessageFollows.value)
putBoolean(PrefKeys.RELAY_AUTH_TRUST_MESSAGE_STRANGERS, settings.relayAuthTrustMessageStrangers.value)
putBoolean(PrefKeys.SPLIT_NOTIFICATIONS_ENABLED, settings.splitNotificationsEnabled.value)
putBoolean(PrefKeys.SHOW_MESSAGES_IN_NOTIFICATIONS, settings.showMessagesInNotifications.value)
// Any account that reaches a save has its notification filter in its
@@ -643,7 +532,6 @@ object LocalPreferences {
)
putStringSet(PrefKeys.HAS_DONATED_IN_VERSION, settings.hasDonatedInVersion.value)
putStringSet(PrefKeys.DISMISSED_POLL_NOTE_IDS, settings.dismissedPollNoteIds.value)
putStringSet(PrefKeys.DISMISSED_CHANNEL_INVITES, settings.dismissedChannelInvites.value)
putString(
PrefKeys.VIEWED_POLL_RESULT_NOTE_IDS,
JsonMapper.toJson(settings.viewedPollResultNoteIds.value),
@@ -716,7 +604,6 @@ object LocalPreferences {
private suspend fun innerLoadCurrentAccountFromEncryptedStorage(npub: String?): AccountSettings? {
Log.d("LocalPreferences") { "Load account from file $npub" }
val startedAtMs = TimeUtils.nowMillis()
val result =
withContext(Dispatchers.IO) {
return@withContext with(encryptedPreferences(npub)) {
@@ -732,27 +619,20 @@ object LocalPreferences {
val stripLocationOnUpload = getBoolean(PrefKeys.STRIP_LOCATION_ON_UPLOAD, true)
val useLocalBlossomCache = getBoolean(PrefKeys.USE_LOCAL_BLOSSOM_CACHE, true)
val localBlossomCacheProfilePicturesOnly = getBoolean(PrefKeys.LOCAL_BLOSSOM_CACHE_PROFILE_PICTURES_ONLY, false)
val mirrorUploadsToAllServers = getBoolean(PrefKeys.MIRROR_UPLOADS_TO_ALL_SERVERS, true)
val optimizeMediaOnUpload = getBoolean(PrefKeys.OPTIMIZE_MEDIA_ON_UPLOAD, false)
val hideCommunityRulesViolations = getBoolean(PrefKeys.HIDE_COMMUNITY_RULES_VIOLATIONS, false)
val nip46SignerEnabled = getBoolean(PrefKeys.NIP46_SIGNER_ENABLED, false)
val nip46BunkerSecret = getString(PrefKeys.NIP46_BUNKER_SECRET, "") ?: ""
val nip46TransportKey = getString(PrefKeys.NIP46_TRANSPORT_KEY, "") ?: ""
val nip46SeenRequestIds = getStringSet(PrefKeys.NIP46_SEEN_IDS, null) ?: setOf()
val hideDeleteRequestDialog = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false)
val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false)
val hideNIP17WarningDialog = getBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, false)
val callsEnabled = getBoolean(PrefKeys.CALLS_ENABLED, true)
val alwaysOnNotificationService = getBoolean(PrefKeys.ALWAYS_ON_NOTIFICATION_SERVICE, false)
// Read as a group via a helper: this load lambda sits right at the JVM's
// per-method bytecode limit (see the note above the awaits below), so keeping
// these heavy string/enum decodes out of it preserves headroom.
val inboxPrefs = readInboxPrefs()
val defaultRelayAuthPolicy =
getString(PrefKeys.DEFAULT_RELAY_AUTH_POLICY, null)
?.let { runCatching { RelayAuthPolicy.valueOf(it) }.getOrNull() }
?: RelayAuthPolicy.IF_IN_MY_LIST
val splitNotificationsEnabled = getBoolean(PrefKeys.SPLIT_NOTIFICATIONS_ENABLED, false)
val showMessagesInNotifications = getBoolean(PrefKeys.SHOW_MESSAGES_IN_NOTIFICATIONS, true)
val hasDonatedInVersion = getStringSet(PrefKeys.HAS_DONATED_IN_VERSION, null) ?: setOf()
val dismissedPollNoteIds = getStringSet(PrefKeys.DISMISSED_POLL_NOTE_IDS, null) ?: setOf()
val dismissedChannelInvites = getStringSet(PrefKeys.DISMISSED_CHANNEL_INVITES, null) ?: setOf()
val viewedPollResultNoteIdsStr = getString(PrefKeys.VIEWED_POLL_RESULT_NOTE_IDS, null)
val localRelayServers = getStringSet(PrefKeys.LOCAL_RELAY_SERVERS, null) ?: setOf()
@@ -783,13 +663,8 @@ object LocalPreferences {
val latestHashtagListStr = getString(PrefKeys.LATEST_HASHTAG_LIST, null)
val latestGeohashListStr = getString(PrefKeys.LATEST_GEOHASH_LIST, null)
val latestEphemeralListStr = getString(PrefKeys.LATEST_EPHEMERAL_LIST, null)
val latestRelayGroupListStr = getString(PrefKeys.LATEST_RELAY_GROUP_LIST, null)
val latestConcordListStr = getString(PrefKeys.LATEST_CONCORD_LIST, null)
val latestTrustProviderListStr = getString(PrefKeys.LATEST_TRUST_PROVIDER_LIST, null)
val latestKeyPackageRelayListStr = getString(PrefKeys.LATEST_KEY_PACKAGE_RELAY_LIST, null)
val latestFavoriteAlgoFeedsListStr = getString(PrefKeys.LATEST_FAVORITE_ALGO_FEEDS_LIST, null)
val latestPaymentTargetsStr = getString(PrefKeys.LATEST_PAYMENT_TARGETS, null)
val latestBolt12OffersStr = getString(PrefKeys.LATEST_BOLT12_OFFERS, null)
val latestCashuWalletStr = getString(PrefKeys.LATEST_CASHU_WALLET, null)
val latestNutzapInfoStr = getString(PrefKeys.LATEST_NUTZAP_INFO, null)
val lastReadPerRouteStr = getString(PrefKeys.LAST_READ_PER_ROUTE, null)
@@ -847,13 +722,8 @@ object LocalPreferences {
val latestHashtagList = async { parseEventOrNull<HashtagListEvent>(latestHashtagListStr) }
val latestGeohashList = async { parseEventOrNull<GeohashListEvent>(latestGeohashListStr) }
val latestEphemeralList = async { parseEventOrNull<EphemeralChatListEvent>(latestEphemeralListStr) }
val latestRelayGroupList = async { parseEventOrNull<SimpleGroupListEvent>(latestRelayGroupListStr) }
val latestConcordList = async { parseEventOrNull<ConcordCommunityListEvent>(latestConcordListStr) }
val latestTrustProviderList = async { parseEventOrNull<TrustProviderListEvent>(latestTrustProviderListStr) }
val latestKeyPackageRelayList = async { parseEventOrNull<KeyPackageRelayListEvent>(latestKeyPackageRelayListStr) }
val latestFavoriteAlgoFeedsList = async { parseEventOrNull<FavoriteAlgoFeedsListEvent>(latestFavoriteAlgoFeedsListStr) }
val latestPaymentTargets = async { parseEventOrNull<PaymentTargetsEvent>(latestPaymentTargetsStr) }
val latestBolt12Offers = async { parseEventOrNull<Bolt12OfferListEvent>(latestBolt12OffersStr) }
val latestCashuWallet =
async {
parseEventOrNull<com.vitorpamplona.quartz.nip60Cashu.wallet.CashuWalletEvent>(latestCashuWalletStr)
@@ -903,13 +773,8 @@ object LocalPreferences {
val latestHashtagListResolved = latestHashtagList.await()
val latestGeohashListResolved = latestGeohashList.await()
val latestEphemeralListResolved = latestEphemeralList.await()
val latestRelayGroupListResolved = latestRelayGroupList.await()
val latestConcordListResolved = latestConcordList.await()
val latestTrustProviderListResolved = latestTrustProviderList.await()
val latestKeyPackageRelayListResolved = latestKeyPackageRelayList.await()
val latestFavoriteAlgoFeedsListResolved = latestFavoriteAlgoFeedsList.await()
val latestPaymentTargetsResolved = latestPaymentTargets.await()
val latestBolt12OffersResolved = latestBolt12Offers.await()
val latestCashuWalletResolved = latestCashuWallet.await()
val latestNutzapInfoResolved = latestNutzapInfo.await()
@@ -924,25 +789,17 @@ object LocalPreferences {
stripLocationOnUpload = stripLocationOnUpload,
useLocalBlossomCache = MutableStateFlow(useLocalBlossomCache),
localBlossomCacheProfilePicturesOnly = MutableStateFlow(localBlossomCacheProfilePicturesOnly),
mirrorUploadsToAllServers = MutableStateFlow(mirrorUploadsToAllServers),
optimizeMediaOnUpload = MutableStateFlow(optimizeMediaOnUpload),
hideCommunityRulesViolations = MutableStateFlow(hideCommunityRulesViolations),
nip46SignerEnabled = MutableStateFlow(nip46SignerEnabled),
nip46BunkerSecret = MutableStateFlow(nip46BunkerSecret),
nip46TransportKey = MutableStateFlow(nip46TransportKey),
nip46SeenRequestIds = MutableStateFlow(nip46SeenRequestIds),
defaultHomeFollowList = MutableStateFlow(followListPrefs.home),
defaultStoriesFollowList = MutableStateFlow(followListPrefs.stories),
defaultNotificationFollowList = MutableStateFlow(followListPrefs.notification),
defaultDiscoveryFollowList = MutableStateFlow(followListPrefs.discovery),
defaultPollsFollowList = MutableStateFlow(followListPrefs.polls),
defaultPicturesFollowList = MutableStateFlow(followListPrefs.pictures),
defaultRelayGroupsDiscoveryFollowList = MutableStateFlow(followListPrefs.relayGroupsDiscovery),
defaultNappletsFollowList = MutableStateFlow(followListPrefs.napplets),
defaultNsitesFollowList = MutableStateFlow(followListPrefs.nsites),
defaultWorkoutsFollowList = MutableStateFlow(followListPrefs.workouts),
defaultGitRepositoriesFollowList = MutableStateFlow(followListPrefs.gitRepositories),
defaultHighlightsFollowList = MutableStateFlow(followListPrefs.highlights),
defaultCalendarsFollowList = MutableStateFlow(followListPrefs.calendars),
defaultProductsFollowList = MutableStateFlow(followListPrefs.products),
defaultShortsFollowList = MutableStateFlow(followListPrefs.shorts),
@@ -975,15 +832,7 @@ object LocalPreferences {
hideBlockAlertDialog = hideBlockAlertDialog,
hideNIP17WarningDialog = hideNIP17WarningDialog,
alwaysOnNotificationService = MutableStateFlow(alwaysOnNotificationService),
defaultRelayAuthPolicy = MutableStateFlow(inboxPrefs.defaultRelayAuthPolicy),
relayGroupViewMode = MutableStateFlow(inboxPrefs.relayGroupViewMode),
concordViewMode = MutableStateFlow(inboxPrefs.concordViewMode),
enabledChatFeeds = MutableStateFlow(inboxPrefs.enabledChatFeeds),
enabledHomeFeedTypes = MutableStateFlow(inboxPrefs.enabledHomeFeedTypes),
relayAuthTrustMyRelaysAndVenues = MutableStateFlow(inboxPrefs.relayAuthTrustMyRelays),
relayAuthTrustReadFollows = MutableStateFlow(inboxPrefs.relayAuthTrustReadFollows),
relayAuthTrustMessageFollows = MutableStateFlow(inboxPrefs.relayAuthTrustMessageFollows),
relayAuthTrustMessageStrangers = MutableStateFlow(inboxPrefs.relayAuthTrustMessageStrangers),
defaultRelayAuthPolicy = MutableStateFlow(defaultRelayAuthPolicy),
splitNotificationsEnabled = MutableStateFlow(splitNotificationsEnabled),
showMessagesInNotifications = MutableStateFlow(showMessagesInNotifications),
backupUserMetadata = latestUserMetadataResolved,
@@ -1003,30 +852,20 @@ object LocalPreferences {
backupHashtagList = latestHashtagListResolved,
backupGeohashList = latestGeohashListResolved,
backupEphemeralChatList = latestEphemeralListResolved,
backupRelayGroupList = latestRelayGroupListResolved,
backupConcordList = latestConcordListResolved,
backupTrustProviderList = latestTrustProviderListResolved,
backupKeyPackageRelayList = latestKeyPackageRelayListResolved,
backupFavoriteAlgoFeedsList = latestFavoriteAlgoFeedsListResolved,
lastReadPerRoute = MutableStateFlow(lastReadPerRouteResolved),
hasDonatedInVersion = MutableStateFlow(hasDonatedInVersion),
dismissedPollNoteIds = MutableStateFlow(dismissedPollNoteIds),
dismissedChannelInvites = MutableStateFlow(dismissedChannelInvites),
viewedPollResultNoteIds = MutableStateFlow(viewedPollResultNoteIdsResolved),
pendingAttestations = MutableStateFlow(pendingAttestationsResolved),
backupNipA3PaymentTargets = latestPaymentTargetsResolved,
backupBolt12Offers = latestBolt12OffersResolved,
backupCashuWallet = latestCashuWalletResolved,
backupNutzapInfo = latestNutzapInfoResolved,
callsEnabled = MutableStateFlow(callsEnabled),
)
}
}
// Milestone with its cost attached. Decrypting and parsing one account's settings is one of
// the most expensive things a cold start does (it resolves a fan of backup events), it runs
// once per account, and "which account was slow" is the first question when a boot drags.
// The six intermediate steps above stay at DEBUG.
Log.i("LocalPreferences") { "Loaded account $npub in ${TimeUtils.nowMillis() - startedAtMs}ms" }
Log.d("LocalPreferences") { "Loaded account from file $npub" }
return result
}
@@ -1051,12 +890,10 @@ object LocalPreferences {
val discovery: TopFilter,
val polls: TopFilter,
val pictures: TopFilter,
val relayGroupsDiscovery: TopFilter,
val napplets: TopFilter,
val nsites: TopFilter,
val workouts: TopFilter,
val gitRepositories: TopFilter,
val highlights: TopFilter,
val calendars: TopFilter,
val products: TopFilter,
val shorts: TopFilter,
@@ -1109,12 +946,10 @@ object LocalPreferences {
discovery = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, null), TopFilter.Global),
polls = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_POLLS_FOLLOW_LIST, null), TopFilter.Global),
pictures = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_PICTURES_FOLLOW_LIST, null), TopFilter.Global),
relayGroupsDiscovery = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_RELAY_GROUPS_DISCOVERY_FOLLOW_LIST, null), TopFilter.Mine),
napplets = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_NAPPLETS_FOLLOW_LIST, null), TopFilter.Global),
nsites = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_NSITES_FOLLOW_LIST, null), TopFilter.Global),
workouts = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_WORKOUTS_FOLLOW_LIST, null), TopFilter.Global),
gitRepositories = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_GIT_REPOSITORIES_FOLLOW_LIST, null), TopFilter.Global),
highlights = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_HIGHLIGHTS_FOLLOW_LIST, null), TopFilter.Global),
calendars = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_CALENDARS_FOLLOW_LIST, null), TopFilter.Global),
products = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_PRODUCTS_FOLLOW_LIST, null), TopFilter.AroundMe),
shorts = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_SHORTS_FOLLOW_LIST, null), TopFilter.Global),
@@ -1198,36 +1033,3 @@ object LocalPreferences {
}
}
}
/**
* The inbox / relay-auth / feed-type preferences, read as one group. Extracted out of
* [LocalPreferences]' account-load lambda (which is right at the JVM's per-method bytecode limit)
* so these enum/set decodes don't count against that method's budget.
*/
private class InboxPrefs(
val defaultRelayAuthPolicy: RelayAuthPolicy,
val relayGroupViewMode: RelayGroupViewMode,
val concordViewMode: ConcordViewMode,
val enabledChatFeeds: Set<ChatFeedType>,
val enabledHomeFeedTypes: Set<HomeFeedType>,
val relayAuthTrustMyRelays: Boolean,
val relayAuthTrustReadFollows: Boolean,
val relayAuthTrustMessageFollows: Boolean,
val relayAuthTrustMessageStrangers: Boolean,
)
private fun SharedPreferences.readInboxPrefs() =
InboxPrefs(
defaultRelayAuthPolicy =
getString(PrefKeys.DEFAULT_RELAY_AUTH_POLICY, null)
?.let { runCatching { RelayAuthPolicy.valueOf(it) }.getOrNull() }
?: RelayAuthPolicy.CUSTOM,
relayGroupViewMode = RelayGroupViewMode.fromName(getString(PrefKeys.RELAY_GROUP_VIEW_MODE, null)),
concordViewMode = ConcordViewMode.fromName(getString(PrefKeys.CONCORD_VIEW_MODE, null)),
enabledChatFeeds = ChatFeedType.ALL - ChatFeedType.decode(getString(PrefKeys.DISABLED_CHAT_FEEDS, null)),
enabledHomeFeedTypes = HomeFeedType.ALL - HomeFeedType.decode(getString(PrefKeys.DISABLED_HOME_FEED_TYPES, null)),
relayAuthTrustMyRelays = getBoolean(PrefKeys.RELAY_AUTH_TRUST_MY_RELAYS, true),
relayAuthTrustReadFollows = getBoolean(PrefKeys.RELAY_AUTH_TRUST_READ_FOLLOWS, true),
relayAuthTrustMessageFollows = getBoolean(PrefKeys.RELAY_AUTH_TRUST_MESSAGE_FOLLOWS, true),
relayAuthTrustMessageStrangers = getBoolean(PrefKeys.RELAY_AUTH_TRUST_MESSAGE_STRANGERS, false),
)
@@ -1,68 +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.connectedApps.consent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
/**
* The account a signer request acts as — avatar + display name — so it's clear WHICH logged-in
* identity is approving/signing/encrypting/decrypting. Shown in both consent dialogs in place of a
* raw pubkey. Falls back to a robohash avatar seeded on [pubKey] when there's no [picture].
*/
@Composable
fun ConnectedAccountRow(
name: String,
picture: String?,
pubKey: String?,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
RobohashFallbackAsyncImage(
robot = pubKey ?: name,
model = picture,
contentDescription = null,
modifier = Modifier.size(26.dp).clip(CircleShape),
loadProfilePicture = true,
loadRobohash = true,
)
Text(
name,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
@@ -1,591 +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.connectedApps.consent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.connectedApps.signers.SignerOpGrant
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.call.CallSessionBridge
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler
import com.vitorpamplona.quartz.utils.TimeUtils
class SignerConsentActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
AmethystTheme {
// The signer services requests concurrently, so more than one may await consent. We never
// mix accounts in one sheet: render only the requests for the OLDEST-pending account as a
// group. When that account's group clears, the next account's requests render (a fresh
// per-account sheet). One request → the rich dialog; several → a batched list. When the
// whole queue empties, close.
val pending by SignerConsentCoordinator.pending.collectAsStateWithLifecycle()
val group =
run {
val account = pending.firstOrNull()?.info?.accountPubKey
pending.filter { it.info.accountPubKey == account }
}
LaunchedEffect(pending.isEmpty()) { if (pending.isEmpty()) finish() }
when {
group.isEmpty() -> Unit
group.size == 1 -> {
val p = group.first()
SignerConsentDialog(
info = p.info,
onGrant = { SignerConsentCoordinator.complete(p.token, it) },
onDismiss = { SignerConsentCoordinator.complete(p.token, SignerOpGrant.DenyOnce) },
)
}
else ->
BatchedConsentDialog(
pending = group,
onResolve = { tokens, grant -> SignerConsentCoordinator.completeAll(tokens, grant) },
// Dismissing denies only THIS account's group; other accounts' requests stay
// pending and render next as their own sheet.
onDismiss = { SignerConsentCoordinator.completeAll(group.map { it.token }, SignerOpGrant.DenyOnce) },
)
}
}
}
}
// Dismissal is failed-closed at the source: each dialog's onDismissRequest (back / tap-outside)
// denies its own request(s). We deliberately do NOT deny-all in onDestroy — a request arriving as
// this Activity finishes is owned by a freshly-launched instance, and denying it here would race
// that instance and reject a legitimate request. A process kill falls back to the bridge's 120s
// timeout, which also fails closed.
}
@Composable
private fun SignerConsentDialog(
info: SignerConsentInfo,
onGrant: (SignerOpGrant) -> Unit,
onDismiss: () -> Unit,
) {
var showMoreOptions by remember { mutableStateOf(false) }
val scrollState = rememberScrollState()
val maxHeight = LocalConfiguration.current.screenHeightDp.dp * 0.85f
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Surface(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.heightIn(max = maxHeight),
shape = MaterialTheme.shapes.extraLarge,
color = MaterialTheme.colorScheme.surface,
tonalElevation = 6.dp,
) {
Column(
modifier =
Modifier
.verticalScroll(scrollState)
.padding(vertical = 24.dp),
) {
// Centered header: icon + title + description
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
val isBrowser = info.coordinate.startsWith("browser:")
FavoriteAppIcon(
app =
if (isBrowser) {
FavoriteApp.WebApp(info.coordinate.substringAfter(':'), info.appletTitle, 0L, info.iconUrl)
} else {
FavoriteApp.NostrApp(info.coordinate, info.appletTitle, 0L, info.iconUrl)
},
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(56.dp),
)
Text(
info.appletTitle,
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center,
)
Text(
stringResource(R.string.napplet_consent_wants_to, info.operationSummary),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
// Show WHICH account would sign/encrypt/decrypt (avatar + name), not the coordinate hex.
if (info.accountName != null) {
ConnectedAccountRow(info.accountName, info.accountPicture, info.accountPubKey)
}
// For a decrypt request, WHOSE conversation is being read is the decision. Show
// that person as an avatar + name, never as nothing.
if (info.counterpartyName != null) {
Text(
stringResource(R.string.nip46_signer_messages_with),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
ConnectedAccountRow(info.counterpartyName, info.counterpartyPicture, info.counterpartyPubKey)
}
}
Spacer(Modifier.height(12.dp))
Box(modifier = Modifier.padding(horizontal = 24.dp)) {
SignerConsentPreview(info)
}
Spacer(Modifier.height(16.dp))
HorizontalDivider()
Spacer(Modifier.height(8.dp))
// Primary: the NARROWEST "remember" available. For decrypt that is "always allow for
// Alice" — one broad decrypt grant would otherwise hand over every conversation
// forever, and scoping the op itself would mean a prompt per conversation.
val narrowOp = info.narrowOp
if (narrowOp != null && info.narrowOpLabel != null) {
Button(
onClick = { onGrant(SignerOpGrant.AllowForOp(narrowOp)) },
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
) {
Text(info.narrowOpLabel)
}
// The broad grant stays available, but demoted below the scoped one.
OutlinedButton(
onClick = { onGrant(SignerOpGrant.AllowForOp(info.op)) },
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
) {
Text(stringResource(R.string.napplet_consent_allow_always))
}
} else {
Button(
onClick = { onGrant(SignerOpGrant.AllowForOp(info.op)) },
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
) {
Text(stringResource(R.string.napplet_consent_allow_always))
}
}
// Secondary: allow just once
OutlinedButton(
onClick = { onGrant(SignerOpGrant.AllowOnce) },
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
) {
Text(stringResource(R.string.napplet_signer_allow_once))
}
// "More options" toggle: session and time-bound grants
TextButton(
onClick = { showMoreOptions = !showMoreOptions },
modifier = Modifier.fillMaxWidth(),
contentPadding = PaddingValues(horizontal = 24.dp, vertical = 8.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
if (showMoreOptions) {
stringResource(R.string.napplet_consent_fewer_options)
} else {
stringResource(R.string.napplet_consent_more_options)
},
style = MaterialTheme.typography.bodyMedium,
)
Icon(
if (showMoreOptions) MaterialSymbols.ExpandLess else MaterialSymbols.ExpandMore,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
}
}
if (showMoreOptions) {
OutlinedButton(
onClick = { onGrant(SignerOpGrant.AllowForSession(info.op)) },
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
) {
Text(stringResource(R.string.napplet_signer_allow_session))
}
OutlinedButton(
onClick = { onGrant(SignerOpGrant.AllowUntil(info.op, TimeUtils.now() + 86_400L)) },
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
) {
Text(stringResource(R.string.napplet_signer_allow_24h))
}
OutlinedButton(
onClick = { onGrant(SignerOpGrant.AllowUntil(info.op, TimeUtils.now() + 30L * 86_400L)) },
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
) {
Text(stringResource(R.string.napplet_signer_allow_30d))
}
OutlinedButton(
onClick = { onGrant(SignerOpGrant.AllowAll) },
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
) {
Text(stringResource(R.string.napplet_signer_allow_all))
}
}
Spacer(Modifier.height(4.dp))
HorizontalDivider()
Spacer(Modifier.height(8.dp))
OutlinedButton(
onClick = { onGrant(SignerOpGrant.DenyOnce) },
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
) {
Text(stringResource(R.string.napplet_signer_deny_once))
}
OutlinedButton(
onClick = { onGrant(SignerOpGrant.DenyForOp(info.op)) },
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
) {
Text(stringResource(R.string.napplet_signer_deny_op, info.operationSummary))
}
}
}
}
}
/**
* The "what you're acting on" block: the unsigned event rendered as a real NoteCompose (what it will
* look like once signed) with a JSON toggle for sign/publish, or the raw content / decrypted plaintext
* for encrypt/decrypt. Shared by the single-request dialog and each expanded batch row so a user can
* always inspect exactly what they are signing/encrypting/decrypting. Best-effort: if the main Activity
* is gone (only the foreground signer service alive) the NoteCompose is skipped and the JSON stands in.
*/
@Composable
private fun SignerConsentPreview(info: SignerConsentInfo) {
var showRawData by remember(info) { mutableStateOf(false) }
val accountViewModel = remember { CallSessionBridge.accountViewModel }
val previewNav = remember { EmptyNav() }
val previewNote =
remember(info, accountViewModel) {
val template = info.previewTemplate
val author = info.accountPubKey ?: accountViewModel?.account?.signer?.pubKey
if (template != null && author != null && accountViewModel != null) {
runCatching {
val unsigned = RumorAssembler.assembleRumor<Event>(author, template)
accountViewModel.createTempDraftNote(unsigned, LocalCache.getOrCreateUser(author))
}.getOrNull()
} else {
null
}
}
val hasContent = previewNote != null || info.contentPreview.isNotBlank() || info.rawData.isNotBlank()
if (!hasContent) return
Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surfaceVariant,
shape = MaterialTheme.shapes.medium,
) {
Column(modifier = Modifier.padding(12.dp)) {
if (previewNote != null && accountViewModel != null) {
NoteCompose(
baseNote = previewNote,
isQuotedNote = true,
quotesLeft = 0,
accountViewModel = accountViewModel,
nav = previewNav,
)
} else if (info.contentPreview.isNotBlank()) {
Text("${info.contentPreview}", style = MaterialTheme.typography.bodySmall)
}
if (info.rawData.isNotBlank()) {
if (showRawData) {
Spacer(Modifier.height(8.dp))
Box(modifier = Modifier.horizontalScroll(rememberScrollState())) {
SelectionContainer {
Text(
info.rawData,
style = MaterialTheme.typography.labelSmall.copy(fontFamily = FontFamily.Monospace),
color = MaterialTheme.colorScheme.onSurfaceVariant,
softWrap = false,
)
}
}
}
TextButton(
onClick = { showRawData = !showRawData },
contentPadding = PaddingValues(horizontal = 4.dp, vertical = 0.dp),
) {
Text(
if (showRawData) {
stringResource(R.string.napplet_consent_hide_event)
} else {
stringResource(R.string.napplet_consent_show_event)
},
style = MaterialTheme.typography.labelSmall,
)
}
}
}
}
}
/**
* Shown when more than one request is awaiting consent at once (the signer services requests
* concurrently). Lists each with a checkbox — all selected by default — and resolves the selected
* ones together as Allow or Deny. "Remember" makes an Allow persist per-op ([SignerOpGrant.AllowForOp]);
* off is a one-time [SignerOpGrant.AllowOnce]. Requests left unselected stay pending and re-render
* (as this list, or the single-request dialog once one remains).
*/
@Composable
private fun BatchedConsentDialog(
pending: List<PendingConsent>,
onResolve: (tokens: List<String>, grant: SignerOpGrant) -> Unit,
onDismiss: () -> Unit,
) {
val maxHeight = LocalConfiguration.current.screenHeightDp.dp * 0.85f
val tokens = pending.map { it.token }.toSet()
// Seed all-selected ONCE for the initial batch the user opened. The signer services requests
// concurrently, so `tokens` can change under an open sheet; reconcile incrementally instead of
// re-seeding — drop resolved tokens but KEEP the user's deselections, and never auto-select a
// newly-arrived request. Otherwise a request landing (or resolving) mid-decision would silently
// re-check everything, and an "Allow selected" tap would grant ops the user deselected or never saw.
var selected by remember { mutableStateOf(tokens) }
LaunchedEffect(tokens) { selected = selected intersect tokens }
var rememberChoice by remember { mutableStateOf(false) }
// Tokens whose full preview (rendered event + JSON, or encrypt/decrypt plaintext) is expanded.
var expanded by remember { mutableStateOf(emptySet<String>()) }
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Surface(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.heightIn(max = maxHeight),
shape = MaterialTheme.shapes.extraLarge,
color = MaterialTheme.colorScheme.surface,
tonalElevation = 6.dp,
) {
Column(modifier = Modifier.padding(vertical = 20.dp)) {
// The sheet is single-account (grouped upstream), so the account is a header, not a
// per-row label. It says WHO every request in this sheet would act as.
val account = pending.first().info
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
account.accountName?.let { name ->
RobohashFallbackAsyncImage(
robot = account.accountPubKey ?: name,
model = account.accountPicture,
contentDescription = null,
modifier = Modifier.size(34.dp).clip(CircleShape),
loadProfilePicture = true,
loadRobohash = true,
)
}
Column(modifier = Modifier.weight(1f)) {
Text(
pluralStringResource(R.plurals.nip46_signer_batch_title, pending.size, pending.size),
style = MaterialTheme.typography.titleLarge,
)
account.accountName?.let { name ->
Text(
stringResource(R.string.nip46_signer_batch_signing_as, name),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
TextButton(
onClick = {
selected = if (selected.size == pending.size) emptySet() else pending.map { it.token }.toSet()
},
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 2.dp),
) {
Text(
stringResource(
if (selected.size == pending.size) R.string.nip46_signer_batch_select_none else R.string.nip46_signer_batch_select_all,
),
style = MaterialTheme.typography.labelLarge,
)
}
Column(
modifier =
Modifier
.weight(1f, fill = false)
.verticalScroll(rememberScrollState()),
) {
pending.forEach { p ->
val isExpanded = p.token in expanded
Column(modifier = Modifier.fillMaxWidth()) {
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable {
expanded = if (isExpanded) expanded - p.token else expanded + p.token
}.padding(horizontal = 12.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
// Checkbox handles its own tap (select); tapping elsewhere on the row expands.
Checkbox(
checked = p.token in selected,
onCheckedChange = { on -> selected = if (on) selected + p.token else selected - p.token },
)
Column(modifier = Modifier.weight(1f)) {
Text(
"${p.info.appletTitle} · ${p.info.operationSummary}",
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
)
if (p.info.contentPreview.isNotBlank()) {
Text(
p.info.contentPreview,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
)
}
}
Icon(
if (isExpanded) MaterialSymbols.ExpandLess else MaterialSymbols.ExpandMore,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
}
if (isExpanded) {
Box(modifier = Modifier.padding(start = 12.dp, end = 12.dp, bottom = 8.dp)) {
SignerConsentPreview(p.info)
}
}
}
}
}
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Switch(checked = rememberChoice, onCheckedChange = { rememberChoice = it })
Text(
stringResource(R.string.nip46_signer_batch_remember),
style = MaterialTheme.typography.bodyMedium,
)
}
Spacer(Modifier.height(8.dp))
HorizontalDivider()
Spacer(Modifier.height(8.dp))
Button(
onClick = {
val tokens = pending.filter { it.token in selected }
// Per-op remember uses each request's own op; one-time is a single AllowOnce.
if (rememberChoice) {
tokens.forEach { onResolve(listOf(it.token), SignerOpGrant.AllowForOp(it.info.op)) }
} else {
onResolve(tokens.map { it.token }, SignerOpGrant.AllowOnce)
}
},
enabled = selected.isNotEmpty(),
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
) {
Text(stringResource(R.string.nip46_signer_batch_allow, selected.size))
}
OutlinedButton(
onClick = { onResolve(pending.filter { it.token in selected }.map { it.token }, SignerOpGrant.DenyOnce) },
enabled = selected.isNotEmpty(),
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
) {
Text(stringResource(R.string.nip46_signer_batch_deny, selected.size))
}
}
}
}
}
@@ -1,175 +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.connectedApps.consent
import android.content.Context
import android.content.Intent
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp
import com.vitorpamplona.amethyst.commons.connectedApps.signers.SignerOpGrant
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.flow.updateAndGet
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
/** Everything the per-operation consent dialog needs to render. */
data class SignerConsentInfo(
val appletTitle: String,
val coordinate: String,
val op: NostrSignerOp,
val operationSummary: String,
/** Short excerpt shown in the dialog body (≤ 160 chars). */
val contentPreview: String,
/**
* Full raw content for the "See more" toggle — event JSON for sign/encrypt operations,
* decrypted plaintext for decrypt (Amethyst decrypts first, then asks permission to expose).
*/
val rawData: String = "",
val iconUrl: String? = null,
/**
* The account that would sign/encrypt/decrypt, shown as an avatar + name so it's clear which
* logged-in identity is acting. Null on paths that don't resolve it; [accountPubKey] seeds the
* robohash avatar fallback when there's no picture.
*/
val accountName: String? = null,
val accountPicture: String? = null,
val accountPubKey: String? = null,
/**
* The unsigned event a `sign_event`/publish request would sign, so the dialog can render it as a
* note preview (what it will look like) in addition to the raw JSON. Null for encrypt/decrypt and
* non-event ops.
*/
val previewTemplate: EventTemplate<Event>? = null,
/**
* The OTHER party of a decrypt request — whose conversation the app is asking to read — shown as
* an avatar + name. "X wants to read your messages with Alice" is a categorically different
* decision from "X wants to read your private messages", so this must reach the dialog.
* Null for every op that has no counterparty (signing, and the napplet/browser paths).
*/
val counterpartyName: String? = null,
val counterpartyPicture: String? = null,
val counterpartyPubKey: String? = null,
/**
* A NARROWER op the dialog may offer to remember instead of [op] — today only
* [com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp.DecryptFrom], i.e.
* "always allow, but only for this counterparty". Offered ALONGSIDE the broad "Always allow" so
* the user gets granularity without a prompt per conversation. [narrowOpLabel] is its button text.
*/
val narrowOp: NostrSignerOp? = null,
val narrowOpLabel: String? = null,
)
/** One pending per-operation consent request, as the batched sheet renders it. */
data class PendingConsent(
val token: String,
val info: SignerConsentInfo,
)
/**
* Bridges the broker to the per-operation signer consent UI. The signer services requests
* concurrently (so their prompts can batch), so several requests can await consent at once: they all
* land in [pending], one [SignerConsentActivity] observes that list and shows a single-request dialog
* or a batched list, and each resolved token completes its own deferred. A dismissed/ignored request
* resolves to [SignerOpGrant.DenyOnce] — fails closed.
*/
object SignerConsentCoordinator {
private val deferreds = ConcurrentHashMap<String, CompletableDeferred<SignerOpGrant>>()
private val _pending = MutableStateFlow<List<PendingConsent>>(emptyList())
/** The live set of requests awaiting the user's decision; the Activity renders this. */
val pending: StateFlow<List<PendingConsent>> = _pending
// A stable notification id (one prompt notification for the whole batch, updated as requests
// arrive) so concurrent requests don't each post their own.
private val batchNotificationId = "nip46-signer-consent".hashCode()
// Guards the surface (post/cancel of the one shared notification) against the pending set so a
// concurrent arrival's post can't be clobbered by another request's teardown cancel. Without it,
// request A could read "pending now empty" and then cancel AFTER request B posted a fresh
// notification under the same id, leaving B with no UI while backgrounded (silent deny at timeout).
private val surfaceLock = Mutex()
suspend fun requestConsent(
context: Context,
info: SignerConsentInfo,
): SignerOpGrant {
val token = UUID.randomUUID().toString()
val deferred = CompletableDeferred<SignerOpGrant>()
deferreds[token] = deferred
surfaceLock.withLock {
_pending.update { it + PendingConsent(token, info) }
// Fast path when Amethyst already owns the foreground: open the dialog directly. When the app
// is backgrounded this is silently dropped by Android 12+ BAL, so the full-screen-intent
// notification is what surfaces the prompt. Both are idempotent — the Activity is singleTop and
// observes [pending], and the notification uses a stable id, so concurrent requests just
// refresh the one prompt. Wrapped because a BAL-blocked launch can throw rather than no-op.
runCatching {
context.startActivity(
Intent(context, SignerConsentActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP),
)
}
SignerConsentNotifier.show(
context = context,
activityClass = SignerConsentActivity::class.java,
extraKey = EXTRA_TOKEN,
token = "nip46-signer-consent",
titleRes = R.string.nip46_signer_notif_sign_title,
)
}
return try {
deferred.await()
} finally {
deferreds.remove(token)
surfaceLock.withLock {
// Remove + emptiness check + cancel are one critical section vs. another request's
// add + show, so a fresh notification is never cancelled out from under a live request.
val stillPending = _pending.updateAndGet { list -> list.filterNot { it.token == token } }
if (stillPending.isEmpty()) SignerConsentNotifier.cancel(context, batchNotificationId)
}
}
}
fun complete(
token: String,
grant: SignerOpGrant,
) {
deferreds[token]?.complete(grant)
}
fun completeAll(
tokens: Collection<String>,
grant: SignerOpGrant,
) {
tokens.forEach { complete(it, grant) }
}
const val EXTRA_TOKEN = "napplet_signer_consent_token"
}
@@ -1,141 +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.connectedApps.consent
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationCompat
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.stringRes
/**
* Surfaces a signer consent/connect [android.app.Activity] from the **background**.
*
* A bare `context.startActivity(...)` from the application context only opens a window while
* Amethyst already owns the foreground. When a signing request arrives over a relay while the app
* is backgrounded, Android 12+ background-activity-launch (BAL) restrictions silently drop that
* `startActivity`, so the dialog would never appear and the request would sit until it times out.
*
* A full-screen-intent notification on an `IMPORTANCE_HIGH` channel (with the
* `USE_FULL_SCREEN_INTENT` permission the manifest declares) is the documented BAL exception — the
* same mechanism [com.vitorpamplona.amethyst.service.call.notification.CallNotifier] uses for
* incoming calls. On a locked/idle screen it launches the Activity immediately; while the user is
* actively on another app it shows as a heads-up banner they tap to review.
*
* Each coordinator posts one notification keyed by the request token's hash so concurrent requests
* don't clobber each other, and cancels it once the deferred resolves (approved, denied, or timed
* out) so no stale prompt lingers.
*/
object SignerConsentNotifier {
private const val CHANNEL_ID = "com.vitorpamplona.amethyst.SIGNER_CONSENT_CHANNEL"
private fun ensureChannel(context: Context): NotificationChannel {
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
manager.getNotificationChannel(CHANNEL_ID)?.let { return it }
val channel =
NotificationChannel(
CHANNEL_ID,
stringRes(context, R.string.nip46_signer_notif_channel_name),
NotificationManager.IMPORTANCE_HIGH,
).apply {
description = stringRes(context, R.string.nip46_signer_notif_channel_desc)
}
manager.createNotificationChannel(channel)
return channel
}
/**
* Posts a full-screen-intent notification whose content/full-screen [PendingIntent] opens
* [activityClass] carrying [token]. Returns the notification id to pass to [cancel] once the
* request resolves.
*/
fun show(
context: Context,
activityClass: Class<*>,
extraKey: String,
token: String,
titleRes: Int,
): Int {
// When Amethyst already owns the foreground the direct startActivity opens the dialog, so a
// heads-up notification would just be redundant noise on top of it. Only fall back to the
// full-screen intent when we're backgrounded — the case where startActivity is BAL-blocked.
if (appInForeground()) return NO_NOTIFICATION
val channel = ensureChannel(context)
val notificationId = token.hashCode()
val intent =
Intent(context, activityClass)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
.putExtra(extraKey, token)
val pendingIntent =
PendingIntent.getActivity(
context,
notificationId,
intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
val notification =
NotificationCompat
.Builder(context, channel.id)
.setSmallIcon(R.drawable.amethyst)
.setContentTitle(stringRes(context, titleRes))
.setContentText(stringRes(context, R.string.nip46_signer_notif_tap))
.setContentIntent(pendingIntent)
.setFullScreenIntent(pendingIntent, true)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setCategory(NotificationCompat.CATEGORY_RECOMMENDATION)
.setAutoCancel(true)
.setOngoing(true)
.setTimeoutAfter(TIMEOUT_MS)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.build()
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
manager.notify(notificationId, notification)
return notificationId
}
fun cancel(
context: Context,
notificationId: Int,
) {
if (notificationId == NO_NOTIFICATION) return
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
manager.cancel(notificationId)
}
private fun appInForeground(): Boolean =
// Defensive: the signer consent path only runs in the main process (where Amethyst.instance
// is set), but touching it from the keyless :napplet process would throw. Treat any failure
// as "not foreground" so the notification fallback still fires.
runCatching { Amethyst.instance.foregroundTracker.isForeground.value }.getOrDefault(false)
private const val NO_NOTIFICATION = Int.MIN_VALUE
private const val TIMEOUT_MS = 120_000L
}
@@ -1,128 +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.connectedApps.nip46
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46ClientInfo
import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46ClientStore
import kotlinx.coroutines.flow.first
import java.io.File
import java.security.MessageDigest
import java.util.concurrent.ConcurrentHashMap
/**
* Single-file DataStore-backed [Nip46ClientStore]. Every connected client's
* display + relay info lives in one `datastore/nip46_clients.preferences_pb`
* file; a SHA-256 prefix of the coordinate is the key so the (already public)
* coordinate is kept alongside for [all]'s reverse lookup. Fields are stored
* individually so no serialization library is needed; [relays] is newline-joined.
*/
class DataStoreNip46ClientStore(
private val filesDir: File,
) : Nip46ClientStore {
constructor(context: Context) : this(context.applicationContext.filesDir)
private val store: DataStore<Preferences> get() = dataStoreFor(File(filesDir, "datastore/nip46_clients.preferences_pb"))
override suspend fun load(coordinate: String): Nip46ClientInfo? {
val prefs = store.data.first()
if (prefs[coordKey(coordinate)] == null) return null
return Nip46ClientInfo(
name = prefs[nameKey(coordinate)],
url = prefs[urlKey(coordinate)],
image = prefs[imageKey(coordinate)],
relays = prefs[relaysKey(coordinate)].toRelaySet(),
)
}
override suspend fun store(
coordinate: String,
info: Nip46ClientInfo,
) {
store.edit { prefs ->
prefs[coordKey(coordinate)] = coordinate
info.name?.let { prefs[nameKey(coordinate)] = it } ?: prefs.remove(nameKey(coordinate))
info.url?.let { prefs[urlKey(coordinate)] = it } ?: prefs.remove(urlKey(coordinate))
info.image?.let { prefs[imageKey(coordinate)] = it } ?: prefs.remove(imageKey(coordinate))
if (info.relays.isNotEmpty()) prefs[relaysKey(coordinate)] = info.relays.joinToString("\n") else prefs.remove(relaysKey(coordinate))
}
}
override suspend fun remove(coordinate: String) {
store.edit { prefs ->
prefs.remove(coordKey(coordinate))
prefs.remove(nameKey(coordinate))
prefs.remove(urlKey(coordinate))
prefs.remove(imageKey(coordinate))
prefs.remove(relaysKey(coordinate))
}
}
override suspend fun all(): Map<String, Nip46ClientInfo> {
val prefs = store.data.first()
val result = mutableMapOf<String, Nip46ClientInfo>()
for ((key, value) in prefs.asMap()) {
if (!key.name.startsWith(COORD_PREFIX)) continue
val coordinate = value as? String ?: continue
result[coordinate] =
Nip46ClientInfo(
name = prefs[nameKey(coordinate)],
url = prefs[urlKey(coordinate)],
image = prefs[imageKey(coordinate)],
relays = prefs[relaysKey(coordinate)].toRelaySet(),
)
}
return result
}
private fun String?.toRelaySet(): Set<String> = this?.split("\n")?.filterTo(mutableSetOf()) { it.isNotEmpty() } ?: emptySet()
private fun coordKey(coordinate: String) = stringPreferencesKey("$COORD_PREFIX${hash(coordinate)}")
private fun nameKey(coordinate: String) = stringPreferencesKey("name:${hash(coordinate)}")
private fun urlKey(coordinate: String) = stringPreferencesKey("url:${hash(coordinate)}")
private fun imageKey(coordinate: String) = stringPreferencesKey("img:${hash(coordinate)}")
private fun relaysKey(coordinate: String) = stringPreferencesKey("relays:${hash(coordinate)}")
companion object {
private val stores = ConcurrentHashMap<String, DataStore<Preferences>>()
private fun dataStoreFor(file: File): DataStore<Preferences> =
stores.computeIfAbsent(file.absolutePath) {
PreferenceDataStoreFactory.create(produceFile = { file })
}
private const val COORD_PREFIX = "coord:"
private fun hash(coordinate: String): String {
val digest = MessageDigest.getInstance("SHA-256").digest(coordinate.toByteArray())
return digest.take(8).joinToString("") { "%02x".format(it) }
}
}
}
@@ -22,14 +22,10 @@ package com.vitorpamplona.amethyst.favorites
import android.content.Context
import android.util.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
/**
@@ -54,28 +50,12 @@ object BrowserIconRegistry {
@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.
*/
/** Binds the app context and indexes already-stored icons. Idempotent. */
fun init(context: Context) {
if (iconDir != null) return
val dir = File(context.applicationContext.filesDir, DIR)
val dir = File(context.applicationContext.filesDir, DIR).apply { mkdirs() }
iconDir = dir
io.launch {
dir.mkdirs()
_keys.value = dir.listFiles()?.mapNotNull { it.name.removeSuffix(PNG).takeIf { n -> n.isNotBlank() } }?.toSet() ?: emptySet()
}
_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. */
@@ -86,17 +66,11 @@ object BrowserIconRegistry {
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)
}
try {
File(dir, key + PNG).writeBytes(bytes)
_keys.update { it + key }
} catch (e: Exception) {
Log.w("BrowserIconRegistry", "Failed to store favicon for $host", e)
}
}
@@ -32,7 +32,6 @@ import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.ThemeType
import com.vitorpamplona.amethyst.napplet.NappletLauncher
import com.vitorpamplona.amethyst.napplet.NappletWebViewProfiles
import com.vitorpamplona.amethyst.napplet.WebAppNetworkRegistry
import com.vitorpamplona.amethyst.napplethost.HostProfile
import com.vitorpamplona.amethyst.napplethost.NappletBrowserActivity
@@ -93,20 +92,9 @@ object FavoriteAppLauncher {
}
val isFavorite = FavoriteAppsRegistry.isFavorite("url:$url")
val intent =
NappletBrowserActivity
.intent(
context,
url,
proxyPort,
useTor,
theme = theme,
isFavorite = isFavorite,
// Opaque per-account storage partition, so a web app can't carry one npub's session
// into another. Derived here (the sandbox never sees the pubkey).
webViewProfile = NappletWebViewProfiles.current(),
).apply {
if (context !is Activity) addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
}
NappletBrowserActivity.intent(context, url, proxyPort, useTor, theme = theme, isFavorite = isFavorite).apply {
if (context !is Activity) addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
}
File diff suppressed because it is too large Load Diff
@@ -22,25 +22,17 @@ package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle
import com.vitorpamplona.amethyst.commons.model.chats.ChatFeedType
import com.vitorpamplona.amethyst.commons.model.clink.ClinkDebitWalletEntryNorm
import com.vitorpamplona.amethyst.commons.model.concord.ConcordListRepository
import com.vitorpamplona.amethyst.commons.model.concord.ConcordViewMode
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatRepository
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatListRepository
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupRepository
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupViewMode
import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntryNorm
import com.vitorpamplona.amethyst.commons.model.payments.PaymentSource
import com.vitorpamplona.amethyst.commons.model.payments.PaymentSourceResolver
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy
import com.vitorpamplona.amethyst.commons.service.pow.PoWCategory
import com.vitorpamplona.amethyst.model.nip60Cashu.CashuPreferences
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry
import com.vitorpamplona.amethyst.ui.screen.FeedDefinition
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
@@ -66,7 +58,6 @@ import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.SimpleGroupListEvent
import com.vitorpamplona.quartz.nip55AndroidSigner.api.CommandType
import com.vitorpamplona.quartz.nip55AndroidSigner.api.permission.Permission
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
@@ -76,7 +67,6 @@ import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.MutableStateFlow
@@ -129,14 +119,6 @@ sealed class TopFilter(
@Serializable
object AroundMe : TopFilter(" Around Me ")
/**
* Not a real selection: a sentinel for the "Teleport" chip in the top-nav filter.
* The spinner intercepts it to open the map picker and then applies the chosen
* [Geohash] instead — it is never persisted or dispatched to a feed flow.
*/
@Serializable
object TeleportPicker : TopFilter(" Teleport ")
@Serializable
object Mine : TopFilter(" Mine ")
@@ -196,45 +178,6 @@ class AccountSettings(
var stripLocationOnUpload: Boolean = true,
val useLocalBlossomCache: MutableStateFlow<Boolean> = MutableStateFlow(true),
val localBlossomCacheProfilePicturesOnly: MutableStateFlow<Boolean> = MutableStateFlow(false),
/**
* BUD-04: after uploading a blob to the primary Blossom server, replicate it to
* the user's other configured servers (kind 10063) for redundancy.
*/
val mirrorUploadsToAllServers: MutableStateFlow<Boolean> = MutableStateFlow(true),
/**
* BUD-05: upload media through the server's `/media` endpoint so the server may
* strip metadata and optimize it, instead of the bit-exact `/upload`.
*/
val optimizeMediaOnUpload: MutableStateFlow<Boolean> = MutableStateFlow(false),
/**
* NIP-46: when true, this account acts as a remote signer (a "bunker") for
* other apps, listening on the user's inbox relays for kind:24133 requests.
* See [com.vitorpamplona.amethyst.model.nip46Signer.Nip46SignerState].
*/
val nip46SignerEnabled: MutableStateFlow<Boolean> = MutableStateFlow(false),
/**
* The active pairing secret advertised in this account's `bunker://` URI. An
* app that connects with this secret is accepted and registered as a
* connected app; regenerating it revokes the ability of not-yet-connected
* apps to pair with an old string.
*/
val nip46BunkerSecret: MutableStateFlow<String> = MutableStateFlow(""),
/**
* A dedicated per-account transport keypair (hex private key) for the NIP-46
* bunker. The kind-24133 envelope is wrapped with THIS key, not the account's
* identity key, so the bunker address and on-relay traffic don't reveal which
* user the bunker belongs to (the identity is disclosed only to a connected
* app via `get_public_key`). Generated once and kept stable so the advertised
* `bunker://` address doesn't change.
*/
val nip46TransportKey: MutableStateFlow<String> = MutableStateFlow(""),
/**
* The kind-24133 **event ids** this signer recently serviced. Persisted so that a relay replaying
* stored ephemeral requests across an app restart doesn't make it sign the same request twice —
* matched by exact event id, so it is immune to client clock skew (unlike a timestamp watermark,
* a global timestamp would wrongly drop a second app whose clock lags). Bounded to a recent window.
*/
val nip46SeenRequestIds: MutableStateFlow<Set<String>> = MutableStateFlow(emptySet()),
/**
* NIP-9B opt-in: when true, community feeds drop events whose latest cached
* `kind:34551` rules document fails [com.vitorpamplona.quartz.nip72ModCommunities.rules.CommunityRulesValidator].
@@ -251,7 +194,6 @@ class AccountSettings(
val defaultNsitesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultWorkoutsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultGitRepositoriesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultHighlightsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultCalendarsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultProductsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.AroundMe),
val defaultShortsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
@@ -270,7 +212,6 @@ class AccountSettings(
val defaultCommunitiesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.AllFollows),
val defaultFollowPacksFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultAppRecommendationsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultRelayGroupsDiscoveryFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Mine),
val nwcWallets: MutableStateFlow<List<NwcWalletEntryNorm>> = MutableStateFlow(emptyList()),
val clinkDebitWallets: MutableStateFlow<List<ClinkDebitWalletEntryNorm>> = MutableStateFlow(emptyList()),
// The unified default spend rail (an NWC wallet OR a CLINK debit). Persisted under a
@@ -301,8 +242,6 @@ class AccountSettings(
var backupFavoriteAlgoFeedsList: FavoriteAlgoFeedsListEvent? = null,
var backupGeohashList: GeohashListEvent? = null,
var backupEphemeralChatList: EphemeralChatListEvent? = null,
var backupRelayGroupList: SimpleGroupListEvent? = null,
var backupConcordList: ConcordCommunityListEvent? = null,
var backupTrustProviderList: TrustProviderListEvent? = null,
var backupCashuWallet: CashuWalletEvent? = null,
var backupNutzapInfo: NutzapInfoEvent? = null,
@@ -322,37 +261,15 @@ class AccountSettings(
val lastReadPerRoute: MutableStateFlow<Map<String, MutableStateFlow<Long>>> = MutableStateFlow(mapOf()),
val hasDonatedInVersion: MutableStateFlow<Set<String>> = MutableStateFlow(setOf()),
val dismissedPollNoteIds: MutableStateFlow<Set<String>> = MutableStateFlow(setOf()),
/**
* Channel ids the viewer chose NOT to show on Messages after somebody added them to the channel
* (kind-44100). Local-only: it records a display preference, not membership — the relay roster
* still lists you, and Leave (kind 9022) is the separate action that actually removes you.
*/
val dismissedChannelInvites: MutableStateFlow<Set<String>> = MutableStateFlow(setOf()),
val viewedPollResultNoteIds: MutableStateFlow<Map<String, Long>> = MutableStateFlow(mapOf()),
val pendingAttestations: MutableStateFlow<Map<HexKey, String>> = MutableStateFlow(mapOf()),
var backupNipA3PaymentTargets: PaymentTargetsEvent? = null,
var backupBolt12Offers: Bolt12OfferListEvent? = null,
var callTurnServers: List<CallTurnServer> = emptyList(),
var callVideoResolution: CallVideoResolution = CallVideoResolution.HD_720,
var callMaxBitrateBps: Int = 1_500_000,
val callsEnabled: MutableStateFlow<Boolean> = MutableStateFlow(true),
val defaultRelayAuthPolicy: MutableStateFlow<RelayAuthPolicy> = MutableStateFlow(RelayAuthPolicy.CUSTOM),
val relayGroupViewMode: MutableStateFlow<RelayGroupViewMode> = MutableStateFlow(RelayGroupViewMode.DEFAULT),
val concordViewMode: MutableStateFlow<ConcordViewMode> = MutableStateFlow(ConcordViewMode.DEFAULT),
// Which conversation protocols the Messages inbox loads and shows. A disabled type is both hidden
// from the inbox and dropped from the always-on downloading routes. Defaults to everything on.
val enabledChatFeeds: MutableStateFlow<Set<ChatFeedType>> = MutableStateFlow(ChatFeedType.ALL),
// Which event-kind groups the Home feed downloads (assembler) and renders (DAL). A disabled group
// is both dropped from the always-on home relay filters and hidden from the tabs. Everything on by default.
val enabledHomeFeedTypes: MutableStateFlow<Set<HomeFeedType>> = MutableStateFlow(HomeFeedType.ALL),
// The per-situation toggles applied under RelayAuthPolicy.CUSTOM.
val relayAuthTrustMyRelaysAndVenues: MutableStateFlow<Boolean> = MutableStateFlow(true),
val relayAuthTrustReadFollows: MutableStateFlow<Boolean> = MutableStateFlow(true),
val relayAuthTrustMessageFollows: MutableStateFlow<Boolean> = MutableStateFlow(true),
val relayAuthTrustMessageStrangers: MutableStateFlow<Boolean> = MutableStateFlow(false),
val defaultRelayAuthPolicy: MutableStateFlow<RelayAuthPolicy> = MutableStateFlow(RelayAuthPolicy.IF_IN_MY_LIST),
) : EphemeralChatRepository,
RelayGroupRepository,
ConcordListRepository,
PublicChatListRepository {
val saveable = MutableStateFlow(AccountSettingsUpdater(null))
val syncedSettings: AccountSyncedSettings = AccountSyncedSettings(AccountSyncedSettingsInternal())
@@ -367,48 +284,6 @@ class AccountSettings(
fun isWriteable(): Boolean = keyPair.privKey != null || externalSignerPackageName != null
fun updateRelayGroupViewMode(mode: RelayGroupViewMode) {
if (relayGroupViewMode.value != mode) {
relayGroupViewMode.tryEmit(mode)
saveAccountSettings()
}
}
fun updateConcordViewMode(mode: ConcordViewMode) {
if (concordViewMode.value != mode) {
concordViewMode.tryEmit(mode)
saveAccountSettings()
}
}
fun isChatFeedEnabled(type: ChatFeedType): Boolean = type in enabledChatFeeds.value
fun setChatFeedEnabled(
type: ChatFeedType,
enabled: Boolean,
) {
val current = enabledChatFeeds.value
val next = if (enabled) current + type else current - type
if (next != current) {
enabledChatFeeds.tryEmit(next)
saveAccountSettings()
}
}
fun isHomeFeedTypeEnabled(type: HomeFeedType): Boolean = type in enabledHomeFeedTypes.value
fun setHomeFeedTypeEnabled(
type: HomeFeedType,
enabled: Boolean,
) {
val current = enabledHomeFeedTypes.value
val next = if (enabled) current + type else current - type
if (next != current) {
enabledHomeFeedTypes.tryEmit(next)
saveAccountSettings()
}
}
// ---
// Always-on Notification Service
// ---
@@ -492,15 +367,6 @@ class AccountSettings(
return false
}
fun changeBottomBarItems(newItems: List<BottomBarEntry>): Boolean {
if (syncedSettings.navigation.bottomBarItems.value != newItems) {
syncedSettings.navigation.bottomBarItems.tryEmit(newItems)
saveAccountSettings()
return true
}
return false
}
/** The selected default spend rail across both NWC wallets and CLINK debits. */
fun defaultPaymentSource(): PaymentSource? = PaymentSourceResolver.resolveDefault(nwcWallets.value, clinkDebitWallets.value, defaultPaymentSourceId.value)
@@ -683,35 +549,6 @@ class AccountSettings(
}
}
fun changeNip46SignerEnabled(enabled: Boolean) {
if (nip46SignerEnabled.value != enabled) {
nip46SignerEnabled.tryEmit(enabled)
saveAccountSettings()
}
}
fun changeNip46BunkerSecret(secret: String) {
if (nip46BunkerSecret.value != secret) {
nip46BunkerSecret.tryEmit(secret)
saveAccountSettings()
}
}
fun changeNip46TransportKey(hexPrivKey: String) {
if (nip46TransportKey.value != hexPrivKey) {
nip46TransportKey.tryEmit(hexPrivKey)
saveAccountSettings()
}
}
/** Replaces the recent serviced-request id set (already bounded by the caller). */
fun changeNip46SeenRequestIds(ids: Set<String>) {
if (nip46SeenRequestIds.value != ids) {
nip46SeenRequestIds.tryEmit(ids)
saveAccountSettings()
}
}
fun changeLocalBlossomCacheProfilePicturesOnly(enabled: Boolean) {
if (localBlossomCacheProfilePicturesOnly.value != enabled) {
localBlossomCacheProfilePicturesOnly.tryEmit(enabled)
@@ -719,20 +556,6 @@ class AccountSettings(
}
}
fun changeMirrorUploadsToAllServers(enabled: Boolean) {
if (mirrorUploadsToAllServers.value != enabled) {
mirrorUploadsToAllServers.tryEmit(enabled)
saveAccountSettings()
}
}
fun changeOptimizeMediaOnUpload(enabled: Boolean) {
if (optimizeMediaOnUpload.value != enabled) {
optimizeMediaOnUpload.tryEmit(enabled)
saveAccountSettings()
}
}
fun updateAddClientTag(add: Boolean): Boolean =
if (syncedSettings.security.updateAddClientTag(add)) {
saveAccountSettings()
@@ -741,25 +564,6 @@ class AccountSettings(
false
}
fun updatePowDifficulty(difficulty: Int): Boolean =
if (syncedSettings.proofOfWork.updateDifficulty(difficulty)) {
saveAccountSettings()
true
} else {
false
}
fun updatePowCategory(
category: PoWCategory,
enabled: Boolean,
): Boolean =
if (syncedSettings.proofOfWork.updateCategory(category, enabled)) {
saveAccountSettings()
true
} else {
false
}
// ---
// list names
// ---
@@ -841,17 +645,6 @@ class AccountSettings(
}
}
fun changeDefaultRelayGroupsDiscoveryFollowList(name: FeedDefinition) {
changeDefaultRelayGroupsDiscoveryFollowList(name.code)
}
fun changeDefaultRelayGroupsDiscoveryFollowList(name: TopFilter) {
if (defaultRelayGroupsDiscoveryFollowList.value != name) {
defaultRelayGroupsDiscoveryFollowList.tryEmit(name)
saveAccountSettings()
}
}
fun changeDefaultNappletsFollowList(name: FeedDefinition) {
changeDefaultNappletsFollowList(name.code)
}
@@ -896,17 +689,6 @@ class AccountSettings(
}
}
fun changeDefaultHighlightsFollowList(name: FeedDefinition) {
changeDefaultHighlightsFollowList(name.code)
}
fun changeDefaultHighlightsFollowList(name: TopFilter) {
if (defaultHighlightsFollowList.value != name) {
defaultHighlightsFollowList.tryEmit(name)
saveAccountSettings()
}
}
fun changeDefaultCalendarsFollowList(name: FeedDefinition) {
changeDefaultCalendarsFollowList(name.code)
}
@@ -1307,16 +1089,6 @@ class AccountSettings(
}
}
fun updateBolt12Offers(newBolt12Offers: Bolt12OfferListEvent?) {
if (newBolt12Offers == null || newBolt12Offers.tags.isEmpty()) return
// Events might be different objects, we have to compare their ids.
if (backupBolt12Offers?.id != newBolt12Offers.id) {
backupBolt12Offers = newBolt12Offers
saveAccountSettings()
}
}
fun updateSearchRelayList(newSearchRelayList: SearchRelayListEvent?) {
if (newSearchRelayList == null || newSearchRelayList.tags.isEmpty()) return
@@ -1441,33 +1213,6 @@ class AccountSettings(
}
}
override fun relayGroupList() = backupRelayGroupList
override fun updateRelayGroupListTo(newRelayGroupList: SimpleGroupListEvent?) {
// Joined groups can live in the NIP-44 private items (encrypted content),
// so an empty `tags` is NOT an empty list — guard only on null.
if (newRelayGroupList == null) return
// Events might be different objects, we have to compare their ids.
if (backupRelayGroupList?.id != newRelayGroupList.id) {
backupRelayGroupList = newRelayGroupList
saveAccountSettings()
}
}
override fun concordList() = backupConcordList
override fun updateConcordListTo(newConcordList: ConcordCommunityListEvent?) {
// The joined list lives entirely in NIP-44-encrypted content (secrets),
// so an empty `tags` is NOT an empty list — guard only on null.
if (newConcordList == null) return
if (backupConcordList?.id != newConcordList.id) {
backupConcordList = newConcordList
saveAccountSettings()
}
}
fun updateTrustProviderListTo(trustProviderList: TrustProviderListEvent?) {
if (trustProviderList == null || trustProviderList.tags.isEmpty()) return
@@ -1566,27 +1311,6 @@ class AccountSettings(
}
}
// ---
// dismissed channel invites (somebody added me to a channel; I don't want it on Messages)
// ---
fun isDismissedChannelInvite(channelId: String) = dismissedChannelInvites.value.contains(channelId)
fun dismissChannelInvite(channelId: String) {
if (!dismissedChannelInvites.value.contains(channelId)) {
dismissedChannelInvites.update { it + channelId }
saveAccountSettings()
}
}
/** Undo a dismissal — used when the viewer accepts the channel after all, so it can re-prompt later. */
fun undismissChannelInvite(channelId: String) {
if (dismissedChannelInvites.value.contains(channelId)) {
dismissedChannelInvites.update { it - channelId }
saveAccountSettings()
}
}
// ---
// pinned chatrooms
// ---
@@ -1761,24 +1485,6 @@ class AccountSettings(
saveAccountSettings()
}
}
private fun changeToggle(
flow: MutableStateFlow<Boolean>,
enabled: Boolean,
) {
if (flow.value != enabled) {
flow.tryEmit(enabled)
saveAccountSettings()
}
}
fun changeRelayAuthTrustMyRelaysAndVenues(enabled: Boolean) = changeToggle(relayAuthTrustMyRelaysAndVenues, enabled)
fun changeRelayAuthTrustReadFollows(enabled: Boolean) = changeToggle(relayAuthTrustReadFollows, enabled)
fun changeRelayAuthTrustMessageFollows(enabled: Boolean) = changeToggle(relayAuthTrustMessageFollows, enabled)
fun changeRelayAuthTrustMessageStrangers(enabled: Boolean) = changeToggle(relayAuthTrustMessageStrangers, enabled)
}
@Serializable
@@ -22,9 +22,6 @@ package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle
import com.vitorpamplona.amethyst.commons.service.pow.PoWCategory
import com.vitorpamplona.amethyst.commons.service.pow.PoWPolicy
import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
@@ -75,15 +72,6 @@ class AccountSyncedSettings(
AccountChatPreferences(
MutableStateFlow(internalSettings.chats.toChatroomKeys()),
)
val proofOfWork =
AccountPoWPreferences(
MutableStateFlow(internalSettings.proofOfWork.difficulty),
MutableStateFlow(PoWCategory.fromIds(internalSettings.proofOfWork.enabledCategories)),
)
val navigation =
AccountNavigationPreferences(
MutableStateFlow(internalSettings.navigation.bottomBarItems),
)
fun toInternal(): AccountSyncedSettingsInternal =
AccountSyncedSettingsInternal(
@@ -116,15 +104,6 @@ class AccountSyncedSettings(
videoPlayer = AccountVideoPlayerPreferencesInternal(videoPlayer.buttonItems.value),
media = AccountMediaPreferencesInternal(media.audioVisualizer.value.name),
chats = AccountChatPreferencesInternal(chats.pinnedChatrooms.value.map { it.users.sorted() }),
proofOfWork =
AccountPoWPreferencesInternal(
proofOfWork.difficulty.value,
// sorted so the serialized form is deterministic
proofOfWork.enabledCategories.value
.map { it.id }
.sorted(),
),
navigation = AccountNavigationPreferencesInternal(navigation.bottomBarItems.value),
)
fun updateFrom(syncedSettingsInternal: AccountSyncedSettingsInternal) {
@@ -203,24 +182,6 @@ class AccountSyncedSettings(
if (chats.pinnedChatrooms.value != newPinnedChatrooms) {
chats.pinnedChatrooms.tryEmit(newPinnedChatrooms)
}
// clamp like the local setter: a synced NIP-78 event from another
// client could carry an out-of-range value that would crash the miner
// (>256) or mine forever (41+).
val newDifficulty = syncedSettingsInternal.proofOfWork.difficulty.coerceIn(0, PoWPolicy.MAX_DIFFICULTY)
if (proofOfWork.difficulty.value != newDifficulty) {
proofOfWork.difficulty.tryEmit(newDifficulty)
}
val newPoWCategories = PoWCategory.fromIds(syncedSettingsInternal.proofOfWork.enabledCategories)
if (proofOfWork.enabledCategories.value != newPoWCategories) {
proofOfWork.enabledCategories.tryEmit(newPoWCategories)
}
val newBottomBarItems = syncedSettingsInternal.navigation.bottomBarItems
if (navigation.bottomBarItems.value != newBottomBarItems) {
navigation.bottomBarItems.tryEmit(newBottomBarItems)
}
}
fun dontTranslateFromFilteredBySpokenLanguages(): Set<String> = languages.dontTranslateFrom.value - getLanguagesSpokenByUser()
@@ -319,53 +280,11 @@ class AccountMediaPreferences(
val audioVisualizer: MutableStateFlow<VisualizerStyle>,
)
@Stable
class AccountNavigationPreferences(
val bottomBarItems: MutableStateFlow<List<BottomBarEntry>>,
)
@Stable
class AccountChatPreferences(
val pinnedChatrooms: MutableStateFlow<Set<ChatroomKey>>,
)
@Stable
class AccountPoWPreferences(
val difficulty: MutableStateFlow<Int> = MutableStateFlow(0),
val enabledCategories: MutableStateFlow<Set<PoWCategory>> = MutableStateFlow(PoWCategory.DEFAULT_ENABLED),
) {
fun updateDifficulty(newDifficulty: Int): Boolean {
// compare the coerced value: reporting a change for an out-of-range
// input that clamps to the current value would republish identical
// settings to relays.
val coerced = newDifficulty.coerceIn(0, MAX_POW_DIFFICULTY)
return if (difficulty.value != coerced) {
difficulty.tryEmit(coerced)
true
} else {
false
}
}
fun updateCategory(
category: PoWCategory,
enabled: Boolean,
): Boolean {
val current = enabledCategories.value
val updated = if (enabled) current + category else current - category
return if (updated != current) {
enabledCategories.tryEmit(updated)
true
} else {
false
}
}
companion object {
const val MAX_POW_DIFFICULTY = PoWPolicy.MAX_DIFFICULTY
}
}
internal fun AccountChatPreferencesInternal.toChatroomKeys(): Set<ChatroomKey> = pinnedRooms.mapTo(mutableSetOf()) { ChatroomKey(it.toSet()) }
@Stable
@@ -22,9 +22,6 @@ package com.vitorpamplona.amethyst.model
import android.content.res.Resources
import androidx.core.os.ConfigurationCompat
import com.vitorpamplona.amethyst.commons.service.pow.PoWCategory
import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry
import com.vitorpamplona.amethyst.ui.navigation.bottombars.DefaultBottomBarEntries
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import kotlinx.serialization.Serializable
import java.util.Locale
@@ -160,16 +157,6 @@ class AccountSyncedSettingsInternal(
val videoPlayer: AccountVideoPlayerPreferencesInternal = AccountVideoPlayerPreferencesInternal(),
val media: AccountMediaPreferencesInternal = AccountMediaPreferencesInternal(),
val chats: AccountChatPreferencesInternal = AccountChatPreferencesInternal(),
val proofOfWork: AccountPoWPreferencesInternal = AccountPoWPreferencesInternal(),
val navigation: AccountNavigationPreferencesInternal = AccountNavigationPreferencesInternal(),
)
@Serializable
class AccountNavigationPreferencesInternal(
// The ordered list of tabs pinned to the bottom navigation bar (built-ins,
// favorite apps, and individual joined chats/groups). Defaulted so blobs
// written before this field existed decode to the app's current defaults.
var bottomBarItems: List<BottomBarEntry> = DefaultBottomBarEntries,
)
@Serializable
@@ -219,14 +206,6 @@ class AccountMediaPreferencesInternal(
var audioVisualizer: String = "CLASSIC",
)
@Serializable
class AccountPoWPreferencesInternal(
// NIP-13 target difficulty in leading zero bits; 0 = don't mine anything.
val difficulty: Int = 0,
// PoWCategory ids the user wants mined when difficulty > 0.
val enabledCategories: List<String> = PoWCategory.DEFAULT_ENABLED.map { it.id },
)
@Serializable
class AccountChatPreferencesInternal(
// Rooms pinned to the top of the chat list. Each room is its member
@@ -82,20 +82,12 @@ class AntiSpamFilter {
(recentAddressables[hash] != null && recentAddressables[hash] != address) ||
(spamMessages[hash] != null && !spamMessages[hash].duplicatedEventAddresses.contains(address))
) {
// may be null if the first duplicate was evicted from the LRU cache
// while the spammer record still matches this hash.
val existingAddress = recentAddressables[hash]
val link1 = njumpLink(NAddress.create(existingAddress.kind, existingAddress.pubKeyHex, existingAddress.dTag, relay))
val link2 = njumpLink(NAddress.create(event.kind, event.pubKey, event.dTag(), relay))
val link1 = existingAddress?.let { njumpLink(NAddress.create(it.kind, it.pubKeyHex, it.dTag, relay)) } ?: link2
// Debug, not warn: a duplicate detection is this filter working, not a fault, and
// it is already reported where it can be acted on — relayStats.newSpam below and
// the flowSpam emission that drives the UI. On a normal boot this fires ~34 times
// with a pair of njump links each, which is the widest line in the log and says
// nothing the spam counters don't. Keep the links at DEBUG for when you need to
// open the two events and compare them.
Log.d("Duplicated/SPAM") { "${relay?.url} $link1 $link2" }
Log.w("Duplicated/SPAM") { "${relay?.url} $link1 $link2" }
// Log down offenders
val spammer = logOffender(hash, event)
@@ -119,18 +111,10 @@ class AntiSpamFilter {
(existingEvent != null && existingEvent != event.id) ||
(spamMessages[hash] != null && !spamMessages[hash].duplicatedEventIds.contains(event.id))
) {
val link1 = njumpLink(NEvent.create(existingEvent, null, null, relay))
val link2 = njumpLink(NEvent.create(event.id, null, null, relay))
// existingEvent may be null if the first duplicate was evicted from the
// LRU cache while the spammer record still matches this hash.
val link1 = existingEvent?.let { njumpLink(NEvent.create(it, null, null, relay)) } ?: link2
// Debug, not warn: a duplicate detection is this filter working, not a fault, and
// it is already reported where it can be acted on — relayStats.newSpam below and
// the flowSpam emission that drives the UI. On a normal boot this fires ~34 times
// with a pair of njump links each, which is the widest line in the log and says
// nothing the spam counters don't. Keep the links at DEBUG for when you need to
// open the two events and compare them.
Log.d("Duplicated/SPAM") { "${relay?.url} $link1 $link2" }
Log.w("Duplicated/SPAM") { "${relay?.url} $link1 $link2" }
// Log down offenders
val spammer = logOffender(hash, event)
@@ -165,12 +149,12 @@ class AntiSpamFilter {
Spammer(
pubkeyHex = event.pubKey,
duplicatedEventIds = setOf(),
duplicatedEventAddresses = setOfNotNull(recentAddressables[hashCode], event.address()),
duplicatedEventAddresses = setOf(recentAddressables[hashCode], event.address()),
)
} else {
Spammer(
pubkeyHex = event.pubKey,
duplicatedEventIds = setOfNotNull(recentEventIds[hashCode], event.id),
duplicatedEventIds = setOf(recentEventIds[hashCode], event.id),
duplicatedEventAddresses = setOf(),
)
}
@@ -1,63 +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
/**
* The outcome of redeeming a Concord invite link (CORD-05). Separating the failure
* modes lets the UI tell the user *why* it failed and — crucially — whether
* retrying could ever help, so a link we can never open doesn't strand the user on
* an endless "redeeming…" spinner with a retry button that loops forever.
*/
sealed interface ConcordInviteResult {
/** Redeemed and joined; navigate to [communityId]. */
data class Joined(
val communityId: String,
) : ConcordInviteResult
/** The link itself is malformed, or this account can't join (read-only key). Retrying can't help. */
data object InvalidLink : ConcordInviteResult
/**
* No invite bundle was reachable on any relay — a transient miss (relays down,
* link too new to have propagated, or expired). Retrying may help.
*/
data object NotReachable : ConcordInviteResult
/**
* The link was revoked: the newest event at its coordinate is a `vsk=9` revocation
* tombstone (CORD-05 §2). Retrying can't help — the owner retired this link.
*/
data object Revoked : ConcordInviteResult
/**
* The bundle opened fine, but its `expires_at` has passed. Retrying can't help —
* unlike [Revoked] the owner didn't retire the link, it simply timed out, so the
* user's next step is to ask for a fresh one.
*/
data object Expired : ConcordInviteResult
/**
* The bundle event was found but could not be opened with the link's token —
* typically because it was minted by a newer/incompatible Concord client whose
* bundle format this app can't read yet. Retrying can't help.
*/
data object Incompatible : ConcordInviteResult
}
@@ -1,123 +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
import androidx.core.content.edit
import com.vitorpamplona.amethyst.Amethyst
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.utils.RandomInstance
/**
* The account's anonymous, per-geohash chat identities.
*
* Geohash channels are location-tagged, so posting under the account's real npub
* would publish the user's movements tied to their public identity. Instead each
* cell gets a throwaway key that is unlinkable to the npub (and to the user's key
* in every other cell). This state object caches the derived keys and owns the
* seed they come from, keyed to a single account — so switching accounts (or
* logging out) switches identities with it.
*
* The seed is chosen per signer:
* - **Local key account** → derived from the account private key
* ([GeohashKeyDerivation.accountSeed]). Stable across all of the user's devices
* and recoverable from the account, while staying publicly unlinkable.
* - **Remote (NIP-46) / external (NIP-55) signer** → the raw key is unreachable,
* so a random 32-byte seed is kept in this account's encrypted storage. Because
* the store is scoped to the account's pubkey, two accounts on one device get
* different seeds (a global seed would have made their throwaway identities
* collide, linking the accounts in every cell).
*/
class GeohashChatIdentityState(
private val signer: NostrSigner,
) {
private val lock = Any()
private val cache = HashMap<String, KeyPair>()
@Volatile private var cachedDeviceSeed: ByteArray? = null
@Volatile private var cachedNickname: String? = null
/**
* 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.
*/
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
}
}
/** Persists the global location-chat nickname (trimmed) for this account. */
fun setNickname(value: String) {
val trimmed = value.trim()
synchronized(lock) {
cachedNickname = trimmed
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) }
}
private 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
}
}
companion object {
private const val PREF_KEY = "geohash_chat_device_seed"
private const val PREF_NICKNAME = "geohash_chat_nickname"
}
}
@@ -20,12 +20,99 @@
*/
package com.vitorpamplona.amethyst.model
import com.vitorpamplona.amethyst.commons.ui.richtext.HashtagIcon as CommonsHashtagIcon
import com.vitorpamplona.amethyst.commons.ui.richtext.checkForHashtagWithIcon as commonsCheckForHashtagWithIcon
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.hashtags.Amethyst
import com.vitorpamplona.amethyst.commons.hashtags.Btc
import com.vitorpamplona.amethyst.commons.hashtags.Cashu
import com.vitorpamplona.amethyst.commons.hashtags.Coffee
import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons
import com.vitorpamplona.amethyst.commons.hashtags.Flowerstr
import com.vitorpamplona.amethyst.commons.hashtags.Footstr
import com.vitorpamplona.amethyst.commons.hashtags.Gamestr
import com.vitorpamplona.amethyst.commons.hashtags.Grownostr
import com.vitorpamplona.amethyst.commons.hashtags.Lightning
import com.vitorpamplona.amethyst.commons.hashtags.Mate
import com.vitorpamplona.amethyst.commons.hashtags.Nostr
import com.vitorpamplona.amethyst.commons.hashtags.Plebs
import com.vitorpamplona.amethyst.commons.hashtags.Skull
import com.vitorpamplona.amethyst.commons.hashtags.Tunestr
import com.vitorpamplona.amethyst.commons.hashtags.Weed
import com.vitorpamplona.amethyst.commons.hashtags.Zap
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.commons.richtext.HashTagSegment
import com.vitorpamplona.amethyst.commons.richtext.RegularTextSegment
import com.vitorpamplona.amethyst.ui.components.HashTag
import com.vitorpamplona.amethyst.ui.components.RenderRegular
import com.vitorpamplona.amethyst.ui.components.RenderTextParagraph
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
// The hashtag-icon table now lives in commons/ui/richtext so the shared
// RichTextViewer and both front ends resolve the same icons. These re-exports keep
// the historical `com.vitorpamplona.amethyst.model` call sites working.
typealias HashtagIcon = CommonsHashtagIcon
@Preview
@Composable
fun RenderHashTagIconsPreview() {
ThemeComparisonColumn {
RenderRegular(
"Testing rendering of hashtags: #flowerstr #Bitcoin, #nostr, #lightning, #zap, #amethyst, #cashu, #plebs, #coffee, #skullofsatoshi, #grownostr, #footstr, #tunestr, #weed, #mate, #gamestr, #gamechain",
EmptyTagList,
) { paragraph, _, spaceWidth, modifier ->
RenderTextParagraph(paragraph, spaceWidth, modifier) { word ->
when (word) {
is HashTagSegment -> HashTag(word, EmptyNav())
is RegularTextSegment -> Text(word.segmentText)
}
}
}
}
}
fun checkForHashtagWithIcon(tag: String): HashtagIcon? = commonsCheckForHashtagWithIcon(tag)
fun checkForHashtagWithIcon(tag: String): HashtagIcon? =
when (tag.lowercase()) {
"₿itcoin", "bitcoin", "btc", "timechain", "bitcoiner", "bitcoiners" -> bitcoin
"nostr", "nostrich", "nostriches", "thenostr" -> nostr
"lightning", "lightningnetwork" -> lightning
"zap", "zaps", "zapper", "zappers", "zapping", "zapped", "zapathon", "zapraiser", "zaplife", "zapchain" -> zap
"amethyst" -> amethyst
"cashu", "ecash", "nut", "nuts", "deeznuts" -> cashu
"plebs", "pleb", "plebchain" -> plebs
"coffee", "coffeechain", "cafe" -> coffee
"skullofsatoshi" -> skull
"grownostr", "gardening", "garden" -> growstr
"footstr" -> footstr
"flowerstr" -> flowerstr
"tunestr", "music", "nowplaying" -> tunestr
"mate", "matechain", "matestr" -> matestr
"weed", "weedstr", "420", "cannabis", "marijuana" -> weed
"gamestr", "gaming", "gamechain" -> gamestr
else -> null
}
val bitcoin = HashtagIcon(CustomHashTagIcons.Btc, "Bitcoin", Modifier.padding(start = 1.dp, bottom = 1.dp, top = 1.dp))
val nostr = HashtagIcon(CustomHashTagIcons.Nostr, "Nostr", Modifier.padding(start = 1.dp, bottom = 1.dp, top = 1.dp))
val lightning = HashtagIcon(CustomHashTagIcons.Lightning, "Lightning", Modifier.padding(start = 1.dp, bottom = 1.dp, top = 1.dp))
val zap = HashtagIcon(CustomHashTagIcons.Zap, "Zap", Modifier.padding(start = 1.dp, bottom = 1.dp, top = 1.dp))
val amethyst = HashtagIcon(CustomHashTagIcons.Amethyst, "Amethyst", Modifier.padding(start = 2.dp, bottom = 1.dp, top = 1.dp))
val cashu = HashtagIcon(CustomHashTagIcons.Cashu, "Cashu", Modifier.padding(start = 1.dp, bottom = 1.dp, top = 1.dp))
val plebs = HashtagIcon(CustomHashTagIcons.Plebs, "Pleb", Modifier.padding(start = 2.dp, bottom = 1.dp, top = 1.dp))
val coffee = HashtagIcon(CustomHashTagIcons.Coffee, "Coffee", Modifier.padding(start = 3.dp, bottom = 1.dp, top = 1.dp))
val skull = HashtagIcon(CustomHashTagIcons.Skull, "SkullofSatoshi", Modifier.padding(start = 1.dp, bottom = 1.dp, top = 1.dp))
val growstr = HashtagIcon(CustomHashTagIcons.Grownostr, "GrowNostr", Modifier.padding(start = 1.dp, bottom = 1.dp, top = 1.dp))
val footstr = HashtagIcon(CustomHashTagIcons.Footstr, "Footstr", Modifier.padding(start = 2.dp, bottom = 1.dp, top = 1.dp))
val flowerstr = HashtagIcon(CustomHashTagIcons.Flowerstr, "Flowerstr", Modifier.padding(start = 2.dp, bottom = 1.dp, top = 1.dp))
val tunestr = HashtagIcon(CustomHashTagIcons.Tunestr, "Tunestr", Modifier.padding(start = 1.dp, bottom = 1.dp, top = 1.dp))
val weed = HashtagIcon(CustomHashTagIcons.Weed, "Weed", Modifier.padding(start = 1.dp, bottom = 0.dp, top = 0.dp))
val matestr = HashtagIcon(CustomHashTagIcons.Mate, "Mate", Modifier.padding(start = 1.dp, bottom = 0.dp, top = 0.dp))
val gamestr = HashtagIcon(CustomHashTagIcons.Gamestr, "GameStr", Modifier.padding(start = 1.dp, bottom = 0.dp, top = 0.dp))
@Immutable
class HashtagIcon(
val icon: ImageVector,
val description: String,
val modifier: Modifier = Modifier,
)
@@ -1,140 +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
import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent
import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent
import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent
import com.vitorpamplona.quartz.experimental.attestations.recommendation.AttestorRecommendationEvent
import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent
import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
import com.vitorpamplona.quartz.experimental.birdstar.BirdDetectionEvent
import com.vitorpamplona.quartz.experimental.birdstar.BirdexEvent
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent
import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent
import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent
import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
import com.vitorpamplona.quartz.nip64Chess.challenge.offer.LiveChessGameChallengeEvent
import com.vitorpamplona.quartz.nip64Chess.end.LiveChessGameEndEvent
import com.vitorpamplona.quartz.nip64Chess.game.ChessGameEvent
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent
import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent
import com.vitorpamplona.quartz.nip71Video.VideoShortEvent
import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent
import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent
/**
* The distinct event-kind groups the Home feed downloads (in the relay assembler) and renders (in
* the DAL). Each is independently toggleable in Settings Home: turning one off both drops its
* kinds from the always-on home relay filters AND hides them from the New Threads / Conversations /
* Everything tabs.
*
* [code] is the stable on-disk identifier (do NOT rename — it is what [encode]/[decode] persist);
* the enum ordinal is never stored, so entries may be reordered freely. [kinds] are the Nostr event
* kinds this group governs; they must stay disjoint across entries so a single toggle owns each kind.
*/
enum class HomeFeedType(
val code: String,
val kinds: List<Int>,
) {
TEXT_NOTES("text_notes", listOf(TextNoteEvent.KIND)),
REPOSTS("reposts", listOf(RepostEvent.KIND, GenericRepostEvent.KIND)),
COMMENTS("comments", listOf(CommentEvent.KIND)),
PICTURES("pictures", listOf(PictureEvent.KIND)),
VIDEOS("videos", listOf(VideoNormalEvent.KIND, VideoHorizontalEvent.KIND)),
SHORTS("shorts", listOf(VideoShortEvent.KIND, VideoVerticalEvent.KIND)),
ARTICLES("articles", listOf(LongTextNoteEvent.KIND)),
WIKI("wiki", listOf(WikiNoteEvent.KIND)),
HIGHLIGHTS("highlights", listOf(HighlightEvent.KIND)),
POLLS("polls", listOf(PollEvent.KIND, ZapPollEvent.KIND, PollResponseEvent.KIND)),
CLASSIFIEDS("classifieds", listOf(ClassifiedsEvent.KIND)),
TORRENTS("torrents", listOf(TorrentEvent.KIND)),
VOICE("voice", listOf(VoiceEvent.KIND, VoiceReplyEvent.KIND)),
LIVE_ACTIVITIES("live_activities", listOf(LiveActivitiesEvent.KIND, LiveActivitiesChatMessageEvent.KIND)),
EPHEMERAL_CHAT("ephemeral_chat", listOf(EphemeralChatEvent.KIND)),
INTERACTIVE_STORIES("interactive_stories", listOf(InteractiveStoryPrologueEvent.KIND)),
CHESS("chess", listOf(ChessGameEvent.KIND, LiveChessGameChallengeEvent.KIND, LiveChessGameEndEvent.KIND)),
BIRDS("birds", listOf(BirdDetectionEvent.KIND, BirdexEvent.KIND)),
ATTESTATIONS(
"attestations",
listOf(
AttestationEvent.KIND,
AttestationRequestEvent.KIND,
AttestorRecommendationEvent.KIND,
AttestorProficiencyEvent.KIND,
),
),
NIPS("nips", listOf(NipTextEvent.KIND)),
MUSIC("music", listOf(AudioTrackEvent.KIND, MusicTrackEvent.KIND, MusicPlaylistEvent.KIND, AudioHeaderEvent.KIND)),
PODCASTS("podcasts", listOf(PodcastEpisodeEvent.KIND, PodcastMetadataEvent.KIND)),
FUNDRAISERS("fundraisers", listOf(FundraiserEvent.KIND)),
;
companion object {
/** Every group, enabled by default so a fresh (or never-customized) account loads everything. */
val ALL: Set<HomeFeedType> = entries.toSet()
fun fromCode(code: String?): HomeFeedType? = entries.firstOrNull { it.code == code }
/** Serializes a set of groups as their comma-joined [code]s, for SharedPreferences. */
fun encode(types: Set<HomeFeedType>): String = types.joinToString(",") { it.code }
/** Parses a comma-joined [code] list back to a set, dropping any unknown codes. */
fun decode(joined: String?): Set<HomeFeedType> =
joined
?.split(",")
?.mapNotNull { fromCode(it.trim()) }
?.toSet()
?: emptySet()
/**
* The event kinds to drop from the home relay filters and the home DAL, given the currently
* [enabled] set. A kind stays live if ANY enabled group still owns it (guards against a
* future overlap between two groups), so disabling one group never silently hides a kind a
* still-enabled group also wants.
*/
fun disabledKinds(enabled: Set<HomeFeedType>): Set<Int> {
if (enabled.size == ALL.size) return emptySet()
val enabledKinds = enabled.flatMapTo(HashSet()) { it.kinds }
return (ALL - enabled).flatMapTo(HashSet()) { it.kinds }.apply { removeAll(enabledKinds) }
}
}
}
File diff suppressed because it is too large Load Diff
@@ -21,8 +21,6 @@
package com.vitorpamplona.amethyst.model
import androidx.collection.LruCache
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
interface MutableMediaAspectRatioCache {
fun get(url: String): Float?
@@ -34,27 +32,10 @@ interface MutableMediaAspectRatioCache {
)
}
/**
* Aspect ratios keyed by media URL, learned from imeta `dim` tags up front or from the decoder once
* a first frame lands.
*
* Entries are snapshot state, so a composable that calls [get] **during composition** recomposes
* when the real dimensions arrive later. That matters because players and image loaders only report
* size after the first frame decodes: a caller that sized itself off a plain cache miss would stay
* wrong for the whole visit and only look right the *next* time the media is opened. Note this only
* works for reads made in composition a read from inside `remember { }` is cached by `remember`
* itself and won't pick the update up.
*/
object MediaAspectRatioCache : MutableMediaAspectRatioCache {
private val cache = LruCache<String, MutableState<Float?>>(1000)
val mediaAspectRatioCacheByUrl = LruCache<String, Float>(1000)
// get-then-put has to be atomic, so the compound op is guarded even though LruCache is itself
// thread-safe. A miss still stores a slot: that empty slot is what the caller observes until
// add() fills it in.
@Synchronized
private fun entry(url: String): MutableState<Float?> = cache.get(url) ?: mutableStateOf<Float?>(null).also { cache.put(url, it) }
override fun get(url: String): Float? = entry(url).value
override fun get(url: String): Float? = mediaAspectRatioCacheByUrl.get(url)
override fun add(
url: String,
@@ -62,7 +43,7 @@ object MediaAspectRatioCache : MutableMediaAspectRatioCache {
height: Int,
) {
if (height > 1) {
entry(url).value = width.toFloat() / height.toFloat()
mediaAspectRatioCacheByUrl.put(url, width.toFloat() / height.toFloat())
}
}
}
@@ -21,71 +21,51 @@
package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Stable
import com.fasterxml.jackson.annotation.JsonSubTypes
import com.fasterxml.jackson.annotation.JsonTypeInfo
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.quartz.utils.Log
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.toImmutableMap
import kotlinx.serialization.DeserializationStrategy
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonContentPolymorphicSerializer
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
/**
* FHIR resources are polymorphic on the `resourceType` string. We only model the
* handful of types Amethyst renders; anything else (and any resource with a
* missing/unrecognized type) decodes into [UnknownResource] so a mixed [Bundle]
* never fails to parse just because it carries a type we don't know about.
*/
object ResourceSerializer : JsonContentPolymorphicSerializer<Resource>(Resource::class) {
override fun selectDeserializer(element: JsonElement): DeserializationStrategy<Resource> =
when (element.jsonObject["resourceType"]?.jsonPrimitive?.content) {
"Practitioner" -> Practitioner.serializer()
"Patient" -> Patient.serializer()
"Bundle" -> Bundle.serializer()
"VisionPrescription" -> VisionPrescription.serializer()
else -> UnknownResource.serializer()
}
}
@Serializable(with = ResourceSerializer::class)
@JsonTypeInfo(
use = JsonTypeInfo.Id.NAME,
include = JsonTypeInfo.As.PROPERTY,
property = "resourceType",
)
@JsonSubTypes(
JsonSubTypes.Type(value = Practitioner::class, name = "Practitioner"),
JsonSubTypes.Type(value = Patient::class, name = "Patient"),
JsonSubTypes.Type(value = Bundle::class, name = "Bundle"),
JsonSubTypes.Type(value = VisionPrescription::class, name = "VisionPrescription"),
)
@Stable
abstract class Resource {
abstract val resourceType: String?
abstract val id: String
}
open class Resource(
var resourceType: String? = null,
var id: String = "",
)
/** Fallback for any FHIR resourceType we don't model. */
@Serializable
@Stable
class UnknownResource(
override val resourceType: String? = null,
override val id: String = "",
) : Resource()
@Serializable
@Stable
class Practitioner(
override val resourceType: String? = null,
override val id: String = "",
resourceType: String? = null,
id: String = "",
var active: Boolean? = null,
var name: ArrayList<HumanName> = arrayListOf(),
var gender: String? = null,
) : Resource()
) : Resource(resourceType, id)
@Serializable
@Stable
class Patient(
override val resourceType: String? = null,
override val id: String = "",
resourceType: String? = null,
id: String = "",
var active: Boolean? = null,
var name: ArrayList<HumanName> = arrayListOf(),
var gender: String? = null,
) : Resource()
) : Resource(resourceType, id)
@Serializable
@Stable
class HumanName(
var use: String? = null,
@@ -95,21 +75,19 @@ class HumanName(
fun assembleName(): String = given.joinToString(" ") + " " + family
}
@Serializable
@Stable
class Bundle(
override val resourceType: String? = null,
override val id: String = "",
resourceType: String? = null,
id: String = "",
var type: String? = null,
var created: String? = null,
var entry: List<Resource> = arrayListOf(),
) : Resource()
) : Resource(resourceType, id)
@Serializable
@Stable
class VisionPrescription(
override val resourceType: String? = null,
override val id: String = "",
resourceType: String? = null,
id: String = "",
var status: String? = null,
var created: String? = null,
var patient: Reference? = Reference(),
@@ -117,7 +95,7 @@ class VisionPrescription(
var dateWritten: String? = null,
var prescriber: Reference? = Reference(),
var lensSpecification: List<LensSpecification> = arrayListOf(),
) : Resource() {
) : Resource(resourceType, id) {
fun glasses() = lensSpecification.filter { it.product == "lens" }
fun contacts() = lensSpecification.filter { it.product == "contacts" }
@@ -131,7 +109,6 @@ class VisionPrescription(
fun contactsLeftEyes() = lensSpecification.filter { it.product == "contacts" && it.eye == "left" }
}
@Serializable
@Stable
class LensSpecification(
var product: String? = null,
@@ -152,14 +129,12 @@ class LensSpecification(
var note: String? = null,
)
@Serializable
@Stable
class Prism(
var amount: Double? = null,
var base: String? = null,
)
@Serializable
class Reference(
var reference: String? = null,
)
@@ -181,22 +156,12 @@ fun findReferenceInDb(
}
}
/**
* Lenient FHIR JSON reader: unknown keys are ignored (implementations routinely add
* their own fields) and missing keys fall back to the property defaults, so we parse
* as much of a resource as we can rather than rejecting the whole document.
*/
val FhirJson =
Json {
ignoreUnknownKeys = true
isLenient = true
explicitNulls = false
coerceInputValues = true
}
fun parseResourceBundleOrNull(json: String): FhirElementDatabase? {
val mapper =
jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
fun parseResourceBundleOrNull(json: String): FhirElementDatabase? =
try {
val resource = FhirJson.decodeFromString(ResourceSerializer, json)
return try {
val resource = mapper.readValue<Resource>(json)
val db =
when (resource) {
@@ -217,3 +182,4 @@ fun parseResourceBundleOrNull(json: String): FhirElementDatabase? =
Log.e("RenderEyeGlassesPrescription", "Parser error", e)
null
}
}
@@ -1,69 +0,0 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model
import com.vitorpamplona.quartz.buzz.stream.StreamMessageEditEvent
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent
import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent
import com.vitorpamplona.quartz.nip40Expiration.isExpirationBefore
import com.vitorpamplona.quartz.utils.TimeUtils
/*
* Per-kind resolution of a message's edit overlay from its own `Note.edits`. Every edit that
* targets a note is held there as a hard-referenced child (like a reaction), so these are cheap
* in-memory folds no cache scan, no LocalCache state involved, which is why they live on the
* note rather than the cache.
*
* All three kinds apply ONLY edits authored by the edited note's own author: the send side gates
* editing to your own messages, and neither the relay (Buzz) nor an encrypted-plane peer (Concord)
* is trusted to enforce that, so a foreign-authored edit never rewrites your message.
*/
/**
* Every kind-1010 post modification of this note, oldest first (author-only, dropping NIP-40
* expired ones). A list because the post's EditState cycles through the original + each version.
*/
fun Note.textNoteModifications(): List<Note> {
val noteAuthor = author ?: return emptyList()
val now = TimeUtils.now()
return edits
.filter { item ->
val e = item.event
e is TextNoteModificationEvent && noteAuthor == item.author && !e.isExpirationBefore(now)
}.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
}
/** The kind-40003 Buzz edit overlaying this message, or null — author-only, newest by created_at. */
fun Note.latestBuzzEdit(): Note? {
val authorHex = author?.pubkeyHex ?: return null
return edits
.filter { it.event is StreamMessageEditEvent && it.author?.pubkeyHex == authorHex }
// idHex tie-break so a same-second pair resolves identically on every client.
.maxWithOrNull(compareBy({ it.createdAt() ?: 0L }, { it.idHex }))
}
/** The kind-3302 Concord edit overlaying this message, or null — author-only, newest by CORD-02 §4 send time. */
fun Note.latestConcordEdit(): Note? {
val authorHex = author?.pubkeyHex ?: return null
return edits
.filter { it.author?.pubkeyHex == authorHex && it.event is ConcordChatEditEvent }
.maxWithOrNull(compareBy({ (it.event as ConcordChatEditEvent).orderingMs() }, { it.idHex }))
}
@@ -1,63 +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
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable
/**
* Route key under which a private chat room's last-read time is stored in AccountSettings.
* Every marker writer (send paths, ingestion, room view, hidden-room sweep) and reader
* (Messages-tab dot, room-row bubble) must build the key through this function: a format
* drift between a writer and a reader silently splits read state (#1286).
*/
fun privateChatLastReadRoute(room: ChatroomKey) = "Room/${room.hashCode()}"
/**
* True when [message] marks [room] as read up to its timestamp: the logged-in user authored
* it, so sending it from this device, or from another one arriving via the self-addressed
* gift wrap means they had caught up with the conversation (#1286, #1287). Notes-to-self
* rooms are exempt: there the user's own messages ARE the content still to be seen.
*/
fun chatMessageMarksRoomAsRead(
message: Event,
room: ChatroomKey,
loggedInUser: HexKey,
): Boolean = message.pubKey == loggedInUser && room.users.singleOrNull() != loggedInUser
/**
* Read-marker route + timestamp for the newest message of a private chat room, or null when
* the room cannot be unread: no chat event, a newest message that counts as read (see
* [chatMessageMarksRoomAsRead]), or every participant hidden.
*/
fun unreadPrivateChatRoute(
newestMessage: Event?,
loggedInUser: HexKey,
isAllHidden: (Set<HexKey>) -> Boolean,
): Pair<String, Long>? {
if (newestMessage !is ChatroomKeyable) return null
val room = newestMessage.chatroomKey(loggedInUser)
if (chatMessageMarksRoomAsRead(newestMessage, room, loggedInUser)) return null
if (isAllHidden(room.users)) return null
return privateChatLastReadRoute(room) to newestMessage.createdAt
}
@@ -1,52 +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
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
/**
* A candidate group channel when routing a stray group-scoped content event: its [key] and whether it is a
* confirmed host (has received relay-signed state). See [redirectStrayRelayGroupContent].
*/
data class RelayGroupTargetCandidate(
val key: GroupId,
val hasRelaySignedState: Boolean,
)
/**
* Resolves the **serving-relay hazard**. A group-scoped content event (kind-9 chat, poll, kind-11 thread)
* is keyed to its group channel by the relay that served it, because a NIP-29 event doesn't carry its host
* relay. That is correct for the group's own host-pinned subscriptions, but a message resolved from a
* **non-host** relay e.g. a quoted kind-9 fetched by id during missing-event resolution would be filed
* under a channel keyed to that stranger relay, one the group's own screens never read, so the message
* silently vanishes.
*
* Called only when there is **no** channel keyed to the serving relay for this group id (the fast, common
* path attaches directly and never gets here). It picks the group's single confirmed **host** channel one
* that has received relay-signed state to attach the stray to instead. Returns that host key, or null when
* there is no single confirmed host (a genuinely new group on the serving relay, or an id ambiguous across
* several hosts), in which case the caller keeps the serving-relay key as today's best effort.
*
* A phantom channel (one minted from an earlier stray) never has relay-signed state, so it can never be
* chosen here the redirect only ever lands on a real host, never on another phantom. This makes the fix
* strictly safe: it can redirect a stray to a known host, but never divert a message away from one.
*/
fun redirectStrayRelayGroupContent(candidates: List<RelayGroupTargetCandidate>): GroupId? = candidates.filter { it.hasRelaySignedState }.singleOrNull()?.key
@@ -22,6 +22,8 @@ package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry
import com.vitorpamplona.amethyst.ui.navigation.bottombars.DefaultBottomBarEntries
import kotlinx.serialization.Serializable
@Stable
@@ -42,6 +44,7 @@ data class UiSettings(
val automaticallyProposeAiImprovements: BooleanType = BooleanType.ALWAYS,
val useTrackedBroadcasts: BooleanType = BooleanType.ALWAYS,
val automaticallyCreateDrafts: BooleanType = BooleanType.ALWAYS,
val bottomBarItems: List<BottomBarEntry> = DefaultBottomBarEntries,
val showHomeNewThreadsTab: Boolean = true,
val showHomeConversationsTab: Boolean = true,
val showHomeEverythingTab: Boolean = false,
@@ -21,6 +21,8 @@
package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry
import com.vitorpamplona.amethyst.ui.navigation.bottombars.DefaultBottomBarEntries
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
@@ -42,6 +44,7 @@ class UiSettingsFlow(
val automaticallyProposeAiImprovements: MutableStateFlow<BooleanType> = MutableStateFlow(BooleanType.ALWAYS),
val useTrackedBroadcasts: MutableStateFlow<BooleanType> = MutableStateFlow(BooleanType.ALWAYS),
val automaticallyCreateDrafts: MutableStateFlow<BooleanType> = MutableStateFlow(BooleanType.ALWAYS),
val bottomBarItems: MutableStateFlow<List<BottomBarEntry>> = MutableStateFlow(DefaultBottomBarEntries),
val showHomeNewThreadsTab: MutableStateFlow<Boolean> = MutableStateFlow(true),
val showHomeConversationsTab: MutableStateFlow<Boolean> = MutableStateFlow(true),
val showHomeEverythingTab: MutableStateFlow<Boolean> = MutableStateFlow(false),
@@ -74,6 +77,7 @@ class UiSettingsFlow(
automaticallyProposeAiImprovements,
useTrackedBroadcasts,
automaticallyCreateDrafts,
bottomBarItems,
showHomeNewThreadsTab,
showHomeConversationsTab,
showHomeEverythingTab,
@@ -110,7 +114,7 @@ class UiSettingsFlow(
flows[12] as BooleanType,
flows[13] as BooleanType,
flows[14] as BooleanType,
flows[15] as Boolean,
flows[15] as List<BottomBarEntry>,
flows[16] as Boolean,
flows[17] as Boolean,
flows[18] as Boolean,
@@ -118,12 +122,13 @@ class UiSettingsFlow(
flows[20] as Boolean,
flows[21] as Boolean,
flows[22] as Boolean,
flows[23] as BooleanType,
flows[24] as AccentColorType,
flows[25] as FontFamilyType,
flows[26] as FontSizeType,
flows[27] as String,
flows[28] as Boolean,
flows[23] as Boolean,
flows[24] as BooleanType,
flows[25] as AccentColorType,
flows[26] as FontFamilyType,
flows[27] as FontSizeType,
flows[28] as String,
flows[29] as Boolean,
)
}
@@ -144,6 +149,7 @@ class UiSettingsFlow(
automaticallyProposeAiImprovements.value,
useTrackedBroadcasts.value,
automaticallyCreateDrafts.value,
bottomBarItems.value,
showHomeNewThreadsTab.value,
showHomeConversationsTab.value,
showHomeEverythingTab.value,
@@ -223,6 +229,10 @@ class UiSettingsFlow(
automaticallyCreateDrafts.tryEmit(torSettings.automaticallyCreateDrafts)
any = true
}
if (bottomBarItems.value != torSettings.bottomBarItems) {
bottomBarItems.tryEmit(torSettings.bottomBarItems)
any = true
}
if (showHomeNewThreadsTab.value != torSettings.showHomeNewThreadsTab) {
showHomeNewThreadsTab.tryEmit(torSettings.showHomeNewThreadsTab)
any = true
@@ -319,6 +329,7 @@ class UiSettingsFlow(
MutableStateFlow(uiSettings.automaticallyProposeAiImprovements),
MutableStateFlow(uiSettings.useTrackedBroadcasts),
MutableStateFlow(uiSettings.automaticallyCreateDrafts),
MutableStateFlow(uiSettings.bottomBarItems),
MutableStateFlow(uiSettings.showHomeNewThreadsTab),
MutableStateFlow(uiSettings.showHomeConversationsTab),
MutableStateFlow(uiSettings.showHomeEverythingTab),
@@ -1,53 +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
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
/**
* Which NIP-71 kind a video upload is published as. Only videos are affected whether an upload
* is a picture (NIP-68 kind 20) or a video is decided by the file's mime type and can't be
* overridden.
*
* The composer picks this from the feed it was opened on, so a post always lands in the feed the
* user was standing in (or shared to): the Shorts feed reads kind 22, the Longs feed reads kind 21
* and the Video feed reads both.
*/
enum class VideoPostKind {
/** Derive from the video's dimensions: portrait -> kind 22, landscape -> kind 21. */
AUTO,
/** Always NIP-71 kind 22 (short-form video), regardless of orientation. */
SHORT,
/** Always NIP-71 kind 21 (normal video), regardless of orientation. */
NORMAL,
;
/** True when a video of [dim] should be published as a NIP-71 kind 22 short. */
fun isShort(dim: DimensionTag): Boolean =
when (this) {
SHORT -> true
NORMAL -> false
AUTO -> dim.height > dim.width
}
}
@@ -22,11 +22,6 @@ package com.vitorpamplona.amethyst.model.accountsCache
import android.content.ContentResolver
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.commons.connectedApps.nip46.InMemoryNip46ClientStore
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.service.pow.PoWPublishQueue
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.LocalCache
@@ -35,7 +30,6 @@ import com.vitorpamplona.amethyst.model.marmot.AndroidMarmotMessageStore
import com.vitorpamplona.amethyst.model.marmot.AndroidMlsGroupStateStore
import com.vitorpamplona.amethyst.model.marmot.InMemoryMlsGroupStateStore
import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.DataStoreRelayAuthPermissionStore
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentFilterAssembler
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
@@ -67,26 +61,13 @@ class AccountCacheState(
val cache: LocalCache,
val client: INostrClient,
val rootFilesDir: () -> File = { File("") },
val powQueue: () -> PoWPublishQueue? = { null },
/** Optional resource-ledger wrapper applied to every account signer (see MeteringNostrSigner). */
val meterSigner: (NostrSigner) -> NostrSigner = { it },
/** App-global Connected-Apps signer permission store (shared with napplets), gating the NIP-46 bunker. */
val signerPermissionStore: NostrSignerPermissionStore = InMemoryNostrSignerPermissionStore(),
/** App-global store of connected NIP-46 client display + relay info. */
val nip46ClientStore: Nip46ClientStore = InMemoryNip46ClientStore(),
) {
val accounts = MutableStateFlow<Map<HexKey, Account>>(emptyMap())
/** Guards [loadAccount]'s check-then-create so concurrent callers can't build twin Accounts. */
private val loadLock = Any()
fun removeAccount(pubkey: HexKey) {
accounts.update { existingAccounts ->
val oldValue = existingAccounts[pubkey]
oldValue?.scope?.cancel()
// Unregisters the tracker's persistent listener from the shared
// client; without this every removed account leaks a listener.
oldValue?.chatDeliveryTracker?.destroy()
existingAccounts.minus(pubkey)
}
}
@@ -190,25 +171,9 @@ class AccountCacheState(
val cached = accounts.value[signer.pubKey]
if (cached != null) return cached
// Serialize construction: the UI login path and the always-on service's preload race
// to load the same account on cold start. Without the lock both see a null cache and
// both build an Account — the loser is never cancelled, leaving a zombie whose
// Nip46SignerState answers bunker requests with a NostrSignerExternal no Activity
// ever registers a launcher on (every sign fails "No activity to launch from"),
// while duplicating consent prompts and racing error replies to NIP-46 clients.
return synchronized(loadLock) {
accounts.value[signer.pubKey]?.let { return it }
createAccount(signer, accountSettings)
}
}
private fun createAccount(
signer: NostrSigner,
accountSettings: AccountSettings,
): Account {
val signerWithClientTag =
NostrSignerWithClientTag(
inner = meterSigner(signer),
inner = signer,
clientName = CLIENT_TAG_NAME,
disabled = { !accountSettings.syncedSettings.security.addClientTag.value },
)
@@ -257,10 +222,6 @@ class AccountCacheState(
null
}
// 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)
return Account(
settings = accountSettings,
signer = signerWithClientTag,
@@ -283,10 +244,6 @@ class AccountCacheState(
mlsGroupStateStore = mlsStore,
marmotMessageStore = marmotMessageStore,
marmotKeyPackageStore = marmotKeyPackageStore,
powQueue = powQueue,
relayAuthPermissionStore = relayAuthPermissionStore,
signerPermissionStore = signerPermissionStore,
nip46ClientStore = nip46ClientStore,
).also { newAccount ->
accounts.update { existingAccounts ->
existingAccounts.plus(Pair(signer.pubKey, newAccount))
@@ -298,7 +255,6 @@ class AccountCacheState(
accounts.update { existingAccounts ->
existingAccounts.forEach {
it.value.scope.cancel()
it.value.chatDeliveryTracker.destroy()
}
emptyMap()
}
@@ -1,90 +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.bolt12Offers
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.NoteState
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListEvent
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
/**
* The logged-in user's NIP-B1 BOLT12 offer list (kind 10058) as live account state,
* mirroring [com.vitorpamplona.amethyst.model.nipA3PaymentTargets.NipA3PaymentTargetsState].
* Exposes the current offers as a [flow], persists them across restarts (via
* [AccountSettings]), and publishes updates with [saveOffers].
*/
class Bolt12OfferListState(
val signer: NostrSigner,
val cache: LocalCache,
val scope: CoroutineScope,
val settings: AccountSettings,
) {
val bolt12OfferListNote = cache.getOrCreateAddressableNote(getBolt12OfferListAddress())
fun getBolt12OfferListFlow(): StateFlow<NoteState> = bolt12OfferListNote.flow().metadata.stateFlow
fun getBolt12OfferListAddress() = Bolt12OfferListEvent.createAddress(signer.pubKey)
fun getBolt12OfferListEvent(): Bolt12OfferListEvent? = bolt12OfferListNote.event as? Bolt12OfferListEvent
/** The user's currently-published canonical raw BOLT12 offers. */
val flow: StateFlow<List<String>> =
getBolt12OfferListFlow()
.map { (it.note.event as? Bolt12OfferListEvent)?.offers() ?: emptyList() }
.flowOn(Dispatchers.IO)
.stateIn(scope, SharingStarted.Eagerly, emptyList())
suspend fun saveOffers(offers: List<String>): Bolt12OfferListEvent {
val existing = getBolt12OfferListEvent()
return if (existing != null && existing.tags.isNotEmpty()) {
Bolt12OfferListEvent.updateOffers(existing, offers, signer)
} else {
Bolt12OfferListEvent.create(offers, signer)
}
}
init {
settings.backupBolt12Offers?.let {
Log.d("AccountRegisterObservers") { "Loading saved BOLT12 offer list ${it.toJson()}" }
@OptIn(DelicateCoroutinesApi::class)
scope.launch(Dispatchers.IO) { cache.justConsumeMyOwnEvent(it) }
}
scope.launch(Dispatchers.IO) {
getBolt12OfferListFlow().collect {
(it.note.event as? Bolt12OfferListEvent)?.let { offerListEvent ->
settings.updateBolt12Offers(offerListEvent)
}
}
}
}
}
@@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.model.nip03Timestamp
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent
import com.vitorpamplona.quartz.nip03Timestamp.OtsResolverBuilder
import com.vitorpamplona.quartz.nip03Timestamp.VerificationStateCache
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
@@ -32,7 +32,7 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
class IncomingOtsEventVerifier(
private val otsResolverBuilder: OtsResolverBuilder,
private val otsVerifCache: () -> VerificationStateCache,
private val cache: LocalCache,
private val scope: CoroutineScope,
) {
@@ -49,12 +49,11 @@ class IncomingOtsEventVerifier(
null,
)
// Verifies each newly-arrived attestation once and memoizes the verdict on the note itself
// (Note.otsVerification), so the OTS pill can render off a cached result without re-hitting
// the blockchain. The verdict is evicted with the note — no separate cache to keep in sync.
suspend fun consume(note: Note) {
if (note.event is OtsEvent) {
note.cacheVerifyOts(otsResolverBuilder.build())
note.event?.let { event ->
if (event is OtsEvent) {
otsVerifCache().cacheVerify(event)
}
}
}
}
@@ -1,111 +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.nip03Timestamp
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent
import com.vitorpamplona.quartz.nip03Timestamp.OtsResolver
import com.vitorpamplona.quartz.nip03Timestamp.VerificationState
import com.vitorpamplona.quartz.nip40Expiration.isExpirationBefore
import com.vitorpamplona.quartz.utils.TimeUtils
/*
* OTS (NIP-03) attestation resolution off a note's own [Note.timestamps] list. Every kind-1040
* attestation targeting a note is anchored there as a hard-referenced child (like a reaction), so
* finding a note's proofs is an in-memory fold no LocalCache scan. Each attestation memoizes its
* blockchain verdict in [Note.otsVerification], which shares the attestation note's lifecycle: it is
* evicted when the target note is (a NIP-09 delete or a cache prune), so there is no separate,
* id-keyed verification cache to keep in sync. This is the read side that replaces the old
* VerificationStateCache + full-cache scan.
*/
/** The memoized OTS verdict for this attestation note, or null when it has never been verified. */
fun Note.justOtsVerification(): VerificationState? = otsVerification
/**
* Returns the OTS verdict for this attestation note, verifying against the blockchain only when no
* usable verdict exists yet (or a stale [VerificationState.NetworkError] is due for a retry). The
* result is stored on the note so later reads are free. Must run off the main thread verification
* hits the network. No-op verdict ([VerificationState.Error]) when the note is not an OTS event.
*/
suspend fun Note.cacheVerifyOts(resolver: OtsResolver): VerificationState {
val event = event as? OtsEvent ?: return VerificationState.Error("Not an OTS event")
return when (val current = otsVerification) {
is VerificationState.Verified -> current
is VerificationState.Error -> current
is VerificationState.NetworkError ->
if (current.time < TimeUtils.fiveMinutesAgo()) verifyOts(event, resolver) else current
// null, or a leftover non-terminal state from an interrupted run: (re)verify.
else -> verifyOts(event, resolver)
}
}
/**
* Verifies the attestation and stores ONLY the terminal verdict. We deliberately never persist a
* `Verifying` sentinel: this runs inside a cancellable `LoadOts` LaunchedEffect, so if the coroutine
* were cancelled at the network suspension point after writing `Verifying` but before the verdict,
* that sentinel would stick on the (long-lived) note forever there is no LRU eviction to recover
* it, unlike the old VerificationStateCache and the OTS pill would silently vanish. Leaving the
* field untouched until a real verdict lands means a cancelled run simply retries on the next read;
* the cost is at worst two concurrent first-time verifications, which is harmless.
*/
private suspend fun Note.verifyOts(
event: OtsEvent,
resolver: OtsResolver,
): VerificationState = event.verifyState(resolver).also { otsVerification = it }
/**
* The earliest blockchain-verified time (unix seconds) among this note's non-expired OTS
* attestations, or null when none verify. Reads already-cached verdicts first so a proof that was
* verified on arrival shows without waiting on the network then verifies any still-unresolved
* proofs. Must run off the main thread.
*/
suspend fun Note.earliestOtsVerifiedTime(resolver: OtsResolver): Long? {
val now = TimeUtils.now()
var minTime: Long? = null
fun consider(time: Long) {
if (minTime.let { it == null || time < it }) minTime = time
}
val live =
timestamps.filter {
val e = it.event
e is OtsEvent && !e.isExpirationBefore(now)
}
val unresolved =
live.filter { proof ->
val verified = (proof.justOtsVerification() as? VerificationState.Verified)?.verifiedTime
if (verified != null) {
consider(verified)
false
} else {
true
}
}
unresolved.forEach { proof ->
(proof.cacheVerifyOts(resolver) as? VerificationState.Verified)?.verifiedTime?.let(::consider)
}
return minTime
}
@@ -21,48 +21,16 @@
package com.vitorpamplona.amethyst.model.nip11RelayInfo
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.produceState
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
@Composable
fun loadRelayInfo(relay: NormalizedRelayUrl): State<Nip11RelayInformation> = loadRelayInfo(relay, Amethyst.instance.nip11Cache)
/**
* Eagerly warms the NIP-11 cache for a whole set of [relays] **in parallel** (each fetch on its own
* coroutine), so callers that later read the cache e.g. the NIP-29 relay-signed group check get
* hits instead of cold fetches. Warming a set serially would sum every relay's latency and let one
* slow/unreachable relay stall the rest until its socket timeout; fanning out bounds the wait to the
* slowest single fetch. [onEachLoaded] fires (on an IO thread) as each relay resolves, so a screen
* can re-evaluate incrementally as docs arrive. The cache dedups, so re-warming is cheap.
*/
@Composable
fun WarmNip11(
relays: Collection<NormalizedRelayUrl>,
onEachLoaded: () -> Unit = {},
) {
val cache = Amethyst.instance.nip11Cache
LaunchedEffect(relays) {
coroutineScope {
relays.forEach { relay ->
launch {
cache.loadRelayInfo(
relay = relay,
onInfo = { onEachLoaded() },
onError = { _, _, _ -> onEachLoaded() },
)
}
}
}
}
}
@Composable
fun loadRelayInfo(
relay: NormalizedRelayUrl,
@@ -36,18 +36,6 @@ class Nip11CachedRetriever(
private val relayInformationDocumentCache = LruCache<NormalizedRelayUrl, RetrieveResult?>(1000)
private val retriever = Nip11Retriever(okHttpClient)
/**
* Drops any cached NIP-11 document (including a cached *error*) for [relay], so the next
* [loadRelayInfo] performs a fresh fetch. Used when the transport to a relay changes e.g. it's
* marked Trusted to move off Tor onto clearnet so a doc that failed over the old transport
* (403/timeout) isn't served from cache for the rest of its TTL, which would keep the relay's
* `self` key unknown and hide its relay-signed NIP-29 groups.
*/
fun invalidate(relay: NormalizedRelayUrl) {
relayInformationDocumentCache.remove(relay)
relayInformationEmptyCache.remove(relay)
}
fun trimToSize(maxItems: Int) {
relayInformationDocumentCache.trimToSize(maxItems)
// relayInformationEmptyCache holds only lightweight display-name+favicon-url placeholders;
@@ -99,16 +87,11 @@ class Nip11CachedRetriever(
}
is RetrieveResult.Loading -> {
// A `Loading` marker means SOME caller started a fetch — but the coroutine that
// owns it may already be gone (e.g. the composable that launched the warm-up left
// composition and its scope was cancelled mid-fetch), which would leave this marker
// stuck and valid for an hour. The old "just wait" here dropped this caller's
// callback entirely, so a screen that navigated in on top of an aborted load would
// never receive the doc and would render as if the relay had no NIP-11 (no `self`,
// no supported_nips) — hiding relay-signed NIP-29 groups until the marker expired.
// Re-fetch instead: the fetch is cheap, dedups at the HTTP layer, and guarantees
// this caller is notified.
retrieve(relay, onInfo, onError)
if (doc.isValid()) {
// just wait.
} else {
retrieve(relay, onInfo, onError)
}
}
is RetrieveResult.Error -> {
@@ -1,80 +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.nip11RelayInfo
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
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.
* "29"). Reads only the in-memory cache it never blocks on a network fetch so it returns false
* for a relay whose NIP-11 hasn't been loaded yet. Callers that need the answer to become true must
* warm the document first (e.g. `loadRelayInfo`), then re-evaluate once it resolves.
*/
fun relayAdvertisesNip(
relay: NormalizedRelayUrl,
nip: String,
): Boolean =
Amethyst.instance.nip11Cache
.getFromCache(relay)
.supported_nips
?.any { it == nip } == true
/** 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))
@@ -1,56 +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.nip46Signer
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
/** One serviced NIP-46 request, for the "recent activity" feed. */
data class Nip46ActivityEntry(
val atSeconds: Long,
val clientPubKey: HexKey,
/** The NIP-46 method (`sign_event`, `nip44_encrypt`, `get_public_key`, …). */
val method: String,
/** Event kind for a `sign_event`, else `null`. */
val kind: Int? = null,
/** `null` when the request succeeded; the error string when it failed or was denied. */
val error: String? = null,
) {
val ok: Boolean get() = error == null
}
/**
* A bounded, newest-first, in-memory log of the requests this account's signer has serviced, so the
* user can see what apps are actually doing. Not persisted across app restarts (it is a live feed,
* not an audit trail); it survives service restarts because it lives on the account's signer state.
*/
class Nip46ActivityLog(
private val capacity: Int = 100,
) {
private val _entries = MutableStateFlow<List<Nip46ActivityEntry>>(emptyList())
val entries: StateFlow<List<Nip46ActivityEntry>> = _entries
fun record(entry: Nip46ActivityEntry) {
_entries.update { (listOf(entry) + it).take(capacity) }
}
}
@@ -1,192 +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.nip46Signer
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46PermissionAuthorizer
import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppConnectResult
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp
import com.vitorpamplona.amethyst.commons.connectedApps.signers.SignerOpGrant
import com.vitorpamplona.amethyst.connectedApps.consent.SignerConnectCoordinator
import com.vitorpamplona.amethyst.connectedApps.consent.SignerConnectInfo
import com.vitorpamplona.amethyst.connectedApps.consent.SignerConsentCoordinator
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.napplet.label
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Decrypt
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Decrypt
import kotlinx.coroutines.withTimeoutOrNull
/**
* Bridges the (KMP, headless) [com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46PermissionAuthorizer]
* to the interactive consent UI. It reuses the SAME dialogs the napplet/browser signer path uses
* [SignerConnectCoordinator] (first-connect trust picker) and [SignerConsentCoordinator]
* (per-operation allow/deny) so a NIP-46 remote app prompts through one consistent surface, and
* the user's "remember" choices land in the same [com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionLedger].
*
* Runs only in the main process (the signer never runs in `:napplet`), so [Amethyst.instance] is set;
* the coordinators launch their Activity from the application context.
*/
object Nip46ConsentBridge {
/**
* Upper bound on how long a consent prompt may block the signer's single-consumer loop. A user who
* ignores the dialog eventually fails the request closed (deny / declined) instead of wedging the
* signer for every other client whose requests queue behind that one blocked prompt.
*/
private const val CONSENT_TIMEOUT_MS = 120_000L
/** First-connect consent: show the app's self-declared identity and let the user pick a trust level. */
suspend fun requestConnect(
coordinate: String,
clientPubKey: HexKey,
request: BunkerRequestConnect,
): AppConnectResult {
val context = Amethyst.instance.appContext
val meta = request.clientMetadata
val title = meta?.name?.ifBlank { null } ?: context.getString(R.string.nip46_signer_remote_app)
val domain = meta?.url?.ifBlank { null } ?: (clientPubKey.take(12) + "")
// The identity being connected to lives in the coordinate; show it as an avatar + name.
val face = accountFace(coordinate)
val info =
SignerConnectInfo(
appletTitle = title,
coordinate = coordinate,
domain = domain,
iconUrl = meta?.image,
accountName = face.name,
accountPicture = face.picture,
accountPubKey = face.pubKey,
)
// Fail closed (declined) if the prompt is never answered, so a stuck first-connect dialog can't
// hold the single-consumer loop hostage against every other client.
return withTimeoutOrNull(CONSENT_TIMEOUT_MS) {
SignerConnectCoordinator.requestConnect(context, info)
} ?: AppConnectResult.Cancelled
}
/**
* First-connect consent for the client-initiated (`nostrconnect://`) flow: like [requestConnect]
* but built from the pasted/scanned offer, and crucially it surfaces the app's declared
* [requestedOps] so the user gives informed consent before those ops are pre-granted. Returns the
* user's [AppConnectResult] (or [AppConnectResult.Cancelled] if the prompt is never answered).
*/
suspend fun requestNostrConnectConsent(
coordinate: String,
name: String?,
url: String?,
image: String?,
requestedOps: List<NostrSignerOp>,
): AppConnectResult {
val context = Amethyst.instance.appContext
val title = name?.ifBlank { null } ?: context.getString(R.string.nip46_signer_remote_app)
val domain = url?.ifBlank { null } ?: (Nip46PermissionAuthorizer.clientPubKeyOf(coordinate)?.take(12)?.plus("") ?: "")
val face = accountFace(coordinate)
val info =
SignerConnectInfo(
appletTitle = title,
coordinate = coordinate,
domain = domain,
iconUrl = image,
accountName = face.name,
accountPicture = face.picture,
accountPubKey = face.pubKey,
requestedPermissions = requestedOps.map { it.label(context) },
)
return withTimeoutOrNull(CONSENT_TIMEOUT_MS) {
SignerConnectCoordinator.requestConnect(context, info)
} ?: AppConnectResult.Cancelled
}
/**
* Per-operation consent: describe the request and await the user's grant.
*
* For a decrypt request this DECRYPTS FIRST and shows the resulting plaintext, together with the
* counterparty the conversation is with. That is what makes the decision reviewable: without it
* the dialog said only "wants to read your private messages" with no way to tell one request from
* another. Decryption is local [signer] runs on this device and nothing leaves it unless the
* user approves and it is bounded by [Nip46ConsentInfoBuilder.DECRYPT_PREVIEW_TIMEOUT_MS] so a slow or failing signer
* degrades to an explanatory message instead of hanging or blanking the prompt.
*/
suspend fun requestOp(
coordinate: String,
clientPubKey: HexKey,
op: NostrSignerOp,
request: BunkerRequest,
signer: NostrSigner,
): SignerOpGrant {
val context = Amethyst.instance.appContext
val info = runCatching { Amethyst.instance.nip46ClientStore.load(coordinate) }.getOrNull()
val title = info?.name?.ifBlank { null } ?: context.getString(R.string.nip46_signer_remote_app)
val consentInfo =
Nip46ConsentInfoBuilder.build(
coordinate = coordinate,
title = title,
iconUrl = info?.image,
op = op,
request = request,
account = accountFace(coordinate),
faceOf = ::userFace,
strings =
Nip46ConsentStrings(
opLabel = { it.label(context) },
allowAlwaysFor = { context.getString(R.string.nip46_signer_allow_always_for, it) },
decryptFailed = context.getString(R.string.nip46_signer_decrypt_failed),
),
decrypt = { decryptWithAccountSigner(signer, it) },
)
// Fail closed if the prompt is never answered so a stuck dialog can't hold the signer hostage.
return withTimeoutOrNull(CONSENT_TIMEOUT_MS) {
SignerConsentCoordinator.requestConsent(context, consentInfo)
} ?: SignerOpGrant.DenyOnce
}
/**
* Performs the local decryption behind the decrypt preview with the account's own signer. Errors
* and timeouts are handled by [Nip46ConsentInfoBuilder]; this only maps the request to a call.
*/
private suspend fun decryptWithAccountSigner(
signer: NostrSigner,
request: BunkerRequest,
): String? =
when (request) {
is BunkerRequestNip04Decrypt -> signer.nip04Decrypt(request.ciphertext, request.pubKey)
is BunkerRequestNip44Decrypt -> signer.nip44Decrypt(request.ciphertext, request.pubKey)
else -> null
}
/** The account being signed for (avatar + name), resolved from the coordinate's signer pubkey. */
private fun accountFace(coordinate: String): SignerFace {
val pubKey = Nip46PermissionAuthorizer.signerPubKeyOf(coordinate)
val user = pubKey?.let { LocalCache.getUserIfExists(it) }
return SignerFace(name = user?.toBestDisplayName(), picture = user?.profilePicture(), pubKey = pubKey)
}
/** Cached profile for a counterparty; the builder supplies the shortened-npub fallback. */
private fun userFace(pubKey: HexKey): SignerFace {
val user = LocalCache.getUserIfExists(pubKey)
return SignerFace(name = user?.toBestDisplayName(), picture = user?.profilePicture(), pubKey = pubKey)
}
}
@@ -1,176 +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.nip46Signer
import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46PermissionAuthorizer.Companion.decryptCounterparty
import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46PermissionAuthorizer.Companion.toNarrowSignerOp
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp
import com.vitorpamplona.amethyst.connectedApps.consent.SignerConsentInfo
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestSign
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.withTimeoutOrNull
/** Avatar + display name for one pubkey, as the consent dialogs render it. */
data class SignerFace(
val name: String?,
val picture: String?,
val pubKey: String?,
)
/**
* The user-visible strings the builder needs, injected rather than read from `R.string` so the
* builder itself carries no Android dependency and can be unit-tested.
*/
class Nip46ConsentStrings(
/** Human-readable label for an op, e.g. "read your private messages with Alice". */
val opLabel: (NostrSignerOp) -> String,
/** Button text for the counterparty-scoped grant; the argument is the counterparty's name. */
val allowAlwaysFor: (String) -> String,
/** Shown as the preview when Amethyst itself could not decrypt the message. */
val decryptFailed: String,
)
/**
* Builds the [SignerConsentInfo] for one NIP-46 per-operation prompt.
*
* Split out of [Nip46ConsentBridge] (which owns the Android `Context`/`LocalCache` lookups) so the
* decisions that matter for safety are testable without an emulator:
* - a decrypt request is DECRYPTED FIRST and the plaintext becomes the preview, honouring the
* contract the dialog documented but never implemented;
* - a decrypt that cannot be decrypted still produces a populated dialog, never a blank one;
* - the counterparty label is never empty it degrades to a shortened npub, never to nothing.
*/
object Nip46ConsentInfoBuilder {
/** Characters of plaintext/content shown inline before the "show more" toggle takes over. */
const val PREVIEW_MAX_CHARS = 160
/**
* Upper bound on the pre-consent decryption. Short on purpose: the preview is a nicety, the
* prompt is not, so a signer that stalls (e.g. an external NIP-55 app that is not responding)
* must not delay the dialog.
*/
const val DECRYPT_PREVIEW_TIMEOUT_MS = 8_000L
suspend fun build(
coordinate: String,
title: String,
iconUrl: String?,
op: NostrSignerOp,
request: BunkerRequest,
account: SignerFace,
/** Resolves a pubkey to a cached profile; the builder supplies its own npub fallback. */
faceOf: (HexKey) -> SignerFace,
strings: Nip46ConsentStrings,
/** Performs the local decryption. May fail, return null, or hang — all are handled. */
decrypt: suspend (BunkerRequest) -> String?,
): SignerConsentInfo {
val counterparty = request.decryptCounterparty()
val plaintext = if (counterparty != null) decryptPreview(request, decrypt, strings.decryptFailed) else null
val preview =
when {
request is BunkerRequestSign ->
request.event.content
.take(PREVIEW_MAX_CHARS)
.trim()
plaintext != null -> plaintext.take(PREVIEW_MAX_CHARS).trim()
else -> ""
}
val rawData =
when {
request is BunkerRequestSign -> JacksonMapper.toJsonPretty(request.event)
// Only worth a "show more" toggle when the preview actually truncated it.
plaintext != null && plaintext.length > PREVIEW_MAX_CHARS -> plaintext
else -> ""
}
// A decrypt grant can be scoped to one conversation: offer "always allow for Alice" next to
// the broad "always allow", instead of only the all-conversations-forever choice.
val narrowOp = request.toNarrowSignerOp()
val counterpartyFace = counterparty?.let { face(it, faceOf) }
return SignerConsentInfo(
appletTitle = title,
coordinate = coordinate,
op = op,
// For decrypt this names the counterparty ("read your private messages with Alice").
operationSummary = strings.opLabel(narrowOp ?: op),
contentPreview = preview,
rawData = rawData,
iconUrl = iconUrl,
accountName = account.name,
accountPicture = account.picture,
accountPubKey = account.pubKey,
previewTemplate = (request as? BunkerRequestSign)?.event,
counterpartyName = counterpartyFace?.name,
counterpartyPicture = counterpartyFace?.picture,
counterpartyPubKey = counterparty,
narrowOp = narrowOp,
narrowOpLabel = counterpartyFace?.name?.let { strings.allowAlwaysFor(it) },
)
}
/**
* Decrypts the message the app asked to read. Never throws and never hangs: a signer that fails,
* refuses, returns nothing, or takes too long yields [failureText], because a request whose
* ciphertext we cannot even read is itself worth showing a blank dialog is not.
*/
private suspend fun decryptPreview(
request: BunkerRequest,
decrypt: suspend (BunkerRequest) -> String?,
failureText: String,
): String =
withTimeoutOrNull(DECRYPT_PREVIEW_TIMEOUT_MS) {
try {
decrypt(request)?.ifBlank { null }
} catch (e: CancellationException) {
// Includes this block's own timeout — must propagate so withTimeoutOrNull sees it.
throw e
} catch (e: Exception) {
Log.w("NIP46Signer") { "decrypt preview failed: ${e.message}" }
null
}
} ?: failureText
/** [faceOf], but with a guaranteed non-blank name (shortened npub when the user isn't cached). */
private fun face(
pubKey: HexKey,
faceOf: (HexKey) -> SignerFace,
): SignerFace {
val resolved = runCatching { faceOf(pubKey) }.getOrNull()
return SignerFace(
name = resolved?.name?.ifBlank { null } ?: shortIdentifier(pubKey),
picture = resolved?.picture,
pubKey = pubKey,
)
}
/** A shortened npub for an uncached pubkey; falls back to the hex prefix if it isn't valid hex. */
fun shortIdentifier(pubKey: HexKey): String {
val npub = runCatching { NPub.create(pubKey) }.getOrNull()
return if (!npub.isNullOrBlank()) npub.take(12) + "" else pubKey.take(12) + ""
}
}
@@ -1,401 +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.nip46Signer
import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46ClientInfo
import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46ClientStore
import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46PermissionAuthorizer
import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppConnectResult
import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppSignerPolicy
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrOpDecision
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionLedger
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.quartz.nip01Core.core.HexKey
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.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestSign
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse
import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent
import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectURI
import com.vitorpamplona.quartz.nip46RemoteSigner.server.BunkerRequestProcessor
import com.vitorpamplona.quartz.nip46RemoteSigner.server.NostrConnectSignerService
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.RandomInstance
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
/** How many recently-serviced request ids to persist for cross-restart replay dedup. */
private const val MAX_SEEN_IDS = 128
/**
* Auto-forget a connected app after this long with no activity. Each connected NIP-46 app makes the
* signer hold a background relay subscription indefinitely, so an app paired once and abandoned would
* leak a relay connection forever; pruning idle apps bounds that growth. Last-used is stamped on
* connect and on every serviced request, so an app still in use is never pruned.
*/
private const val IDLE_PRUNE_SECONDS = 7 * 24 * 60 * 60L
/**
* Runs Amethyst as a NIP-46 remote signer ("bunker") for the account, so other
* apps can sign through it. While [AccountSettings.nip46SignerEnabled] is on, a
* [NostrConnectSignerService] listens on the user's inbox relays (plus any relays
* pulled in by a pasted `nostrconnect://` offer) for kind:24133 requests.
*
* Two keys are kept apart: a dedicated local [transportSigner] wraps/unwraps the
* kind-24133 envelope (so the bunker address never reveals the user, and an
* external NIP-55 account pays no IPC cost for envelope crypto), while the actual
* sign/encrypt/decrypt and `get_public_key` use the account's identity [signer]
* a local key or a NIP-55 external app, whichever the user logged in with.
*
* Every request is gated by [Nip46PermissionAuthorizer], i.e. the same
* "Connected Apps" trust ledger that governs napplets and web origins: a remote
* client is a connected app under the coordinate `nip46:<signerPubKey>:<clientPubKey>`.
*
* The listener restarts whenever the enabled flag or the relay set changes
* ([collectLatest] cancels the previous run), so editing inbox relays or toggling
* the feature takes effect immediately.
*/
class Nip46SignerState(
val signer: NostrSigner,
val client: INostrClient,
val ledger: NostrSignerPermissionLedger,
val clientStore: Nip46ClientStore,
val inboxRelays: StateFlow<Set<NormalizedRelayUrl>>,
val scope: CoroutineScope,
val settings: AccountSettings,
) {
/** Relays contributed by pasted `nostrconnect://` offers this session, unioned with the inbox set. */
private val extraRelays = MutableStateFlow<Set<NormalizedRelayUrl>>(emptySet())
/** Newest-first, in-memory feed of serviced requests, so the UI can show what apps are doing. */
val activityLog = Nip46ActivityLog()
/**
* A bounded, recently-serviced set of kind-24133 event ids, persisted so a relay replaying stored
* requests after an app restart is deduped by exact id (see [NostrConnectSignerService.initialSeen]).
* Touched only from the service's single consumer coroutine, so it needs no synchronization.
*/
private val recentHandledIds = LinkedHashSet(settings.nip46SeenRequestIds.value)
private fun rememberHandledId(eventId: HexKey) {
if (!recentHandledIds.add(eventId)) return
while (recentHandledIds.size > MAX_SEEN_IDS) {
recentHandledIds.iterator().let {
it.next()
it.remove()
}
}
settings.changeNip46SeenRequestIds(recentHandledIds.toSet())
}
/**
* The dedicated per-account transport signer that wraps the kind-24133 envelope a local key
* unrelated to the account identity, so the bunker address/traffic doesn't reveal who it is for,
* and (unlike the identity signer) an external NIP-55 account pays no IPC cost for envelope crypto.
* Generated + persisted lazily on first use so accounts that never enable the signer mint nothing.
*
* Rebuilt from the persisted key on every call rather than cached, so [rotateAddress] takes effect:
* the service-restart trigger includes [AccountSettings.nip46TransportKey], and this reads the
* current value deriving a keypair from stored bytes is cheap enough for the per-restart cost.
*/
private fun transportSigner(): NostrSignerInternal = NostrSignerInternal(KeyPair(ensureTransportKeyBytes()))
/** All relays the signer listens on: the account inbox plus any nostrconnect offer relays. */
val listeningRelays: StateFlow<Set<NormalizedRelayUrl>> =
combine(inboxRelays, extraRelays) { inbox, extra -> inbox + extra }
.stateIn(scope, SharingStarted.Eagerly, inboxRelays.value)
private val authorizer =
Nip46PermissionAuthorizer(
ledger = ledger,
signerPubKey = signer.pubKey,
validateSecret = { clientPubKey, offered ->
// A new app pairs with the current bunker secret; an already-connected app
// re-authenticates by identity (it already holds a trust level in the ledger).
val secret = settings.nip46BunkerSecret.value
(secret.isNotEmpty() && offered == secret) ||
ledger.hasPolicy(Nip46PermissionAuthorizer.coordinateFor(signer.pubKey, clientPubKey))
},
onConnected = { clientPubKey, request ->
// A bunker-flow client talks to us on the inbox relays we always listen on, so we
// only persist its self-declared display metadata (never as authorization — just a label).
val meta = request.clientMetadata
if (meta != null && !meta.isEmpty()) {
clientStore.store(
Nip46PermissionAuthorizer.coordinateFor(signer.pubKey, clientPubKey),
Nip46ClientInfo(name = meta.name, url = meta.url, image = meta.image),
)
}
},
clientStore = clientStore,
// A forgotten client's relays are gone from the store now; recompute the listen set so we
// stop listening on them this session instead of waiting for a restart.
onDisconnected = { refreshExtraRelaysFromStore() },
// Interactive consent through the shared signer dialogs: a trust-level picker on first
// connect, and an allow/deny prompt whenever the ledger says ASK (dangerous kinds,
// decryption, DMs, or a PARANOID app). Same surface + ledger as napplet/browser signing.
connectConsent = Nip46ConsentBridge::requestConnect,
// The account's own signer goes to the bridge so a decrypt request can be decrypted
// BEFORE the prompt — the dialog shows the actual plaintext instead of an opaque
// "wants to read your private messages". Local only; nothing is disclosed until approval.
opConsent = { coordinate, clientPubKey, op, request ->
Nip46ConsentBridge.requestOp(coordinate, clientPubKey, op, request, signer)
},
)
init {
// extraRelays is a live projection of the persisted client store (the nostrconnect apps' own
// relays). Load it on start so paired apps stay reachable across restarts; it is refreshed
// whenever a client connects or is forgotten (bunker-flow apps use the inbox relays instead).
// Prune apps idle past IDLE_PRUNE_SECONDS first so we don't re-subscribe to a relay only an
// abandoned app used — forget() already refreshes extraRelays, and we refresh again in case
// nothing was pruned.
scope.launch(Dispatchers.IO) {
runCatching { authorizer.pruneIdle(IDLE_PRUNE_SECONDS) }
.onFailure { Log.w("NIP46Signer") { "idle prune failed: ${it.message}" } }
refreshExtraRelaysFromStore()
}
scope.launch(Dispatchers.IO) {
combine(settings.nip46SignerEnabled, listeningRelays, settings.nip46TransportKey) { enabled, relays, transportKey ->
Triple(enabled, relays, transportKey)
}
// Inbox/relay/key StateFlows can re-emit an identical value; without this every duplicate
// would tear the subscription down and re-open it on every relay for no reason. Including
// the transport key here makes rotateAddress() re-subscribe under the fresh key.
.distinctUntilChanged()
.collectLatest { (enabled, relays, _) ->
if (!enabled) return@collectLatest
if (!signer.isWriteable()) {
Log.w("NIP46Signer") { "signer not writeable; cannot host a bunker" }
return@collectLatest
}
if (relays.isEmpty()) return@collectLatest
// Envelope wrapped with the local transport key; the actual work (and get_public_key)
// uses the account's identity signer inside the processor.
val processor = BunkerRequestProcessor(signer, { listeningRelays.value }, authorizer)
val service =
NostrConnectSignerService(
client = client,
transportSigner = transportSigner(),
processor = processor,
relays = relays,
onServiced = { request, clientPubKey, error ->
Log.d("NIP46Signer") { "${request.method} from ${clientPubKey.take(8)}… → ${error ?: "ok"}" }
activityLog.record(
Nip46ActivityEntry(
atSeconds = TimeUtils.now(),
clientPubKey = clientPubKey,
method = request.method,
kind = (request as? BunkerRequestSign)?.event?.kind,
error = error,
),
)
},
// Seed dedup with the ids we serviced last session so an app restart doesn't
// re-sign a relay's replay of the same stored requests (matched by exact id).
initialSeen = settings.nip46SeenRequestIds.value,
onHandledId = { id -> rememberHandledId(id) },
)
service.run()
}
}
}
/** Whether the account is currently advertising itself as a signer. */
val enabled: StateFlow<Boolean> get() = settings.nip46SignerEnabled
fun setEnabled(enabled: Boolean) {
if (enabled) {
// Settle the secret and transport key BEFORE flipping the flag, so the service-restart
// trigger sees the final transport key on its first emission (no throwaway double-start).
ensureSecret()
ensureTransportKeyBytes()
}
settings.changeNip46SignerEnabled(enabled)
}
/** The `bunker://<transport-pubkey>?relay=…&secret=…` string to paste into another app. Generates keys/secret if needed. */
fun bunkerUri(): String {
val secret = ensureSecret()
// Advertise the transport key, not the identity key, so the address doesn't reveal who we are.
return NostrConnectURI.buildBunker(transportSigner().pubKey, inboxRelays.value, secret)
}
/** Replaces the pairing secret with a fresh one, revoking the ability of not-yet-connected apps to use the old one. */
fun regenerateSecret(): String {
val fresh = RandomInstance.randomChars(32)
settings.changeNip46BunkerSecret(fresh)
return fresh
}
/**
* The anti-spam "burn it down" action: mints a brand-new transport key (and pairing secret), so
* the old `bunker://` address goes dark — anyone who had it (a spammer included) can no longer
* reach us, and every app talking to the old transport pubkey is dropped. The running service
* re-subscribes under the new key because [AccountSettings.nip46TransportKey] feeds the restart
* trigger. Legit apps re-pair by re-scanning the new address; their trust survives because the
* Connected-Apps coordinate keys off the stable identity pubkey, not the transport key.
*/
fun rotateAddress(): String {
val fresh = KeyPair()
settings.changeNip46TransportKey(fresh.privKey!!.toHexKey())
regenerateSecret()
return NostrConnectURI.buildBunker(fresh.pubKey.toHexKey(), inboxRelays.value, settings.nip46BunkerSecret.value)
}
/** Recomputes [extraRelays] from the persisted client store — the source of truth for nostrconnect relays. */
private suspend fun refreshExtraRelaysFromStore() {
extraRelays.value =
clientStore
.all()
.filterKeys { Nip46PermissionAuthorizer.belongsTo(it, signer.pubKey) }
.values
.flatMap { it.relays }
.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }
.toSet()
}
/**
* Forgets a connected client (the user's "Forget" action): revokes its grant, drops its stored
* metadata/relays, and stops listening on relays that only it used. Same path as a client-sent
* `logout`, so both are consistent.
*/
suspend fun forgetClient(clientPubKey: HexKey) = authorizer.forget(clientPubKey)
/** Returns the current pairing secret, generating and persisting one the first time. */
private fun ensureSecret(): String {
val current = settings.nip46BunkerSecret.value
if (current.isNotEmpty()) return current
val fresh = RandomInstance.randomChars(32)
settings.changeNip46BunkerSecret(fresh)
return fresh
}
/** The transport private key bytes, generating and persisting a fresh keypair the first time (or if corrupt). */
private fun ensureTransportKeyBytes(): ByteArray {
val stored = settings.nip46TransportKey.value
val existing = stored.takeIf { it.length == 64 }?.let { runCatching { it.hexToByteArray() }.getOrNull() }
if (existing != null) return existing
val fresh = KeyPair()
settings.changeNip46TransportKey(fresh.privKey!!.toHexKey())
return fresh.privKey!!
}
/**
* The client-initiated (`nostrconnect://`) pairing flow: parse a client's
* offer, send the connect ack that echoes its secret (so the client learns
* our signer pubkey), register it as a connected app, and start listening on
* its relays. Enables the signer if it was off.
*/
suspend fun connectViaNostrConnect(uri: String): ConnectResult {
val offer = NostrConnectURI.parseNostrConnect(uri) ?: return ConnectResult.InvalidUri
if (offer.relays.isEmpty()) return ConnectResult.NoRelays
if (!signer.isWriteable()) return ConnectResult.NotWriteable
val coordinate = authorizer.coordinateFor(offer.clientPubKey)
val requestedOps = Nip46PermissionAuthorizer.parsePerms(offer.perms)
val firstContact = !ledger.hasPolicy(coordinate)
// First contact: get informed consent — the app's identity, the perms it declared, and a trust
// level — BEFORE we publish the ack or grant anything. A re-pair keeps the existing trust and
// per-op decisions the user may have since changed (e.g. an op set to DENY), so it skips the prompt.
val grantedPolicy: AppSignerPolicy? =
if (firstContact) {
when (val result = Nip46ConsentBridge.requestNostrConnectConsent(coordinate, offer.name, offer.url, offer.image, requestedOps)) {
is AppConnectResult.Connected -> result.policy
AppConnectResult.Blocked, AppConnectResult.Cancelled -> return ConnectResult.Declined
}
} else {
null
}
return try {
// Echo the offer secret back to the client — authored by the transport key so the client
// learns THAT as our remote-signer pubkey (not our identity). Only after consent.
val ack = BunkerResponse(newSubId(), offer.secret, null)
val reply = NostrConnectEvent.create(ack, offer.clientPubKey, transportSigner())
client.publish(reply, offer.relays)
if (grantedPolicy != null) {
ledger.setPolicy(coordinate, grantedPolicy)
// The user just reviewed and approved these declared perms, so honor them — including
// sensitive ones — unless they chose PARANOID (ask every time, pre-grant nothing).
if (grantedPolicy != AppSignerPolicy.PARANOID) {
requestedOps.forEach { ledger.setOpDecision(coordinate, it, NostrOpDecision.ALLOW) }
}
}
ledger.updateLastUsed(coordinate)
// Persist the app's label + its relays so it survives a restart, then start listening now.
clientStore.store(
coordinate,
Nip46ClientInfo(name = offer.name, url = offer.url, image = offer.image, relays = offer.relays.map { it.url }.toSet()),
)
refreshExtraRelaysFromStore()
setEnabled(true)
ConnectResult.Connected(offer.clientPubKey, offer.name)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w("NIP46Signer") { "nostrconnect pairing failed: ${e.message}" }
ConnectResult.Failed(e.message ?: "unknown error")
}
}
sealed interface ConnectResult {
data class Connected(
val clientPubKey: String,
val name: String?,
) : ConnectResult
data object InvalidUri : ConnectResult
data object NoRelays : ConnectResult
data object NotWriteable : ConnectResult
/** The user reviewed the connect request and declined (cancelled or blocked). */
data object Declined : ConnectResult
data class Failed(
val reason: String,
) : ConnectResult
}
}
@@ -1,121 +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.nip47WalletConnect
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcInfoEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.util.concurrent.ConcurrentHashMap
/**
* Per-account cache of NWC wallets' kind 13194 info events, keyed by wallet
* service pubkey. One fetch backs every capability question we ask about a
* wallet the advertised encryption schemes (NIP-44 vs NIP-04 negotiation), the
* supported RPC methods, and whether it emits notifications.
*
* Entries expire after [ttlSeconds] (default 2 days) so a wallet that later
* changes its advertised capabilities is eventually re-checked. Reads never block
* on the network:
*
* - [current] returns whatever is cached (possibly stale, possibly null) with no
* side effect for the payment hot path.
* - [refreshIfStale] triggers a background fetch when the entry is missing or
* expired, and returns immediately call it right before using a wallet so a
* stale entry self-heals without holding up the transaction.
* - [getFresh] is the suspending variant for callers that can await (e.g. the
* notification watcher deciding whether to open a subscription).
*
* A completed fetch including a definitive "wallet published no info event"
* (null) is cached with a timestamp. A *failed* fetch (network error/timeout)
* is never cached, so a transient error retries on the next use instead of
* pinning the wallet to the fallback for the whole TTL window.
*/
class NwcInfoCache(
private val fetch: suspend (Nip47WalletConnect.Nip47URINorm) -> NwcInfoEvent?,
private val scope: CoroutineScope,
private val ttlSeconds: Long = DEFAULT_TTL_SECONDS,
private val now: () -> Long = { TimeUtils.now() },
) {
private class Entry(
val info: NwcInfoEvent?,
val fetchedAt: Long,
)
private val cache = ConcurrentHashMap<HexKey, Entry>()
private val inFlight = ConcurrentHashMap.newKeySet<HexKey>()
private fun isFresh(entry: Entry): Boolean = now() - entry.fetchedAt < ttlSeconds
/** Non-blocking read of the currently cached info event (may be stale or null). */
fun current(uri: Nip47WalletConnect.Nip47URINorm): NwcInfoEvent? = cache[uri.pubKeyHex]?.info
/**
* Non-blocking. Kicks off a background fetch when the wallet's entry is missing
* or expired; a fetch already running for that wallet is not duplicated. Safe
* to call on the hot path it never suspends.
*/
fun refreshIfStale(uri: Nip47WalletConnect.Nip47URINorm) {
val entry = cache[uri.pubKeyHex]
if (entry != null && isFresh(entry)) return
if (!inFlight.add(uri.pubKeyHex)) return
scope.launch(Dispatchers.IO) {
try {
fetchAndStore(uri)
} finally {
inFlight.remove(uri.pubKeyHex)
}
}
}
/**
* Suspends until a fresh-enough info event is available, fetching when the
* entry is missing or expired. Returns the last cached (possibly stale) value
* if the fetch fails.
*/
suspend fun getFresh(uri: Nip47WalletConnect.Nip47URINorm): NwcInfoEvent? {
val entry = cache[uri.pubKeyHex]
if (entry != null && isFresh(entry)) return entry.info
return fetchAndStore(uri)
}
private suspend fun fetchAndStore(uri: Nip47WalletConnect.Nip47URINorm): NwcInfoEvent? {
val info =
try {
fetch(uri)
} catch (e: Exception) {
if (e is CancellationException) throw e
return cache[uri.pubKeyHex]?.info // keep the old value; retry on next use
}
cache[uri.pubKeyHex] = Entry(info, now())
return info
}
companion object {
const val DEFAULT_TTL_SECONDS = 2L * 24 * 60 * 60 // 2 days
}
}
@@ -37,25 +37,14 @@ import com.vitorpamplona.quartz.nip47WalletConnect.cache.NostrWalletConnectReque
import com.vitorpamplona.quartz.nip47WalletConnect.cache.NostrWalletConnectResponseCache
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent
import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcNotificationEvent
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcTransaction
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaymentReceivedNotification
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
@@ -71,13 +60,6 @@ class NwcSignerState(
val cache: LocalCache,
val scope: CoroutineScope,
val settings: AccountSettings,
/**
* Shared cache of wallets' kind 13194 info events, used here to negotiate
* encryption. Injected by [com.vitorpamplona.amethyst.model.Account] (which
* owns the relay client). Null in tests / when unavailable requests then
* fall back to NIP-04.
*/
val infoCache: NwcInfoCache? = null,
) : INwcSignerState {
/**
* Flow of the default wallet's NWC URI, derived from multi-wallet settings.
@@ -123,31 +105,6 @@ class NwcSignerState(
NostrSignerInternal(KeyPair(it))
}
init {
// Warm the info cache in the background whenever the default wallet changes
// so the payment hot path can read the encryption preference without waiting.
scope.launch(Dispatchers.IO) {
defaultWalletUri
.filterNotNull()
.distinctUntilChanged { a, b -> a.pubKeyHex == b.pubKeyHex && a.relayUri == b.relayUri }
.collect { infoCache?.refreshIfStale(it) }
}
}
/**
* Non-blocking read of the negotiated encryption preference for a wallet.
* NIP-47 says a client "should always prefer nip44 if supported by the wallet
* service". Returns true only when the cached info event advertises `nip44_v2`;
* otherwise NIP-04 (the legacy default). Also nudges a background refresh so a
* stale/expired entry self-heals for the next transaction without blocking this
* one.
*/
private fun prefersNip44(uri: Nip47WalletConnect.Nip47URINorm?): Boolean {
uri ?: return false
infoCache?.refreshIfStale(uri)
return infoCache?.current(uri)?.encryptionSchemes()?.any { it.equals("nip44_v2", ignoreCase = true) } ?: false
}
fun hasWalletConnectSetup(): Boolean = settings.nwcWallets.value.isNotEmpty()
override fun isNIP47Author(pubKey: HexKey?): Boolean = nip47Signer.value.pubKey == pubKey
@@ -162,42 +119,6 @@ class NwcSignerState(
return zapPaymentResponseDecryptionCache.value.decryptResponse(event)
}
// Non-zap incoming payments reported by connected wallets (NIP-47
// payment_received). Buffered + drop-oldest so a burst never blocks the
// decrypt coroutine; consumers (e.g. the tray-notification poster) collect it.
private val _incomingNonZapPayments =
MutableSharedFlow<NwcTransaction>(extraBufferCapacity = 32, onBufferOverflow = BufferOverflow.DROP_OLDEST)
val incomingNonZapPayments: SharedFlow<NwcTransaction> = _incomingNonZapPayments.asSharedFlow()
/**
* Decrypts an incoming NWC notification (kind 23197/23196) with the matching
* wallet's connection secret and, when it is a non-zap `payment_received`,
* publishes its transaction to [incomingNonZapPayments]. Zap-carrying payments
* are dropped those already surface via the kind-9735 ZapNotification path.
*/
suspend fun handleIncomingNotification(event: NwcNotificationEvent) {
if (!hasWalletConnectSetup()) return
// The notification is `p`-tagged to the per-wallet client pubkey; match it
// to the wallet whose connection secret derives that key.
val clientPubKey = event.clientPubKey() ?: return
val wallet = settings.nwcWallets.value.firstOrNull { buildSigner(it.uri)?.pubKey == clientPubKey } ?: return
val walletSigner = buildSigner(wallet.uri) ?: return
val notification =
try {
event.decryptNotification(walletSigner)
} catch (e: Exception) {
if (e is CancellationException) throw e
return
}
val tx = (notification as? PaymentReceivedNotification)?.notification ?: return
if (tx.parsedMetadata()?.nostr != null) return // zap — already shown by ZapNotification
_incomingNonZapPayments.tryEmit(tx)
}
/**
* Sends a generic NIP-47 request to the default wallet.
*/
@@ -217,7 +138,7 @@ class NwcSignerState(
val walletService = walletUri ?: throw IllegalArgumentException("No NIP47 setup")
val walletSigner = buildSigner(walletService) ?: signer
val event = LnZapPaymentRequestEvent.createRequest(request, walletService.pubKeyHex, walletSigner, useNip44 = prefersNip44(walletService))
val event = LnZapPaymentRequestEvent.createRequest(request, walletService.pubKeyHex, walletSigner)
val filter =
NWCPaymentQueryState(
@@ -263,7 +184,7 @@ class NwcSignerState(
): Pair<LnZapPaymentRequestEvent, NormalizedRelayUrl> {
val walletService = defaultWalletUri.value ?: throw IllegalArgumentException("No NIP47 setup")
val event = LnZapPaymentRequestEvent.create(bolt11, walletService.pubKeyHex, nip47Signer.value, useNip44 = prefersNip44(walletService))
val event = LnZapPaymentRequestEvent.create(bolt11, walletService.pubKeyHex, nip47Signer.value)
val filter =
NWCPaymentQueryState(
@@ -25,7 +25,6 @@ import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.EventTag
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.HashtagTag
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.MuteTag
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.WordTag
@@ -58,7 +57,6 @@ class HiddenUsersState(
): LiveHiddenUsers {
val hiddenUsers = blockList.mapNotNullTo(mutableSetOf()) { if (it is UserTag) it.pubKey else null } + muteList.mapNotNull { if (it is UserTag) it.pubKey else null }
val hiddenWords = blockList.mapNotNullTo(mutableSetOf()) { if (it is WordTag) it.word else null } + muteList.mapNotNull { if (it is WordTag) it.word else null }
val hiddenHashtags = blockList.mapNotNullTo(mutableSetOf()) { if (it is HashtagTag) it.hashtag.lowercase() else null } + muteList.mapNotNull { if (it is HashtagTag) it.hashtag.lowercase() else null }
val mutedThreads = muteList.mapNotNullTo(mutableSetOf()) { if (it is EventTag) it.eventId else null }
return LiveHiddenUsers(
@@ -69,7 +67,6 @@ class HiddenUsersState(
hiddenUsers = hiddenUsers,
spammers = transientHiddenUsers,
hiddenWords = hiddenWords,
hiddenHashtags = hiddenHashtags,
maxHashtagLimit = maxHashtagLimit,
mutedThreads = mutedThreads,
)
@@ -29,7 +29,6 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.EventTag
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.HashtagTag
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.MuteTag
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.WordTag
@@ -143,39 +142,6 @@ class MuteListState(
}
}
suspend fun hideHashtag(hashtag: String): MuteListEvent {
val muteList = getMuteList()
return if (muteList != null) {
MuteListEvent.add(
earlierVersion = muteList,
mute = HashtagTag(hashtag),
isPrivate = true,
signer = signer,
)
} else {
MuteListEvent.create(
mute = HashtagTag(hashtag),
isPrivate = true,
signer = signer,
)
}
}
suspend fun showHashtag(hashtag: String): MuteListEvent? {
val muteList = getMuteList()
return if (muteList != null) {
MuteListEvent.remove(
earlierVersion = muteList,
mute = HashtagTag(hashtag),
signer = signer,
)
} else {
null
}
}
suspend fun hideThread(rootHex: HexKey): MuteListEvent {
val muteList = getMuteList()
return if (muteList != null) {
@@ -39,7 +39,6 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
import com.vitorpamplona.quartz.nip60Cashu.history.CashuSpendingHistoryEvent
import com.vitorpamplona.quartz.nip60Cashu.mintApi.DeterministicSecretFactory
@@ -314,19 +313,6 @@ class CashuWalletState(
*/
suspend fun exportP2pkPrivkeyHex(): String? = walletPrivkeyHex()
/**
* Private keys that can sign a NUT-11 P2PK witness when redeeming a pasted
* `cashuA`/`cashuB` token see [CashuWalletOps.redeemToken].
*
* `first` is the wallet's kind:17375 P2PK key (for tokens locked to our
* wallet key, e.g. an inbound nutzap handed over out-of-band). `second` is
* the account identity key, present ONLY for a local nsec signer some
* senders (e.g. Bey Wallet's P2PK send) lock ecash directly to the
* recipient's npub, and only a local key can produce that raw signature.
* A remote (NIP-46) / external (NIP-55) signer yields null there.
*/
suspend fun redeemSigningKeys(): Pair<String?, String?> = walletPrivkeyHex() to (signer as? NostrSignerInternal)?.keyPair?.privKey?.toHexKey()
private suspend fun walletPrivkeyHex(): String? =
_walletEvent.value?.let { evt ->
runCatching { evt.privkey(signer) }.getOrNull()
@@ -120,26 +120,12 @@ class BlossomServerListState(
hash: HexKey,
size: Long,
alt: String,
servers: List<String> = emptyList(),
): BlossomAuthorizationEvent = BlossomAuthorizationEvent.createUploadAuth(hash, size, alt, signer, servers)
suspend fun createBlossomMediaAuth(
hash: HexKey,
size: Long,
alt: String,
servers: List<String> = emptyList(),
): BlossomAuthorizationEvent = BlossomAuthorizationEvent.createMediaAuth(hash, size, alt, signer, servers)
): BlossomAuthorizationEvent = BlossomAuthorizationEvent.createUploadAuth(hash, size, alt, signer)
suspend fun createBlossomDeleteAuth(
hash: HexKey,
alt: String,
servers: List<String> = emptyList(),
): BlossomAuthorizationEvent = BlossomAuthorizationEvent.createDeleteAuth(hash, alt, signer, servers)
suspend fun createBlossomListAuth(
alt: String,
servers: List<String> = emptyList(),
): BlossomAuthorizationEvent = BlossomAuthorizationEvent.createListAuth(signer, alt, servers)
): BlossomAuthorizationEvent = BlossomAuthorizationEvent.createDeleteAuth(hash, alt, signer)
}
/**
@@ -1,107 +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.content.Context
import androidx.compose.runtime.Stable
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzHeldAttestations
import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.OwnerAttestation
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlin.coroutines.cancellation.CancellationException
/**
* Device-global persistence for the NIP-OA attestations this device holds
* ([BuzzHeldAttestations]), so a held credential survives an app restart instead of
* needing to be re-pasted. Uses the app-wide [sharedPreferencesDataStore] like
* [NamecoinSharedPreferences] (not per-account the store is already keyed by the agent
* pubkey each attestation authorizes).
*
* On construction it loads the saved entries into the singleton **re-verifying each
* against its agent key**, so a tampered on-disk credential is dropped rather than trusted
* then mirrors every later change back to disk. Construct once, eagerly, at startup.
*/
@Stable
class BuzzAttestationPreferences(
private val context: Context,
private val scope: CoroutineScope,
) {
private val json = Json { ignoreUnknownKeys = true }
@Serializable
private data class Entry(
val agent: HexKey,
val owner: HexKey,
val conditions: String,
val sig: HexKey,
)
init {
scope.launch {
restoreFromDisk()
// Persist on every change AFTER the initial restore (drop(1) skips the value
// present at collection start, which restoreFromDisk already wrote).
BuzzHeldAttestations.flow.drop(1).collect { persist(it) }
}
}
private suspend fun restoreFromDisk() {
try {
val raw = context.sharedPreferencesDataStore.data.first()[KEY] ?: return
val verified =
json
.decodeFromString<List<Entry>>(raw)
.mapNotNull { e ->
val attestation = OwnerAttestation(e.owner, e.conditions, e.sig)
// Only reinstate a credential that still verifies for its agent key.
if (attestation.verify(e.agent)) e.agent to attestation else null
}.toMap()
if (verified.isNotEmpty()) BuzzHeldAttestations.restore(verified)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("BuzzAttestationPrefs") { "Error reading held attestations: ${e.message}" }
}
}
private suspend fun persist(entries: Map<HexKey, OwnerAttestation>) {
try {
val list = entries.map { (agent, a) -> Entry(agent, a.ownerPubKey, a.conditions, a.sig) }
context.sharedPreferencesDataStore.edit { prefs ->
prefs[KEY] = json.encodeToString(list)
}
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("BuzzAttestationPrefs") { "Error writing held attestations: ${e.message}" }
}
}
companion object {
private val KEY = stringPreferencesKey("buzz.heldAttestations")
}
}
@@ -1,76 +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.content.Context
import androidx.compose.runtime.Stable
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringSetPreferencesKey
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelStars
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlin.coroutines.cancellation.CancellationException
/**
* Device-global persistence for the set of starred Buzz workspace channels ([BuzzChannelStars]),
* so favorites survive a restart. Mirrors [BuzzWorkspacePreferences]: app-wide (not per-account),
* loads the saved ids into the singleton on construction, then writes every later change back.
* Construct once, eagerly.
*/
@Stable
class BuzzChannelStarPreferences(
private val context: Context,
private val scope: CoroutineScope,
) {
init {
scope.launch {
restoreFromDisk()
// drop(1) skips the value present at collection start, which restoreFromDisk already wrote.
BuzzChannelStars.flow.drop(1).collect { persist(it) }
}
}
private suspend fun restoreFromDisk() {
try {
val raw = context.sharedPreferencesDataStore.data.first()[KEY] ?: return
if (raw.isNotEmpty()) BuzzChannelStars.restore(raw)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("BuzzChannelStarPrefs") { "Error reading starred channels: ${e.message}" }
}
}
private suspend fun persist(ids: Set<String>) {
try {
context.sharedPreferencesDataStore.edit { prefs -> prefs[KEY] = ids }
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("BuzzChannelStarPrefs") { "Error writing starred channels: ${e.message}" }
}
}
companion object {
private val KEY = stringSetPreferencesKey("buzz.starredChannels")
}
}
@@ -1,88 +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.content.Context
import androidx.compose.runtime.Stable
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringSetPreferencesKey
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaces
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlin.coroutines.cancellation.CancellationException
/**
* Device-global persistence for the set of joined `block/buzz` workspaces ([BuzzWorkspaces]),
* so the app knows which relays to connect + NIP-42-authenticate + run member-channel discovery
* against on a cold start Buzz membership is server-side (granted by the HTTP invite claim),
* with no NIP-51/kind-10009 join event to rebuild the set from. Uses the app-wide
* [sharedPreferencesDataStore] like [BuzzAttestationPreferences] (not per-account: a joined
* relay is workspace-wide, and restoring only marks relays to sync the relay still gates every
* read/write by the authenticated key).
*
* On construction it loads the saved relay URLs into the singleton (re-normalizing each, dropping
* any that no longer parse), then mirrors every later change back to disk. Construct once, eagerly.
*/
@Stable
class BuzzWorkspacePreferences(
private val context: Context,
private val scope: CoroutineScope,
) {
init {
scope.launch {
restoreFromDisk()
// Persist on every change AFTER the initial restore (drop(1) skips the value present
// at collection start, which restoreFromDisk already wrote).
BuzzWorkspaces.flow.drop(1).collect { persist(it) }
}
}
private suspend fun restoreFromDisk() {
try {
val raw = context.sharedPreferencesDataStore.data.first()[KEY] ?: return
val relays = raw.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }.toSet()
if (relays.isNotEmpty()) BuzzWorkspaces.restore(relays)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("BuzzWorkspacePrefs") { "Error reading joined workspaces: ${e.message}" }
}
}
private suspend fun persist(relays: Set<NormalizedRelayUrl>) {
try {
context.sharedPreferencesDataStore.edit { prefs ->
prefs[KEY] = relays.map { it.url }.toSet()
}
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("BuzzWorkspacePrefs") { "Error writing joined workspaces: ${e.message}" }
}
}
companion object {
private val KEY = stringSetPreferencesKey("buzz.joinedWorkspaces")
}
}

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