mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-07-31 20:16:16 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
722d9526a1 |
@@ -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+202A–U+202E, the isolates U+2066–U+2069, U+200E/U+200F, U+061C —
|
||||
plus zero-width characters (U+200B–U+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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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, 11–19, 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, 11–19, 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.
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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']
|
||||
});
|
||||
@@ -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']
|
||||
});
|
||||
|
||||
@@ -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']
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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/
|
||||
|
||||
Generated
+1
-1
@@ -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
@@ -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 (~25–30 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
|
||||
|
||||
---
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ Join the social network you control.
|
||||
[](https://central.sonatype.com/artifact/com.vitorpamplona.quartz/quartz)
|
||||
[](https://jitpack.io/#vitorpamplona/amethyst)
|
||||
[](https://github.com/vitorpamplona/amethyst/actions/workflows/build.yml)
|
||||
[](/LICENSE)
|
||||
[](/LICENSE)
|
||||
[](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
@@ -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 (~25–30 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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 39000–39003 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 39000–39003; 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 30–70s, 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 |
|
||||
| 36–70s | 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 30–70s *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 4–5 minutes (most are 15–30 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 |
|
||||
|---|---|
|
||||
| ~25–35s | `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 |
|
||||
| ~55–70s | `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 30–70s 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
|
||||
200–500ms, 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
|
||||
5–10k 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` (39000–39005 + 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 1–6 + 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 C1–C9 + 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 C1–C9 pass (esp. **C3**).
|
||||
- Tier D1–D9 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
|
||||
(12–15).
|
||||
|
||||
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 26–35) 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.
|
||||
@@ -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 26–28 (Android 8–9):** `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 26–28 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 8–11** — 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 26–30 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 8–11.
|
||||
- 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 8–11" 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 8–11**,
|
||||
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 8–11 the throw would land on the first
|
||||
iteration, before any registration exists:
|
||||
|
||||
- "location is dead on Android 8–11" — **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 2–6 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 20648–20663 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 20659–20665 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 1–6 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:41–46, 15:50:47–52, 15:52:07–15:53:05, 15:53:17–22), 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.
|
||||
@@ -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()))
|
||||
|
||||
+1
-2
@@ -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()
|
||||
}
|
||||
|
||||
-250
@@ -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
|
||||
}
|
||||
}
|
||||
-113
@@ -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)
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -97,7 +97,7 @@ class EventSyncTest {
|
||||
RelayAuthenticator(
|
||||
newClient,
|
||||
appScope,
|
||||
signWithAllLoggedInUsers = { _, authTemplate, _ ->
|
||||
signWithAllLoggedInUsers = { authTemplate ->
|
||||
listOf(signer.sign(authTemplate))
|
||||
},
|
||||
)
|
||||
|
||||
+2
-2
@@ -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" }
|
||||
|
||||
-7
@@ -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
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
-68
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
-591
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-175
@@ -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"
|
||||
}
|
||||
-141
@@ -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
|
||||
}
|
||||
-128
@@ -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
|
||||
|
||||
-21
@@ -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
|
||||
}
|
||||
}
|
||||
+1
-45
@@ -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()
|
||||
}
|
||||
|
||||
-90
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
-7
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-111
@@ -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
|
||||
}
|
||||
-32
@@ -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,
|
||||
|
||||
+5
-22
@@ -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 -> {
|
||||
|
||||
-80
@@ -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))
|
||||
-56
@@ -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) }
|
||||
}
|
||||
}
|
||||
-192
@@ -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)
|
||||
}
|
||||
}
|
||||
-176
@@ -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) + "…"
|
||||
}
|
||||
}
|
||||
-401
@@ -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
|
||||
}
|
||||
}
|
||||
-121
@@ -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
|
||||
}
|
||||
}
|
||||
+2
-81
@@ -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,
|
||||
)
|
||||
|
||||
-34
@@ -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) {
|
||||
|
||||
-14
@@ -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()
|
||||
|
||||
+2
-16
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
-107
@@ -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")
|
||||
}
|
||||
}
|
||||
-76
@@ -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")
|
||||
}
|
||||
}
|
||||
-88
@@ -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
Reference in New Issue
Block a user