mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-03 21:36:17 +00:00
Compare 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
|
||||
@@ -221,10 +153,8 @@ 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"/ {
|
||||
@@ -243,9 +173,7 @@ done
|
||||
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
|
||||
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*) continue ;;
|
||||
@@ -332,48 +260,14 @@ 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`)
|
||||
|
||||
@@ -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,157 +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.
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [released]
|
||||
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.release.tag_name || inputs.tag }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
sync-formula:
|
||||
if: github.event_name == 'workflow_dispatch' || github.event.release.prerelease == false
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Re-assert stable release
|
||||
uses: ./.github/actions/assert-stable-release
|
||||
with:
|
||||
tag: ${{ github.event.release.tag_name || inputs.tag }}
|
||||
is_prerelease: ${{ github.event.release.prerelease || 'false' }}
|
||||
is_draft: ${{ github.event.release.draft || 'false' }}
|
||||
|
||||
- name: Resolve version
|
||||
id: ver
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${{ github.event.release.tag_name || inputs.tag }}"
|
||||
VER="${TAG#v}"
|
||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "ver=$VER" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Download jvm bundle and compute sha256
|
||||
id: asset
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${{ steps.ver.outputs.tag }}"
|
||||
VER="${{ steps.ver.outputs.ver }}"
|
||||
URL="https://github.com/${{ github.repository }}/releases/download/${TAG}/amy-${VER}-jvm.tar.gz"
|
||||
echo "Fetching $URL"
|
||||
# The `released` event can fire a hair before every matrix leg finishes
|
||||
# uploading; retry with backoff (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.ver.outputs.tag }}
|
||||
add-paths: cli/packaging/homebrew/amy.rb
|
||||
commit-message: 'chore: sync amy Homebrew formula to ${{ steps.ver.outputs.tag }}'
|
||||
title: 'chore: sync amy Homebrew formula to ${{ steps.ver.outputs.tag }}'
|
||||
body: |
|
||||
Auto-synced `cli/packaging/homebrew/amy.rb` to the
|
||||
`${{ steps.ver.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
|
||||
# step here that opens the homebrew-core bump PR automatically — symmetric to
|
||||
# the cask bump in bump-homebrew.yml (a pinned macauley/action-homebrew-bump-
|
||||
# formula, or `brew bump-formula-pr amy --url=<url> --sha256=<sha>` with a
|
||||
# HOMEBREW_TOKEN). It is intentionally omitted until then because
|
||||
# bump-formula-pr errors on a formula that is not yet in the tap.
|
||||
|
||||
- name: Report failure
|
||||
if: failure()
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
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] 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']
|
||||
});
|
||||
@@ -15,10 +15,6 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
# The "Report failure" step below opens a [release-ops] issue via
|
||||
# github.rest.issues.create; that needs issues:write. Without it the failure
|
||||
# reporter itself 403s and no alert is ever filed.
|
||||
issues: write
|
||||
|
||||
concurrency:
|
||||
# Serialize bumps per tag; do not cancel in-progress bumps.
|
||||
|
||||
@@ -12,10 +12,6 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
# The "Report failure" step below opens a [release-ops] issue via
|
||||
# github.rest.issues.create; that needs issues:write. Without it the failure
|
||||
# reporter itself 403s and no alert is ever filed.
|
||||
issues: write
|
||||
|
||||
concurrency:
|
||||
group: bump-winget-${{ github.event.release.tag_name || inputs.tag }}
|
||||
|
||||
@@ -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
|
||||
@@ -180,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
|
||||
|
||||
@@ -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/
|
||||
|
||||
+2
-93
@@ -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 +
|
||||
@@ -629,18 +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` | `~/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/` |
|
||||
|
||||
Uninstall:
|
||||
|
||||
@@ -649,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)
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)
|
||||
@@ -90,10 +87,8 @@ android {
|
||||
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.
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
+1
-1
@@ -97,7 +97,7 @@ class EventSyncTest {
|
||||
RelayAuthenticator(
|
||||
newClient,
|
||||
appScope,
|
||||
signWithAllLoggedInUsers = { _, authTemplate, _ ->
|
||||
signWithAllLoggedInUsers = { authTemplate ->
|
||||
listOf(signer.sign(authTemplate))
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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,16 +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>
|
||||
|
||||
<intent-filter android:label="zap.stream">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
@@ -365,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"
|
||||
@@ -402,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">
|
||||
@@ -481,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
|
||||
|
||||
/**
|
||||
@@ -98,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)
|
||||
@@ -117,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
|
||||
@@ -166,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
|
||||
@@ -54,8 +45,8 @@ import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilde
|
||||
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
|
||||
@@ -74,43 +65,25 @@ 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.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
|
||||
@@ -120,20 +93,14 @@ 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
|
||||
@@ -151,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
|
||||
@@ -172,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
|
||||
@@ -243,7 +204,7 @@ class AppModules(
|
||||
// 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, onListening = { locationSession.setActive(it) })
|
||||
LocationState(appContext, applicationIOScope)
|
||||
}
|
||||
val connManager = ConnectivityManager(appContext, applicationIOScope)
|
||||
|
||||
@@ -252,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
|
||||
@@ -286,109 +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() }
|
||||
|
||||
// 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(
|
||||
@@ -408,11 +270,10 @@ class AppModules(
|
||||
master && !profileOnly && localBlossomCacheProbe.available.value
|
||||
},
|
||||
onionCache = onionLocationCache,
|
||||
usageInterceptor = httpUsageInterceptor,
|
||||
)
|
||||
|
||||
// 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")
|
||||
@@ -642,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
|
||||
@@ -687,45 +532,18 @@ 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)
|
||||
|
||||
@@ -743,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
|
||||
@@ -781,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(
|
||||
@@ -795,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(
|
||||
@@ -852,10 +592,6 @@ class AppModules(
|
||||
cache = cache,
|
||||
client = client,
|
||||
rootFilesDir = { appContext.filesDir },
|
||||
powQueue = { powPublishQueue },
|
||||
meterSigner = { MeteringNostrSigner(it, resourceUsage) },
|
||||
signerPermissionStore = signerPermissionStore,
|
||||
nip46ClientStore = nip46ClientStore,
|
||||
)
|
||||
|
||||
val sessionManager =
|
||||
@@ -934,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.
|
||||
@@ -1011,9 +743,7 @@ 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,
|
||||
)
|
||||
@@ -1024,10 +754,6 @@ class AppModules(
|
||||
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
|
||||
@@ -1096,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.
|
||||
@@ -1233,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()
|
||||
@@ -1242,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,10 +25,7 @@ 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
|
||||
@@ -38,10 +35,8 @@ 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
|
||||
@@ -57,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
|
||||
@@ -65,7 +59,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.nip65RelayList.AdvertisedRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent
|
||||
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
|
||||
@@ -75,7 +68,6 @@ 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
|
||||
@@ -99,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"
|
||||
@@ -112,20 +99,13 @@ 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"
|
||||
@@ -170,27 +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"
|
||||
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"
|
||||
|
||||
@@ -228,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 =
|
||||
@@ -494,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))
|
||||
@@ -509,7 +426,6 @@ 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))
|
||||
@@ -582,11 +498,7 @@ 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_CASHU_WALLET, settings.backupCashuWallet)
|
||||
putOrRemove(PrefKeys.LATEST_NUTZAP_INFO, settings.backupNutzapInfo)
|
||||
@@ -597,13 +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))
|
||||
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
|
||||
@@ -714,13 +619,7 @@ 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)
|
||||
@@ -729,14 +628,7 @@ object LocalPreferences {
|
||||
val defaultRelayAuthPolicy =
|
||||
getString(PrefKeys.DEFAULT_RELAY_AUTH_POLICY, null)
|
||||
?.let { runCatching { RelayAuthPolicy.valueOf(it) }.getOrNull() }
|
||||
?: RelayAuthPolicy.CUSTOM
|
||||
val relayGroupViewMode = RelayGroupViewMode.fromName(getString(PrefKeys.RELAY_GROUP_VIEW_MODE, null))
|
||||
val concordViewMode = ConcordViewMode.fromName(getString(PrefKeys.CONCORD_VIEW_MODE, null))
|
||||
val enabledChatFeeds = ChatFeedType.ALL - ChatFeedType.decode(getString(PrefKeys.DISABLED_CHAT_FEEDS, null))
|
||||
val relayAuthTrustMyRelays = getBoolean(PrefKeys.RELAY_AUTH_TRUST_MY_RELAYS, true)
|
||||
val relayAuthTrustReadFollows = getBoolean(PrefKeys.RELAY_AUTH_TRUST_READ_FOLLOWS, true)
|
||||
val relayAuthTrustMessageFollows = getBoolean(PrefKeys.RELAY_AUTH_TRUST_MESSAGE_FOLLOWS, true)
|
||||
val relayAuthTrustMessageStrangers = getBoolean(PrefKeys.RELAY_AUTH_TRUST_MESSAGE_STRANGERS, false)
|
||||
?: 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()
|
||||
@@ -771,11 +663,7 @@ 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 latestCashuWalletStr = getString(PrefKeys.LATEST_CASHU_WALLET, null)
|
||||
val latestNutzapInfoStr = getString(PrefKeys.LATEST_NUTZAP_INFO, null)
|
||||
@@ -834,11 +722,7 @@ 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 latestCashuWallet =
|
||||
async {
|
||||
@@ -889,11 +773,7 @@ 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 latestCashuWalletResolved = latestCashuWallet.await()
|
||||
val latestNutzapInfoResolved = latestNutzapInfo.await()
|
||||
@@ -909,20 +789,13 @@ 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),
|
||||
@@ -960,13 +833,6 @@ object LocalPreferences {
|
||||
hideNIP17WarningDialog = hideNIP17WarningDialog,
|
||||
alwaysOnNotificationService = MutableStateFlow(alwaysOnNotificationService),
|
||||
defaultRelayAuthPolicy = MutableStateFlow(defaultRelayAuthPolicy),
|
||||
relayGroupViewMode = MutableStateFlow(relayGroupViewMode),
|
||||
concordViewMode = MutableStateFlow(concordViewMode),
|
||||
enabledChatFeeds = MutableStateFlow(enabledChatFeeds),
|
||||
relayAuthTrustMyRelaysAndVenues = MutableStateFlow(relayAuthTrustMyRelays),
|
||||
relayAuthTrustReadFollows = MutableStateFlow(relayAuthTrustReadFollows),
|
||||
relayAuthTrustMessageFollows = MutableStateFlow(relayAuthTrustMessageFollows),
|
||||
relayAuthTrustMessageStrangers = MutableStateFlow(relayAuthTrustMessageStrangers),
|
||||
splitNotificationsEnabled = MutableStateFlow(splitNotificationsEnabled),
|
||||
showMessagesInNotifications = MutableStateFlow(showMessagesInNotifications),
|
||||
backupUserMetadata = latestUserMetadataResolved,
|
||||
@@ -986,11 +852,7 @@ 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),
|
||||
@@ -1028,7 +890,6 @@ object LocalPreferences {
|
||||
val discovery: TopFilter,
|
||||
val polls: TopFilter,
|
||||
val pictures: TopFilter,
|
||||
val relayGroupsDiscovery: TopFilter,
|
||||
val napplets: TopFilter,
|
||||
val nsites: TopFilter,
|
||||
val workouts: TopFilter,
|
||||
@@ -1085,7 +946,6 @@ 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),
|
||||
|
||||
-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,24 +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.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
|
||||
@@ -65,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
|
||||
@@ -127,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 ")
|
||||
|
||||
@@ -194,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].
|
||||
@@ -267,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
|
||||
@@ -298,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,
|
||||
@@ -326,20 +268,8 @@ class AccountSettings(
|
||||
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),
|
||||
// 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())
|
||||
@@ -354,34 +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()
|
||||
}
|
||||
}
|
||||
|
||||
// ---
|
||||
// Always-on Notification Service
|
||||
// ---
|
||||
@@ -647,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)
|
||||
@@ -683,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()
|
||||
@@ -705,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
|
||||
// ---
|
||||
@@ -805,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)
|
||||
}
|
||||
@@ -1384,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
|
||||
|
||||
@@ -1683,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,8 +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.screen.loggedIn.notifications.equalImmutableLists
|
||||
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
@@ -74,11 +72,6 @@ class AccountSyncedSettings(
|
||||
AccountChatPreferences(
|
||||
MutableStateFlow(internalSettings.chats.toChatroomKeys()),
|
||||
)
|
||||
val proofOfWork =
|
||||
AccountPoWPreferences(
|
||||
MutableStateFlow(internalSettings.proofOfWork.difficulty),
|
||||
MutableStateFlow(PoWCategory.fromIds(internalSettings.proofOfWork.enabledCategories)),
|
||||
)
|
||||
|
||||
fun toInternal(): AccountSyncedSettingsInternal =
|
||||
AccountSyncedSettingsInternal(
|
||||
@@ -111,14 +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(),
|
||||
),
|
||||
)
|
||||
|
||||
fun updateFrom(syncedSettingsInternal: AccountSyncedSettingsInternal) {
|
||||
@@ -197,19 +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)
|
||||
}
|
||||
}
|
||||
|
||||
fun dontTranslateFromFilteredBySpokenLanguages(): Set<String> = languages.dontTranslateFrom.value - getLanguagesSpokenByUser()
|
||||
@@ -313,43 +285,6 @@ 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
|
||||
|
||||
-10
@@ -22,7 +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.quartz.nip57Zaps.LnZapEvent
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.util.Locale
|
||||
@@ -158,7 +157,6 @@ class AccountSyncedSettingsInternal(
|
||||
val videoPlayer: AccountVideoPlayerPreferencesInternal = AccountVideoPlayerPreferencesInternal(),
|
||||
val media: AccountMediaPreferencesInternal = AccountMediaPreferencesInternal(),
|
||||
val chats: AccountChatPreferencesInternal = AccountChatPreferencesInternal(),
|
||||
val proofOfWork: AccountPoWPreferencesInternal = AccountPoWPreferencesInternal(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@@ -208,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,12 +82,10 @@ 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
|
||||
|
||||
Log.w("Duplicated/SPAM") { "${relay?.url} $link1 $link2" }
|
||||
|
||||
@@ -113,10 +111,8 @@ 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
|
||||
|
||||
Log.w("Duplicated/SPAM") { "${relay?.url} $link1 $link2" }
|
||||
|
||||
@@ -153,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,
|
||||
)
|
||||
|
||||
@@ -30,11 +30,8 @@ import com.vitorpamplona.amethyst.commons.model.Channel
|
||||
import com.vitorpamplona.amethyst.commons.model.OnchainZapStatus
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.LargeSoftCache
|
||||
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.geohashChat.GeohashChatChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.observables.CreatedAtIdHexComparator
|
||||
import com.vitorpamplona.amethyst.commons.model.observables.EventListMatchingFilter
|
||||
@@ -50,8 +47,6 @@ import com.vitorpamplona.amethyst.model.nipBCOnchainZaps.OnchainZapResolver
|
||||
import com.vitorpamplona.amethyst.service.BundledInsert
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.ui.note.dateFormatter
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId
|
||||
import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent
|
||||
import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent
|
||||
import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent
|
||||
@@ -61,7 +56,6 @@ 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.bitchat.geohash.GeohashChatEvent
|
||||
import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent
|
||||
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
|
||||
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
|
||||
@@ -158,24 +152,6 @@ import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMuteUserEvent
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.groupId
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupAdminsEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupParticipantsEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupPinnedEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.SupportedRolesEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateGroupEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateInviteEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.DeleteEventEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.DeleteGroupEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.EditMetadataEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.PutUserEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.RemoveUserEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.UpdatePinListEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.request.JoinRequestEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.request.LeaveRequestEvent
|
||||
import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent
|
||||
import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEvent
|
||||
import com.vitorpamplona.quartz.nip31Alts.AltTag
|
||||
@@ -219,7 +195,6 @@ import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.relaySets.RelaySetEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.releaseArtifactSet.ReleaseArtifactSetEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.SimpleGroupListEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent
|
||||
@@ -244,7 +219,6 @@ import com.vitorpamplona.quartz.nip58Badges.accepted.AcceptedBadgeSetEvent
|
||||
import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent
|
||||
import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent
|
||||
import com.vitorpamplona.quartz.nip58Badges.profile.ProfileBadgesEvent
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.HasInnerEvent
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
|
||||
import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent
|
||||
@@ -279,7 +253,6 @@ import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefiniti
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent
|
||||
import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent
|
||||
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
|
||||
import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent
|
||||
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
|
||||
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent
|
||||
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent
|
||||
@@ -361,9 +334,6 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
val publicChatChannels = LargeCache<HexKey, PublicChatChannel>()
|
||||
val liveChatChannels = LargeCache<Address, LiveActivitiesChannel>()
|
||||
val ephemeralChannels = LargeCache<RoomId, EphemeralChatChannel>()
|
||||
val geohashChannels = LargeCache<String, GeohashChatChannel>()
|
||||
val relayGroupChannels = LargeCache<GroupId, RelayGroupChannel>()
|
||||
val concordChannels = LargeCache<ConcordChannelId, ConcordChannel>()
|
||||
|
||||
val paymentTracker = NwcPaymentTracker()
|
||||
|
||||
@@ -637,13 +607,6 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
|
||||
fun getEphemeralChatChannelIfExists(key: RoomId): EphemeralChatChannel? = ephemeralChannels.get(key)
|
||||
|
||||
fun getGeohashChannelIfExists(geohash: String): GeohashChatChannel? = geohashChannels.get(geohash)
|
||||
|
||||
fun getRelayGroupChannelIfExists(key: GroupId): RelayGroupChannel? = relayGroupChannels.get(key)
|
||||
|
||||
/** Every relay group we know of that is hosted on [relay] (its channel directory). */
|
||||
fun getRelayGroupChannelsOnRelay(relay: NormalizedRelayUrl): List<RelayGroupChannel> = relayGroupChannels.filter { key, _ -> key.relayUrl == relay }
|
||||
|
||||
fun getLiveActivityChannelIfExists(key: Address): LiveActivitiesChannel? = liveChatChannels.get(key)
|
||||
|
||||
fun getNoteIfExists(event: Event): Note? =
|
||||
@@ -732,85 +695,6 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
|
||||
fun getOrCreateEphemeralChannel(key: RoomId): EphemeralChatChannel = ephemeralChannels.getOrCreate(key) { EphemeralChatChannel(key) }
|
||||
|
||||
fun getOrCreateGeohashChannel(geohash: String): GeohashChatChannel = geohashChannels.getOrCreate(geohash) { GeohashChatChannel(geohash) }
|
||||
|
||||
fun getOrCreateRelayGroupChannel(key: GroupId): RelayGroupChannel = relayGroupChannels.getOrCreate(key) { RelayGroupChannel(key) }
|
||||
|
||||
fun getConcordChannelIfExists(key: ConcordChannelId): ConcordChannel? = concordChannels.get(key)
|
||||
|
||||
fun getOrCreateConcordChannel(key: ConcordChannelId): ConcordChannel = concordChannels.getOrCreate(key) { ConcordChannel(key) }
|
||||
|
||||
/**
|
||||
* Lands a decrypted Concord chat rumor in the cache as a real Note and, for
|
||||
* message-like kinds, attaches it to its channel so the shared chat feed and
|
||||
* the Messages inbox render it (with previews, threading, OTS, reactions/zaps
|
||||
* reusing the same id-keyed machinery as every other chat). Reactions (kind 7),
|
||||
* deletes (kind 5), etc. are consumed too — they wire to their target Note by
|
||||
* `e`-tag through [justConsume] — but are not themselves added as channel rows.
|
||||
*
|
||||
* Fed by [com.vitorpamplona.amethyst.commons.model.concord.ConcordSessionManager]
|
||||
* once a wrap decrypts + validates against the folded Control Plane.
|
||||
*/
|
||||
fun consumeConcordRumor(
|
||||
communityId: String,
|
||||
channelIdHex: String,
|
||||
rumor: Event,
|
||||
seenOnRelays: Set<NormalizedRelayUrl> = emptySet(),
|
||||
) {
|
||||
// Attach to the channel BEFORE justConsume sets the event and notifies feeds,
|
||||
// so the note already carries its ConcordChannel gatherer when it flows through
|
||||
// the Messages-list incremental filter (which routes rows by that gatherer).
|
||||
val messageRow =
|
||||
if (rumor is ChatEvent || rumor is CommentEvent) {
|
||||
val ch = getOrCreateConcordChannel(ConcordChannelId(communityId, channelIdHex))
|
||||
val note = getOrCreateNote(rumor.id)
|
||||
// Skip attaching a row for a message we already know is deleted (its kind-5 delete
|
||||
// was processed first). Otherwise every reproject — which re-emits the whole wrap
|
||||
// buffer — would re-add then re-remove it, churning the feed. justConsume still
|
||||
// records the (already-known) deletion below; a delete arriving LATER is handled by
|
||||
// the normal deletion cascade unlinking the note from its gatherers.
|
||||
if (!deletionIndex.hasBeenDeleted(rumor)) ch.addNote(note)
|
||||
ch to note
|
||||
} else {
|
||||
null
|
||||
}
|
||||
// wasVerified = true: a Concord rumor is unsigned (its `sig` is empty), so a signature
|
||||
// check would fail and the event would never load onto its Note — leaving the chat row
|
||||
// stuck on the "loading / not found" placeholder. Its authenticity is already established
|
||||
// by the envelope open path (ConcordStreamEnvelope.open verifies the seal signature,
|
||||
// binds rumor.pubKey == seal.pubKey, and checks rumor.verifyId()), exactly like a NIP-59
|
||||
// gift-wrapped DM rumor, so we consume it as pre-verified.
|
||||
justConsume(rumor, null, true)
|
||||
|
||||
// A Concord rumor is decrypted locally with the plane key, so it arrives with no
|
||||
// per-relay attribution (relay = null above). Stamp the relays its carrying wrap was
|
||||
// seen on — mirroring NIP-17's addRelayToNoteAndInners — so the chat UI can show where
|
||||
// the message actually came from, not just the channel's configured relays.
|
||||
if (seenOnRelays.isNotEmpty()) {
|
||||
getNoteIfExists(rumor.id)?.takeIf { it.event != null }?.let { note ->
|
||||
seenOnRelays.forEach { note.addRelay(it) }
|
||||
}
|
||||
}
|
||||
|
||||
// justConsume bails without loading the event when the rumor has already been deleted
|
||||
// (a kind-5 delete referencing it was processed first — easy to hit in Concord because a
|
||||
// reproject re-emits the whole wrap buffer and ordering isn't guaranteed) or fails to
|
||||
// verify. We attached the row up front, so an unpopulated note would otherwise linger as a
|
||||
// permanent "Event is loading…" ghost. Drop it; the reverse order (delete after the message)
|
||||
// is already handled by the normal deletion cascade unlinking the note from its gatherers.
|
||||
messageRow?.let { (ch, note) ->
|
||||
if (note.event == null) {
|
||||
ch.removeNote(note)
|
||||
} else {
|
||||
// The row was attached (addNote) BEFORE justConsume set the event, so addNote saw a
|
||||
// null createdAt and could not pick lastNote or order the feed. The event is loaded
|
||||
// now — refresh so the channel's last-message preview, unread count, and ordering are
|
||||
// correct (otherwise lastNote stays null forever and every row reads "No messages yet").
|
||||
ch.refreshAfterEventLoad(note)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun checkGetOrCreatePublicChatChannel(key: String): PublicChatChannel? {
|
||||
if (isValidHex(key)) {
|
||||
return getOrCreatePublicChatChannel(key)
|
||||
@@ -896,11 +780,7 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
|
||||
if (relay != null) {
|
||||
author.addRelayBeingUsed(relay, event.createdAt)
|
||||
// A gift wrap re-delivered by another relay is a duplicate (returns
|
||||
// false below and is never re-processed), so drill into the already
|
||||
// unwrapped chain here — otherwise the relay never reaches the
|
||||
// rumor note that the chat UI actually renders.
|
||||
addRelayToNoteAndInners(note, relay)
|
||||
note.addRelay(relay)
|
||||
}
|
||||
|
||||
// Already processed this event.
|
||||
@@ -1495,7 +1375,6 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
is LiveActivitiesChatMessageEvent -> noteEvent.activityAddress()?.let { getLiveActivityChannelIfExists(it) }
|
||||
is LiveActivitiesEvent -> getLiveActivityChannelIfExists(noteEvent.address())
|
||||
is EphemeralChatEvent -> noteEvent.roomId()?.let { getEphemeralChatChannelIfExists(it) }
|
||||
is GeohashChatEvent -> noteEvent.geohash()?.let { getGeohashChannelIfExists(it) }
|
||||
else -> null
|
||||
}
|
||||
|
||||
@@ -1555,11 +1434,6 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
): Boolean {
|
||||
val note = getOrCreateNote(event.id)
|
||||
|
||||
if (relay != null) {
|
||||
getOrCreateUser(event.pubKey).addRelayBeingUsed(relay, event.createdAt)
|
||||
note.addRelay(relay)
|
||||
}
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event != null) return false
|
||||
|
||||
@@ -1590,11 +1464,6 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
): Boolean {
|
||||
val note = getOrCreateNote(event.id)
|
||||
|
||||
if (relay != null) {
|
||||
getOrCreateUser(event.pubKey).addRelayBeingUsed(relay, event.createdAt)
|
||||
note.addRelay(relay)
|
||||
}
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event != null) return false
|
||||
|
||||
@@ -1626,14 +1495,6 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
): Boolean {
|
||||
val note = getOrCreateNote(event.id)
|
||||
|
||||
// Approval notes render their own relay list directly in community feeds
|
||||
// (there is no repost-style indirection to a replyTo for them), so without
|
||||
// this attribution the "accepted by relays" gallery line stays empty forever.
|
||||
if (relay != null) {
|
||||
getOrCreateUser(event.pubKey).addRelayBeingUsed(relay, event.createdAt)
|
||||
note.addRelay(relay)
|
||||
}
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event != null) return false
|
||||
|
||||
@@ -1861,216 +1722,6 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
return new
|
||||
}
|
||||
|
||||
/**
|
||||
* Public geohash chat message (kind 20000). Routes into the cell's
|
||||
* [GeohashChatChannel]. Presence (kind 20001) is deliberately NOT consumed
|
||||
* here — it is an empty-content heartbeat handled by the live chat screen, so
|
||||
* it never becomes a room's "last message".
|
||||
*/
|
||||
fun consume(
|
||||
event: GeohashChatEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
): Boolean {
|
||||
val geohash = event.geohash() ?: return false
|
||||
|
||||
val new = consumeRegularEvent(event, relay, wasVerified)
|
||||
|
||||
if (new) {
|
||||
val note = getOrCreateNote(event.id)
|
||||
val channel = getOrCreateGeohashChannel(geohash)
|
||||
channel.addNote(note, relay)
|
||||
}
|
||||
|
||||
return new
|
||||
}
|
||||
|
||||
/**
|
||||
* NIP-29 addressables (kinds 39000-39005) are authoritative for a group's metadata,
|
||||
* roster, roles and pins ONLY when signed by the relay's own key — the NIP-11 `self`
|
||||
* pubkey. This returns false only when we can positively tell an event is NOT relay-signed
|
||||
* (the relay advertises a `self` and the event's author differs), so a stray or malicious
|
||||
* user-published 39000/39001/… served by a lax relay can't overwrite a group's state (e.g.
|
||||
* inject itself into the admin list). When `self` isn't known yet — the NIP-11 doc hasn't
|
||||
* loaded, or the relay doesn't advertise one — we don't block, so legitimate groups still
|
||||
* populate and this never regresses a relay whose key we simply haven't fetched.
|
||||
*/
|
||||
private fun isRelaySignedGroupEvent(
|
||||
event: Event,
|
||||
relay: NormalizedRelayUrl,
|
||||
): Boolean {
|
||||
val self =
|
||||
Amethyst.instance.nip11Cache
|
||||
.getFromCache(relay)
|
||||
.self ?: return true
|
||||
return event.pubKey == self
|
||||
}
|
||||
|
||||
/**
|
||||
* NIP-29 relay-signed group metadata (kind 39000). Stored as an addressable
|
||||
* note and used to populate the [RelayGroupChannel]'s name/picture/about/
|
||||
* flags. The group is keyed by (host relay + group id): unlike NIP-C7, a
|
||||
* NIP-29 event does not carry its host relay in a tag — the host is the relay
|
||||
* that served it — so the channel can only be associated when we know the
|
||||
* serving relay (provenance). With no relay we still store the metadata.
|
||||
*/
|
||||
fun consume(
|
||||
event: GroupMetadataEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
): Boolean {
|
||||
val new = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
if (relay != null && isRelaySignedGroupEvent(event, relay)) {
|
||||
val note = getOrCreateAddressableNote(event.address())
|
||||
val channel = getOrCreateRelayGroupChannel(GroupId(event.groupId(), relay))
|
||||
(note.event as? GroupMetadataEvent)?.let { channel.updateGroupInfo(it, note) }
|
||||
}
|
||||
|
||||
return new
|
||||
}
|
||||
|
||||
/** NIP-29 relay-signed member list (kind 39002) → the group's roster. */
|
||||
fun consume(
|
||||
event: GroupMembersEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
): Boolean {
|
||||
val new = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
if (relay != null && isRelaySignedGroupEvent(event, relay)) {
|
||||
val latest = getOrCreateAddressableNote(event.address()).event as? GroupMembersEvent
|
||||
latest?.let { getOrCreateRelayGroupChannel(GroupId(it.groupId(), relay)).updateMembers(it) }
|
||||
}
|
||||
return new
|
||||
}
|
||||
|
||||
/** NIP-29 relay-signed admin list (kind 39001) → the group's roster. */
|
||||
fun consume(
|
||||
event: GroupAdminsEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
): Boolean {
|
||||
val new = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
if (relay != null && isRelaySignedGroupEvent(event, relay)) {
|
||||
val latest = getOrCreateAddressableNote(event.address()).event as? GroupAdminsEvent
|
||||
latest?.let { getOrCreateRelayGroupChannel(GroupId(it.groupId(), relay)).updateAdmins(it) }
|
||||
}
|
||||
return new
|
||||
}
|
||||
|
||||
/** NIP-29 relay-signed pinned-message list (kind 39005) → the group's pins. */
|
||||
fun consume(
|
||||
event: GroupPinnedEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
): Boolean {
|
||||
val new = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
if (relay != null && isRelaySignedGroupEvent(event, relay)) {
|
||||
val latest = getOrCreateAddressableNote(event.address()).event as? GroupPinnedEvent
|
||||
latest?.let { getOrCreateRelayGroupChannel(GroupId(it.groupId(), relay)).updatePinned(it) }
|
||||
}
|
||||
return new
|
||||
}
|
||||
|
||||
/** NIP-29 relay-declared supported roles (kind 39003) → the group's role set. */
|
||||
fun consume(
|
||||
event: SupportedRolesEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
): Boolean {
|
||||
val new = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
if (relay != null && isRelaySignedGroupEvent(event, relay)) {
|
||||
val latest = getOrCreateAddressableNote(event.address()).event as? SupportedRolesEvent
|
||||
latest?.let { getOrCreateRelayGroupChannel(GroupId(it.groupId(), relay)).updateSupportedRoles(it) }
|
||||
}
|
||||
return new
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach a group-scoped content event (a kind-9 chat, kind-1068 poll, …
|
||||
* carrying an `h` tag) to its [RelayGroupChannel]. NIP-29 reuses the generic
|
||||
* content kinds and scopes them with `h`, so the note is consumed normally
|
||||
* and then, when it belongs to a group and we know the serving relay, added
|
||||
* to that group's channel timeline.
|
||||
*/
|
||||
private fun attachToRelayGroupIfScoped(
|
||||
event: Event,
|
||||
relay: NormalizedRelayUrl?,
|
||||
) {
|
||||
val groupId = event.groupId() ?: return
|
||||
val note = getOrCreateNote(event.id)
|
||||
// Only attach a note we've actually loaded — never a placeholder for an
|
||||
// unverified/not-yet-seen event. This is checked here (not via the "was
|
||||
// newly consumed" flag) so the host relay's echo of an event we already
|
||||
// stored from our own send still lands in the channel.
|
||||
if (note.event == null) return
|
||||
|
||||
if (relay != null) {
|
||||
val exact = GroupId(groupId, relay)
|
||||
val existing = getRelayGroupChannelIfExists(exact)
|
||||
if (existing != null) {
|
||||
// Normal arrival: the group's host-pinned filters served it, so the serving relay IS the
|
||||
// group's key and its channel already exists. Fast O(1) path — no scan.
|
||||
existing.addNote(note, relay)
|
||||
} else {
|
||||
// No channel keyed to the serving relay: this may be a stray from a NON-host relay (e.g. a
|
||||
// quoted kind-9 resolved by id). Redirect it to the group's single confirmed host rather
|
||||
// than mint a phantom channel the group's screens never read (the serving-relay hazard);
|
||||
// fall back to the serving-relay key when there is no single host (new/ambiguous group).
|
||||
val target = redirectStrayRelayGroupContent(relayGroupCandidatesFor(groupId)) ?: exact
|
||||
getOrCreateRelayGroupChannel(target).addNote(note, relay)
|
||||
}
|
||||
} else {
|
||||
// Our own optimistic send has no provenance relay, so we can't build the (groupId,
|
||||
// relay) key. Attach only when a SINGLE open channel has this group id (the room being
|
||||
// composed in). When the id is ambiguous across relays — e.g. the relay-wide "_" group
|
||||
// joined on several relays — skip: attaching to all of them bleeds the message into
|
||||
// rooms it wasn't sent to. The host relay's echo (relay != null) lands it on the right key.
|
||||
relayGroupChannels
|
||||
.filter { key, _ -> key.id == groupId }
|
||||
.singleOrNull()
|
||||
?.addNote(note, null)
|
||||
}
|
||||
}
|
||||
|
||||
/** Candidate group channels for the [redirectStrayRelayGroupContent] slow path — one scan by group id. */
|
||||
private fun relayGroupCandidatesFor(groupId: String): List<RelayGroupTargetCandidate> =
|
||||
relayGroupChannels
|
||||
.filter { key, _ -> key.id == groupId }
|
||||
.map { RelayGroupTargetCandidate(it.groupId, it.hasRelaySignedState()) }
|
||||
|
||||
/**
|
||||
* Same routing as [attachToRelayGroupIfScoped] but for kind-11 threads, which
|
||||
* are kept in a separate collection from the chat timeline so the two content
|
||||
* types don't mix in one feed.
|
||||
*/
|
||||
private fun attachThreadToRelayGroupIfScoped(
|
||||
event: Event,
|
||||
relay: NormalizedRelayUrl?,
|
||||
) {
|
||||
val groupId = event.groupId() ?: return
|
||||
val note = getOrCreateNote(event.id)
|
||||
if (note.event == null) return
|
||||
|
||||
if (relay != null) {
|
||||
val exact = GroupId(groupId, relay)
|
||||
val existing = getRelayGroupChannelIfExists(exact)
|
||||
if (existing != null) {
|
||||
existing.addThread(note)
|
||||
} else {
|
||||
// Same serving-relay hazard as the chat path: prefer the single confirmed host over a phantom.
|
||||
val target = redirectStrayRelayGroupContent(relayGroupCandidatesFor(groupId)) ?: exact
|
||||
getOrCreateRelayGroupChannel(target).addThread(note)
|
||||
}
|
||||
} else {
|
||||
// See attachToRelayGroupIfScoped: only attach when the group id is unambiguous.
|
||||
relayGroupChannels
|
||||
.filter { key, _ -> key.id == groupId }
|
||||
.singleOrNull()
|
||||
?.addThread(note)
|
||||
}
|
||||
}
|
||||
|
||||
fun consume(
|
||||
event: LiveActivitiesChatMessageEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
@@ -2874,6 +2525,7 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
null
|
||||
}
|
||||
|
||||
return liveChatChannels.filter { _, channel ->
|
||||
@@ -2997,10 +2649,6 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
pruneHiddenMessagesChannel(channel, account)
|
||||
}
|
||||
|
||||
geohashChannels.forEach { _, channel ->
|
||||
pruneHiddenMessagesChannel(channel, account)
|
||||
}
|
||||
|
||||
liveChatChannels.forEach { _, channel ->
|
||||
pruneHiddenMessagesChannel(channel, account)
|
||||
}
|
||||
@@ -3008,10 +2656,6 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
publicChatChannels.forEach { _, channel ->
|
||||
pruneHiddenMessagesChannel(channel, account)
|
||||
}
|
||||
|
||||
relayGroupChannels.forEach { _, channel ->
|
||||
pruneHiddenMessagesChannel(channel, account)
|
||||
}
|
||||
}
|
||||
|
||||
// 2× the 10-min `PRESENCE_FRESHNESS_WINDOW_SECONDS` used by
|
||||
@@ -3054,10 +2698,6 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
pruneOldMessagesChannel(channel)
|
||||
}
|
||||
|
||||
geohashChannels.forEach { _, channel ->
|
||||
pruneOldMessagesChannel(channel)
|
||||
}
|
||||
|
||||
liveChatChannels.forEach { _, channel ->
|
||||
pruneOldMessagesChannel(channel)
|
||||
}
|
||||
@@ -3066,10 +2706,6 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
pruneOldMessagesChannel(channel)
|
||||
}
|
||||
|
||||
relayGroupChannels.forEach { _, channel ->
|
||||
pruneOldMessagesChannel(channel)
|
||||
}
|
||||
|
||||
chatroomList.forEach { userHex, room ->
|
||||
// History floors are pinned per scope on first advance; null means that window never paged
|
||||
// history, so its cursors hold no position to misalign and nothing needs rewinding. Only the
|
||||
@@ -3363,28 +2999,7 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
}
|
||||
}
|
||||
|
||||
note?.let { addRelayToNoteAndInners(it, relay) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds [relay] to [note] and to every already-unwrapped inner note of its
|
||||
* gift-wrap chain (wrap → seal → rumor). The chat UI renders the inner
|
||||
* rumor, so a relay recorded only on the outer envelope never surfaces as
|
||||
* an icon. Inner notes that don't exist yet are not lost: the unwrap path
|
||||
* copies the envelope's relays down via [copyRelaysFromTo] when it runs.
|
||||
*/
|
||||
fun addRelayToNoteAndInners(
|
||||
note: Note,
|
||||
relay: NormalizedRelayUrl,
|
||||
) {
|
||||
note.addRelay(relay)
|
||||
|
||||
val noteEvent = note.event
|
||||
if (noteEvent is HasInnerEvent) {
|
||||
noteEvent.innerEventId?.let { innerId ->
|
||||
getNoteIfExists(innerId)?.let { addRelayToNoteAndInners(it, relay) }
|
||||
}
|
||||
}
|
||||
note?.addRelay(relay)
|
||||
}
|
||||
|
||||
// Observers line up here.
|
||||
@@ -3411,28 +3026,10 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
live.removedNote(newNote)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resource-usage ledger hook: called with (elapsedNanos, valid) for every
|
||||
* signature verification so the app can account crypto CPU per day.
|
||||
* Wired by AppModules like [onchainBackend]; null costs nothing.
|
||||
*/
|
||||
@Volatile
|
||||
var verifyMeter: ((elapsedNanos: Long, valid: Boolean) -> Unit)? = null
|
||||
|
||||
fun justVerify(event: Event): Boolean {
|
||||
checkNotInMainThread()
|
||||
|
||||
val meter = verifyMeter
|
||||
if (meter == null) return justVerifyInner(event)
|
||||
|
||||
val start = System.nanoTime()
|
||||
val valid = justVerifyInner(event)
|
||||
meter(System.nanoTime() - start, valid)
|
||||
return valid
|
||||
}
|
||||
|
||||
private fun justVerifyInner(event: Event): Boolean =
|
||||
if (!event.verify()) {
|
||||
return if (!event.verify()) {
|
||||
try {
|
||||
event.checkSignature()
|
||||
} catch (e: Exception) {
|
||||
@@ -3443,6 +3040,7 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fun consume(
|
||||
event: DraftWrapEvent,
|
||||
@@ -3941,101 +3539,10 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
consume(event, relay, wasVerified)
|
||||
}
|
||||
|
||||
is GeohashChatEvent -> {
|
||||
consume(event, relay, wasVerified)
|
||||
}
|
||||
|
||||
is EphemeralChatListEvent -> {
|
||||
consumeBaseReplaceable(event, relay, wasVerified)
|
||||
}
|
||||
|
||||
// NIP-51 "simple groups" list (kind 10009): the user's joined NIP-29 groups +
|
||||
// servers. Replaceable like its sibling lists; RelayGroupListState reads it from the
|
||||
// addressable cache, so it must be stored (it was silently dropped before).
|
||||
is SimpleGroupListEvent -> {
|
||||
consumeBaseReplaceable(event, relay, wasVerified)
|
||||
}
|
||||
|
||||
// Concord private joined-communities list (kind 13302). Replaceable, self-encrypted;
|
||||
// ConcordChannelListState observes it via the addressable cache (Address(13302, me, "")),
|
||||
// so — exactly like the 10009 list above — it must be stored replaceably or the Concord
|
||||
// hub stays empty even after the event arrives.
|
||||
is ConcordCommunityListEvent -> {
|
||||
consumeBaseReplaceable(event, relay, wasVerified)
|
||||
}
|
||||
|
||||
is GroupMetadataEvent -> {
|
||||
consume(event, relay, wasVerified)
|
||||
}
|
||||
|
||||
is GroupMembersEvent -> {
|
||||
consume(event, relay, wasVerified)
|
||||
}
|
||||
|
||||
is GroupAdminsEvent -> {
|
||||
consume(event, relay, wasVerified)
|
||||
}
|
||||
|
||||
is GroupPinnedEvent -> {
|
||||
consume(event, relay, wasVerified)
|
||||
}
|
||||
|
||||
// 39003 (relay-declared roles) is durable group state like 39000/39001/39002:
|
||||
// route it onto the channel so a moderation UI can offer the relay's role set.
|
||||
is SupportedRolesEvent -> {
|
||||
consume(event, relay, wasVerified)
|
||||
}
|
||||
|
||||
// Remaining NIP-29 relay-group kinds. The relay-signed 39004 AV-participants
|
||||
// addressable is durable group state, so it's stored replaceably. The 9xxx
|
||||
// moderation actions and join/leave requests are regular one-shot events the
|
||||
// relay is authoritative for (it applies them and republishes the
|
||||
// 39000/39001/39002); we store them so they're queryable and don't fall through
|
||||
// to the "Not Supported" warning, but we don't act on them client-side.
|
||||
is GroupParticipantsEvent -> {
|
||||
consumeBaseReplaceable(event, relay, wasVerified)
|
||||
}
|
||||
|
||||
is PutUserEvent -> {
|
||||
consumeRegularEvent(event, relay, wasVerified)
|
||||
}
|
||||
|
||||
is RemoveUserEvent -> {
|
||||
consumeRegularEvent(event, relay, wasVerified)
|
||||
}
|
||||
|
||||
is EditMetadataEvent -> {
|
||||
consumeRegularEvent(event, relay, wasVerified)
|
||||
}
|
||||
|
||||
is DeleteEventEvent -> {
|
||||
consumeRegularEvent(event, relay, wasVerified)
|
||||
}
|
||||
|
||||
is UpdatePinListEvent -> {
|
||||
consumeRegularEvent(event, relay, wasVerified)
|
||||
}
|
||||
|
||||
is DeleteGroupEvent -> {
|
||||
consumeRegularEvent(event, relay, wasVerified)
|
||||
}
|
||||
|
||||
is CreateGroupEvent -> {
|
||||
consumeRegularEvent(event, relay, wasVerified)
|
||||
}
|
||||
|
||||
is CreateInviteEvent -> {
|
||||
consumeRegularEvent(event, relay, wasVerified)
|
||||
}
|
||||
|
||||
is JoinRequestEvent -> {
|
||||
consumeRegularEvent(event, relay, wasVerified)
|
||||
}
|
||||
|
||||
is LeaveRequestEvent -> {
|
||||
consumeRegularEvent(event, relay, wasVerified)
|
||||
}
|
||||
|
||||
is ExternalIdentitiesEvent -> {
|
||||
consumeBaseReplaceable(event, relay, wasVerified)
|
||||
}
|
||||
@@ -4081,15 +3588,7 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
}
|
||||
|
||||
is GiftWrapEvent -> {
|
||||
// A wrap with an empty content carries no NIP-44 ciphertext and can
|
||||
// never be unwrapped — reject it before paying for a signature check
|
||||
// and a cache slot. Locally stripped copies (copyNoContent) are
|
||||
// assigned straight to note.event and never pass through here.
|
||||
if (event.content.isEmpty()) {
|
||||
false
|
||||
} else {
|
||||
consumeRegularEvent(event, relay, wasVerified)
|
||||
}
|
||||
consumeRegularEvent(event, relay, wasVerified)
|
||||
}
|
||||
|
||||
is GroupEvent -> {
|
||||
@@ -4398,25 +3897,11 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
}
|
||||
|
||||
is ChatEvent -> {
|
||||
consumeRegularEvent(event, relay, wasVerified).also {
|
||||
// Attach on every arrival, not just the newly-consumed one:
|
||||
// our own send is consumed first with a null relay, so the
|
||||
// host relay's later echo (new == false) is what carries the
|
||||
// provenance needed to key the channel. attach is idempotent.
|
||||
attachToRelayGroupIfScoped(event, relay)
|
||||
}
|
||||
consumeRegularEvent(event, relay, wasVerified)
|
||||
}
|
||||
|
||||
is PollEvent -> {
|
||||
consumeRegularEvent(event, relay, wasVerified).also {
|
||||
attachToRelayGroupIfScoped(event, relay)
|
||||
}
|
||||
}
|
||||
|
||||
is ThreadEvent -> {
|
||||
consumeRegularEvent(event, relay, wasVerified).also {
|
||||
attachThreadToRelayGroupIfScoped(event, relay)
|
||||
}
|
||||
consumeRegularEvent(event, relay, wasVerified)
|
||||
}
|
||||
|
||||
is PollResponseEvent -> {
|
||||
|
||||
@@ -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,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
|
||||
+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()
|
||||
}
|
||||
|
||||
-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
-10
@@ -87,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
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
+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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+7
-20
@@ -22,8 +22,6 @@ package com.vitorpamplona.amethyst.model.privacyOptions
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.tor.TorType
|
||||
import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManager
|
||||
import com.vitorpamplona.amethyst.service.resourceusage.HttpUsageMeter
|
||||
import com.vitorpamplona.amethyst.service.resourceusage.UsageKeys
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import okhttp3.OkHttpClient
|
||||
@@ -34,18 +32,7 @@ import javax.net.SocketFactory
|
||||
class RoleBasedHttpClientBuilder(
|
||||
val okHttpClient: DualHttpClientManager,
|
||||
val torSettings: TorSettingsFlow,
|
||||
/**
|
||||
* When present, every role's client is wrapped with a byte-counting
|
||||
* interceptor so the resource-usage ledger can attribute HTTP traffic
|
||||
* per subsystem. Null keeps the raw shared clients (tests).
|
||||
*/
|
||||
val usageMeter: HttpUsageMeter? = null,
|
||||
) : IRoleBasedHttpClientBuilder {
|
||||
private fun metered(
|
||||
role: String,
|
||||
base: OkHttpClient,
|
||||
): OkHttpClient = usageMeter?.counted(role, base) ?: base
|
||||
|
||||
fun shouldUseTorForImageDownload(url: String) =
|
||||
shouldUseTorFor(
|
||||
url,
|
||||
@@ -144,19 +131,19 @@ class RoleBasedHttpClientBuilder(
|
||||
|
||||
override fun proxyPortForVideo(url: String): Int? = okHttpClient.getCurrentProxyPort(shouldUseTorForVideoDownload(url))
|
||||
|
||||
override fun okHttpClientForNip05(url: String): OkHttpClient = metered(UsageKeys.ROLE_NIP05, okHttpClient.getHttpClient(shouldUseTorForNIP05(url)))
|
||||
override fun okHttpClientForNip05(url: String): OkHttpClient = okHttpClient.getHttpClient(shouldUseTorForNIP05(url))
|
||||
|
||||
override fun okHttpClientForUploads(url: String): OkHttpClient = metered(UsageKeys.ROLE_UPLOADS, okHttpClient.getHttpClient(shouldUseTorForUploads(url)))
|
||||
override fun okHttpClientForUploads(url: String): OkHttpClient = okHttpClient.getHttpClient(shouldUseTorForUploads(url))
|
||||
|
||||
override fun okHttpClientForImage(url: String): OkHttpClient = metered(UsageKeys.ROLE_IMAGE, okHttpClient.getHttpClient(shouldUseTorForImageDownload(url)))
|
||||
override fun okHttpClientForImage(url: String): OkHttpClient = okHttpClient.getHttpClient(shouldUseTorForImageDownload(url))
|
||||
|
||||
override fun okHttpClientForVideo(url: String): OkHttpClient = metered(UsageKeys.ROLE_VIDEO, okHttpClient.getHttpClient(shouldUseTorForVideoDownload(url)))
|
||||
override fun okHttpClientForVideo(url: String): OkHttpClient = okHttpClient.getHttpClient(shouldUseTorForVideoDownload(url))
|
||||
|
||||
override fun okHttpClientForMoney(url: String): OkHttpClient = metered(UsageKeys.ROLE_MONEY, okHttpClient.getHttpClient(shouldUseTorForMoneyOperations(url)))
|
||||
override fun okHttpClientForMoney(url: String): OkHttpClient = okHttpClient.getHttpClient(shouldUseTorForMoneyOperations(url))
|
||||
|
||||
override fun okHttpClientForPreview(url: String): OkHttpClient = metered(UsageKeys.ROLE_PREVIEW, okHttpClient.getHttpClient(shouldUseTorForPreviewUrl(url)))
|
||||
override fun okHttpClientForPreview(url: String): OkHttpClient = okHttpClient.getHttpClient(shouldUseTorForPreviewUrl(url))
|
||||
|
||||
override fun okHttpClientForPushRegistration(url: String): OkHttpClient = metered(UsageKeys.ROLE_PUSH, okHttpClient.getHttpClient(shouldUseTorForTrustedRelays()))
|
||||
override fun okHttpClientForPushRegistration(url: String): OkHttpClient = okHttpClient.getHttpClient(shouldUseTorForTrustedRelays())
|
||||
|
||||
/**
|
||||
* Returns a [SocketFactory] that routes through the user's Tor proxy
|
||||
|
||||
+1
-3
@@ -74,9 +74,7 @@ class FeedTopNavFilterState(
|
||||
) {
|
||||
fun loadFlowsFor(listName: TopFilter): IFeedFlowsType =
|
||||
when (listName) {
|
||||
// TeleportPicker is a UI-only sentinel (intercepted by the spinner to open the
|
||||
// map picker); it never reaches here, but fall back to Global for exhaustiveness.
|
||||
TopFilter.Global, TopFilter.Selected, TopFilter.TeleportPicker -> {
|
||||
TopFilter.Global, TopFilter.Selected -> {
|
||||
GlobalFeedFlow(followsRelays, proxyRelays, relayFeeds)
|
||||
}
|
||||
|
||||
|
||||
+7
-20
@@ -41,21 +41,12 @@ private val Context.nappletPermissionsDataStore by preferencesDataStore(name = "
|
||||
*/
|
||||
class DataStoreNappletPermissionStore(
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
private val accountPubKey: () -> String,
|
||||
) : NappletPermissionStore {
|
||||
constructor(context: Context, accountPubKey: () -> String) :
|
||||
this(context.applicationContext.nappletPermissionsDataStore, accountPubKey)
|
||||
|
||||
/**
|
||||
* Grants belong to one account. [accountPubKey] is read at call time, so an account switch moves
|
||||
* every read and write to that account's namespace with no rebuild — a grant made by one account
|
||||
* can never authorize another.
|
||||
*/
|
||||
private fun scoped(coordinate: String) = "${accountPubKey()}$SEP$coordinate"
|
||||
constructor(context: Context) : this(context.applicationContext.nappletPermissionsDataStore)
|
||||
|
||||
override suspend fun load(coordinate: String): Map<NappletCapability, GrantState> {
|
||||
val prefs = dataStore.data.first()
|
||||
val prefix = "${scoped(coordinate)}$SEP"
|
||||
val prefix = "$coordinate$SEP"
|
||||
val result = mutableMapOf<NappletCapability, GrantState>()
|
||||
for ((key, value) in prefs.asMap()) {
|
||||
val name = key.name
|
||||
@@ -77,7 +68,7 @@ class DataStoreNappletPermissionStore(
|
||||
}
|
||||
|
||||
override suspend fun clear(coordinate: String) {
|
||||
val prefix = "${scoped(coordinate)}$SEP"
|
||||
val prefix = "$coordinate$SEP"
|
||||
dataStore.edit { prefs ->
|
||||
val toRemove = prefs.asMap().keys.filter { it.name.startsWith(prefix) }
|
||||
toRemove.forEach { prefs.remove(it) }
|
||||
@@ -87,15 +78,11 @@ class DataStoreNappletPermissionStore(
|
||||
override suspend fun all(): Map<String, Map<NappletCapability, GrantState>> {
|
||||
val prefs = dataStore.data.first()
|
||||
val result = mutableMapOf<String, MutableMap<NappletCapability, GrantState>>()
|
||||
val accountPrefix = "${accountPubKey()}$SEP"
|
||||
for ((key, value) in prefs.asMap()) {
|
||||
val name = key.name
|
||||
// Key is "<account><SEP><coordinate><SEP><CAPABILITY>". Only the active account's grants
|
||||
// are listed, so the Connected Apps screen never surfaces another account's permissions.
|
||||
if (!name.startsWith(accountPrefix)) continue
|
||||
val scoped = name.removePrefix(accountPrefix)
|
||||
val capName = scoped.substringAfterLast(SEP, "")
|
||||
val coordinate = scoped.substringBeforeLast(SEP, "")
|
||||
// Key is "<coordinate> <CAPABILITY>"; the capability is the final space-delimited token.
|
||||
val capName = name.substringAfterLast(SEP, "")
|
||||
val coordinate = name.substringBeforeLast(SEP, "")
|
||||
if (capName.isEmpty() || coordinate.isEmpty()) continue
|
||||
val capability = runCatching { NappletCapability.valueOf(capName) }.getOrNull() ?: continue
|
||||
val grant = runCatching { GrantState.valueOf(value as String) }.getOrNull() ?: continue
|
||||
@@ -116,7 +103,7 @@ class DataStoreNappletPermissionStore(
|
||||
private fun keyOf(
|
||||
coordinate: String,
|
||||
capability: NappletCapability,
|
||||
) = stringPreferencesKey("${scoped(coordinate)}$SEP${capability.name}")
|
||||
) = stringPreferencesKey("$coordinate$SEP${capability.name}")
|
||||
|
||||
companion object {
|
||||
private const val SEP = "\u0000"
|
||||
|
||||
+6
-17
@@ -32,20 +32,14 @@ import kotlinx.coroutines.flow.first
|
||||
private val Context.nappletStorageDataStore by preferencesDataStore(name = "napplet_storage")
|
||||
|
||||
/**
|
||||
* DataStore-backed [NappletStorage]. Every key is prefixed with the **active account** and then the
|
||||
* applet's coordinate, so one napplet's keys can never collide with another's, one account's data is
|
||||
* never visible to another, and this store is entirely separate from the app's own preferences.
|
||||
*
|
||||
* [accountPubKey] is read at call time rather than captured, so switching accounts moves reads and
|
||||
* writes to the new namespace with no rebuild — an embedded applet always sees the current account's
|
||||
* data and never the previous one's.
|
||||
* DataStore-backed [NappletStorage]. Every key is prefixed with the applet's coordinate, so one
|
||||
* napplet's keys can never collide with another's, and this store is entirely separate from the
|
||||
* app's own preferences.
|
||||
*/
|
||||
class DataStoreNappletStorage(
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
private val accountPubKey: () -> String,
|
||||
) : NappletStorage {
|
||||
constructor(context: Context, accountPubKey: () -> String) :
|
||||
this(context.applicationContext.nappletStorageDataStore, accountPubKey)
|
||||
constructor(context: Context) : this(context.applicationContext.nappletStorageDataStore)
|
||||
|
||||
override suspend fun get(
|
||||
coordinate: String,
|
||||
@@ -68,9 +62,7 @@ class DataStoreNappletStorage(
|
||||
}
|
||||
|
||||
override suspend fun keys(coordinate: String): List<String> {
|
||||
// Must match keyOf's separator exactly. This filtered on a space while keys are written
|
||||
// with NUL, so no key could ever match and keys() always returned an empty list.
|
||||
val prefix = prefixOf(coordinate)
|
||||
val prefix = "$coordinate "
|
||||
return dataStore.data
|
||||
.first()
|
||||
.asMap()
|
||||
@@ -80,11 +72,8 @@ class DataStoreNappletStorage(
|
||||
.map { it.removePrefix(prefix) }
|
||||
}
|
||||
|
||||
/** Account first, then applet: isolates accounts from each other, and applets within an account. */
|
||||
private fun prefixOf(coordinate: String) = "${accountPubKey()}\u0000$coordinate\u0000"
|
||||
|
||||
private fun keyOf(
|
||||
coordinate: String,
|
||||
key: String,
|
||||
) = stringPreferencesKey(prefixOf(coordinate) + key)
|
||||
) = stringPreferencesKey("$coordinate\u0000$key")
|
||||
}
|
||||
|
||||
+19
-24
@@ -18,7 +18,7 @@
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.connectedApps
|
||||
package com.vitorpamplona.amethyst.napplet
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
@@ -26,14 +26,12 @@ 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.signers.AppSignerPolicy
|
||||
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrOpDecision
|
||||
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp
|
||||
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionStore
|
||||
import com.vitorpamplona.amethyst.commons.napplet.signers.AppSignerPolicy
|
||||
import com.vitorpamplona.amethyst.commons.napplet.signers.NostrOpDecision
|
||||
import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerOp
|
||||
import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerPermissionStore
|
||||
import com.vitorpamplona.quartz.utils.cache.LargeCache
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.security.MessageDigest
|
||||
|
||||
@@ -104,24 +102,21 @@ class DataStoreNostrSignerPermissionStore(
|
||||
storeFor(coordinate).edit { it.remove(opKey(op)) }
|
||||
}
|
||||
|
||||
override suspend fun allPolicies(): Map<String, AppSignerPolicy> =
|
||||
// Enumerates the datastore directory + reads each file — blocking disk IO, so keep it off the
|
||||
// caller's thread (callers invoke this from Compose LaunchedEffects on the main dispatcher).
|
||||
withContext(Dispatchers.IO) {
|
||||
val dir = File(filesDir, "datastore")
|
||||
if (!dir.exists()) return@withContext emptyMap()
|
||||
val result = mutableMapOf<String, AppSignerPolicy>()
|
||||
for (file in dir.listFiles { f -> f.name.startsWith("nsp_") } ?: emptyArray()) {
|
||||
val ds =
|
||||
cache.getOrCreate(file.absolutePath) {
|
||||
PreferenceDataStoreFactory.create(produceFile = { file })
|
||||
}
|
||||
val coordinate = ds.data.first()[KEY_COORDINATE] ?: continue
|
||||
val policy = loadPolicy(coordinate) ?: continue
|
||||
result[coordinate] = policy
|
||||
}
|
||||
result
|
||||
override suspend fun allPolicies(): Map<String, AppSignerPolicy> {
|
||||
val dir = File(filesDir, "datastore")
|
||||
if (!dir.exists()) return emptyMap()
|
||||
val result = mutableMapOf<String, AppSignerPolicy>()
|
||||
for (file in dir.listFiles { f -> f.name.startsWith("nsp_") } ?: emptyArray()) {
|
||||
val ds =
|
||||
cache.getOrCreate(file.absolutePath) {
|
||||
PreferenceDataStoreFactory.create(produceFile = { file })
|
||||
}
|
||||
val coordinate = ds.data.first()[KEY_COORDINATE] ?: continue
|
||||
val policy = loadPolicy(coordinate) ?: continue
|
||||
result[coordinate] = policy
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
override suspend fun allOpDecisions(coordinate: String): Map<String, NostrOpDecision> {
|
||||
val prefs = storeFor(coordinate).data.first()
|
||||
+42
-108
@@ -32,16 +32,16 @@ import android.os.Messenger
|
||||
import android.os.RemoteException
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import androidx.core.net.toUri
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionLedger
|
||||
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletBroker
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletCapability
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletRequestRouter
|
||||
import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger
|
||||
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson
|
||||
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletResponse
|
||||
import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerPermissionLedger
|
||||
import com.vitorpamplona.amethyst.favorites.BrowserHistoryRegistry
|
||||
import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry
|
||||
import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry
|
||||
@@ -49,7 +49,7 @@ import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.napplet.gateways.AccountNappletGateways
|
||||
import com.vitorpamplona.amethyst.napplethost.NappletIpc
|
||||
import com.vitorpamplona.amethyst.ui.MainActivity
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.amethyst.ui.screen.AccountState
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -77,37 +77,32 @@ import kotlinx.coroutines.launch
|
||||
class NappletBrokerService : Service() {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
// Persistent grants on disk, session grants in RAM. Shared app-wide (see AppModules) so the
|
||||
// Connected Apps screens revoke the very grants this broker consults; session grants are dropped
|
||||
// in onDestroy, which is the "all applet surfaces closed" boundary.
|
||||
private val ledger get() = Amethyst.instance.nappletPermissionLedger
|
||||
// One ledger for the whole service lifetime: persistent grants on disk, session grants in RAM.
|
||||
private val ledger by lazy { NappletPermissionLedger(Amethyst.instance.nappletPermissionStore) }
|
||||
|
||||
// Per-app internal-signer permission ledger (policy + per-op overrides). Lazy so it's only
|
||||
// instantiated in the main process where the signer lives; never touched from :napplet.
|
||||
private val signerLedger by lazy { NostrSignerPermissionLedger(Amethyst.instance.signerPermissionStore) }
|
||||
|
||||
// Per-applet sandboxed key-value store (namespaced by account + coordinate inside the impl).
|
||||
private val storage by lazy { DataStoreNappletStorage(applicationContext, Amethyst.instance.nappletAccountScope) }
|
||||
// Per-applet sandboxed key-value store (namespaced by coordinate inside the impl).
|
||||
private val storage by lazy { DataStoreNappletStorage(applicationContext) }
|
||||
|
||||
private val incoming by lazy { Messenger(Handler(Looper.getMainLooper(), ::handleMessage)) }
|
||||
|
||||
// Live relay subscriptions, keyed by the applet's subId. The account comes per-open from the
|
||||
// requesting surface's launch token, so a surface's REQs always target the account it acts as.
|
||||
private val liveSubscriptions = NappletLiveSubscriptions()
|
||||
// The broker for the current account, rebuilt only on account switch (see broker()).
|
||||
private var cachedBroker: Pair<Account, NappletBroker>? = null
|
||||
|
||||
// Live relay subscriptions, keyed by the applet's subId; reads the current account live.
|
||||
private val liveSubscriptions = NappletLiveSubscriptions { Amethyst.instance.sessionManager.loggedInAccount() }
|
||||
|
||||
// The app-wide inc pub/sub bus: routes inc.emit between live napplet sessions as inc.event pushes.
|
||||
private val incBus = NappletIncBus { replyTo, payload -> push(replyTo, payload) }
|
||||
|
||||
// Streams identity.changed to a watching applet. Bound to the surface's LAUNCH account, not the
|
||||
// app's active one: a surface acts as the account that opened it for its whole life, so switching
|
||||
// accounts elsewhere is not an identity change *for it*. Announcing the newly-active pubkey here
|
||||
// would tell a page it had become someone else while its signatures still came back as the
|
||||
// original — the same desync the launch binding exists to prevent. What this does still report is
|
||||
// that account going away (logout/removal), which emits "".
|
||||
// Streams identity.changed pushes (account switch / connect / disconnect) to a watching applet.
|
||||
private val identityWatch =
|
||||
NappletIdentityWatch(scope) { boundPubKey ->
|
||||
Amethyst.instance.accountsCache.accounts
|
||||
.map { loaded -> if (loaded.containsKey(boundPubKey)) boundPubKey else "" }
|
||||
NappletIdentityWatch(scope) {
|
||||
Amethyst.instance.sessionManager.accountContent
|
||||
.map { (it as? AccountState.LoggedIn)?.account?.signer?.pubKey ?: "" }
|
||||
}
|
||||
|
||||
// Binding is restricted to our own UID by exported=false in the manifest, enforced by the OS.
|
||||
@@ -118,13 +113,6 @@ class NappletBrokerService : Service() {
|
||||
override fun onDestroy() {
|
||||
liveSubscriptions.closeAll()
|
||||
identityWatch.stop()
|
||||
// Every applet/browser surface has unbound, so the "session" the user granted for is over.
|
||||
// The ledger and the broker cache are now app-wide singletons that outlive this service, so
|
||||
// their in-memory session grants have to be dropped explicitly here — that keeps the lifetime
|
||||
// the consent dialog promises ("allow for this session") instead of letting it become
|
||||
// "allow until the app process dies".
|
||||
dropCachedBroker()
|
||||
Amethyst.instance.nappletPermissionLedger.endSession()
|
||||
// Drop any foreground holds this broker still owns so they don't leak past the service.
|
||||
synchronized(foregroundLeases) {
|
||||
repeat(foregroundLeases.size) { SandboxForegroundHold.release() }
|
||||
@@ -252,10 +240,7 @@ class NappletBrokerService : Service() {
|
||||
val replyTo = msg.replyTo ?: return true
|
||||
val origin = data.getString(NappletIpc.KEY_BROWSER_ORIGIN)?.takeIf { it.isNotBlank() } ?: return true
|
||||
val identity = NappletIdentity(authorPubKey = BROWSER_IDENTITY_AUTHOR, identifier = origin)
|
||||
// Bind to the account active at mint time: a browser token minted for one account must
|
||||
// never sign as another if the user switches while the page is still open.
|
||||
val mintAccount = Amethyst.instance.sessionManager.loggedInAccount() ?: return true
|
||||
val token = NappletLaunchRegistry.register(identity, setOf(NappletCapability.IDENTITY, NappletCapability.RELAY), mintAccount.pubKey)
|
||||
val token = NappletLaunchRegistry.register(identity, setOf(NappletCapability.IDENTITY, NappletCapability.RELAY))
|
||||
val response =
|
||||
Message.obtain(null, NappletIpc.MSG_BROWSER_TOKEN).apply {
|
||||
this.data =
|
||||
@@ -292,20 +277,17 @@ class NappletBrokerService : Service() {
|
||||
// The shared, host-agnostic router owns decode → broker → encode and the subscribe-vs-reply
|
||||
// decision (it stays wire-identical with the future desktop host). This service only supplies
|
||||
// the broker, the Messenger transport, and the live relay subscription each Outcome implies.
|
||||
// The launch token decides whose key signs — not the active account. A surface opened by
|
||||
// one account can never be handed another's signer, even while it stays open across a switch.
|
||||
val broker = brokerFor(session.accountPubKey)
|
||||
val broker = broker()
|
||||
if (broker == null) {
|
||||
reply(replyTo, requestId, NappletProtocolJson.encodeResponse(requestType, NappletResponse.Failed("That account is no longer signed in.")))
|
||||
reply(replyTo, requestId, NappletProtocolJson.encodeResponse(requestType, NappletResponse.Failed("No account is signed in.")))
|
||||
return@launch
|
||||
}
|
||||
when (val outcome = NappletRequestRouter.route(broker, identity, declared, payload)) {
|
||||
is NappletRequestRouter.Outcome.Ignore -> {}
|
||||
is NappletRequestRouter.Outcome.Reply -> reply(replyTo, requestId, outcome.payload)
|
||||
is NappletRequestRouter.Outcome.OpenSubscription ->
|
||||
liveSubscriptions.open(outcome.subId, outcome.filters, accountFor(session.accountPubKey)) { push(replyTo, it) }
|
||||
is NappletRequestRouter.Outcome.OpenSubscription -> liveSubscriptions.open(outcome.subId, outcome.filters) { push(replyTo, it) }
|
||||
is NappletRequestRouter.Outcome.CloseSubscription -> liveSubscriptions.close(outcome.subId)
|
||||
is NappletRequestRouter.Outcome.WatchIdentity -> identityWatch.start(session.accountPubKey) { push(replyTo, it) }
|
||||
is NappletRequestRouter.Outcome.WatchIdentity -> identityWatch.start { push(replyTo, it) }
|
||||
is NappletRequestRouter.Outcome.UnwatchIdentity -> identityWatch.stop()
|
||||
is NappletRequestRouter.Outcome.Push -> outcome.payloads.forEach { push(replyTo, it) }
|
||||
is NappletRequestRouter.Outcome.SubscribeInc -> incBus.subscribe(replyTo, outcome.topic)
|
||||
@@ -344,44 +326,28 @@ class NappletBrokerService : Service() {
|
||||
}
|
||||
}
|
||||
|
||||
/** The launched-as account, or null once it is no longer loaded. */
|
||||
private fun accountFor(accountPubKey: HexKey): Account? = Amethyst.instance.accountsCache.accounts.value[accountPubKey]
|
||||
|
||||
/**
|
||||
* The broker for the account a surface was LAUNCHED as — [NappletLaunchRegistry.Session.accountPubKey],
|
||||
* never whichever account is active right now.
|
||||
*
|
||||
* Resolving live was wrong in a way that defeated per-account isolation: a full-screen host is a
|
||||
* separate activity that an account switch does not tear down, so its WebView kept account A's
|
||||
* cookies while requests were signed by B. The page displayed one identity while another signed,
|
||||
* and B's session was written into A's storage jar — after which even the embedded tab, which is
|
||||
* rebuilt correctly, showed the wrong account.
|
||||
*
|
||||
* Binding to the launch account satisfies both halves of the rule with no extra machinery:
|
||||
* embedded surfaces are torn down and re-minted on a switch, so they follow the active account,
|
||||
* while a full-screen surface stays on the account it was opened with.
|
||||
*
|
||||
* Returns null when that account is no longer loaded (logged out), so requests fail closed
|
||||
* rather than silently falling back to someone else's key.
|
||||
* The broker for the *currently* signed-in account, cached and rebuilt only when the account
|
||||
* changes (reference identity). The gateways capture the account and read its flows live, so a
|
||||
* cached broker stays correct across requests without per-request allocation.
|
||||
*/
|
||||
private fun brokerFor(accountPubKey: HexKey): NappletBroker? {
|
||||
val account = accountFor(accountPubKey) ?: return null
|
||||
synchronized(brokerLock) {
|
||||
cachedBroker?.let { (acc, broker) -> if (acc === account) return broker }
|
||||
val broker =
|
||||
AccountNappletGateways(
|
||||
account = account,
|
||||
context = applicationContext,
|
||||
ledger = ledger,
|
||||
storage = storage,
|
||||
// Per-applet Tor decision (see NappletResourceFetcher): the shared manager routes
|
||||
// through Tor when asked + active, and falls back to clearnet otherwise.
|
||||
httpClient = { useProxy -> Amethyst.instance.okHttpClients.getHttpClient(useProxy) },
|
||||
signerLedger = signerLedger,
|
||||
).broker()
|
||||
cachedBroker = account to broker
|
||||
return broker
|
||||
}
|
||||
@Synchronized
|
||||
private fun broker(): NappletBroker? {
|
||||
val account = Amethyst.instance.sessionManager.loggedInAccount() ?: return null
|
||||
cachedBroker?.let { (acc, broker) -> if (acc === account) return broker }
|
||||
val broker =
|
||||
AccountNappletGateways(
|
||||
account = account,
|
||||
context = applicationContext,
|
||||
ledger = ledger,
|
||||
storage = storage,
|
||||
// Per-applet Tor decision (see NappletResourceFetcher): the shared manager routes
|
||||
// through Tor when asked + active, and falls back to clearnet otherwise.
|
||||
httpClient = { useProxy -> Amethyst.instance.okHttpClients.getHttpClient(useProxy) },
|
||||
signerLedger = signerLedger,
|
||||
).broker()
|
||||
cachedBroker = account to broker
|
||||
return broker
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -393,7 +359,7 @@ class NappletBrokerService : Service() {
|
||||
val intent =
|
||||
Intent(applicationContext, MainActivity::class.java).apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
data = ("nostr:connectedapp?coordinate=" + Uri.encode(coordinate)).toUri()
|
||||
data = Uri.parse("nostr:connectedapp?coordinate=" + Uri.encode(coordinate))
|
||||
}
|
||||
runCatching { applicationContext.startActivity(intent) }
|
||||
.onFailure { Log.w("NappletBrokerService", "Could not open Connected Apps detail", it) }
|
||||
@@ -436,38 +402,6 @@ class NappletBrokerService : Service() {
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Guards [cachedBroker]. Both live on the companion rather than the service instance so the
|
||||
* Connected Apps UI can reach the running broker to revoke its live session grants — the
|
||||
* screens are plain composables with no binder to this service, and the broker is the only
|
||||
* holder of the in-memory "allow for this session" signer grants.
|
||||
*
|
||||
* Main-process only, like the sibling `Napplet*Registry` objects: the `:napplet` process gets
|
||||
* its own (unused, empty) copy of these statics and must never touch them.
|
||||
*/
|
||||
private val brokerLock = Any()
|
||||
|
||||
// The broker for the current account, rebuilt only on account switch (see brokerFor()).
|
||||
private var cachedBroker: Pair<Account, NappletBroker>? = null
|
||||
|
||||
/**
|
||||
* Drops the live "allow for this session" signer grants the running broker holds for
|
||||
* [coordinate] (the bare app coordinate). Called when the user revokes or forgets an app in
|
||||
* Connected Apps: without it the persisted grants are cleared but the in-memory session ones
|
||||
* keep authorizing signatures until the broker dies, so a revoked app goes on signing.
|
||||
*
|
||||
* No-op when no broker has been built yet (no applet has run this process).
|
||||
*/
|
||||
suspend fun revokeSessionGrants(coordinate: String) {
|
||||
val broker = synchronized(brokerLock) { cachedBroker?.second } ?: return
|
||||
broker.revokeSessionGrants(coordinate)
|
||||
}
|
||||
|
||||
/** Forgets the cached broker, dropping every session grant it holds. */
|
||||
private fun dropCachedBroker() {
|
||||
synchronized(brokerLock) { cachedBroker = null }
|
||||
}
|
||||
|
||||
/**
|
||||
* Sentinel "author" for a browser-mode per-origin identity. The real key is the visited origin,
|
||||
* carried in the identity's identifier (which the consent dialog shows); this constant only fills
|
||||
|
||||
+24
-64
@@ -18,7 +18,7 @@
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.connectedApps.consent
|
||||
package com.vitorpamplona.amethyst.napplet
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
@@ -58,24 +58,23 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppConnectResult
|
||||
import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppSignerPolicy
|
||||
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.MaterialSymbol
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.napplet.signers.AppConnectResult
|
||||
import com.vitorpamplona.amethyst.commons.napplet.signers.AppSignerPolicy
|
||||
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
|
||||
|
||||
class SignerConnectActivity : ComponentActivity() {
|
||||
class NappletConnectActivity : ComponentActivity() {
|
||||
private var token: String? = null
|
||||
private var decided = false
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val token = intent.getStringExtra(SignerConnectCoordinator.EXTRA_TOKEN)
|
||||
val token = intent.getStringExtra(NappletConnectCoordinator.EXTRA_TOKEN)
|
||||
this.token = token
|
||||
val info = token?.let { SignerConnectCoordinator.infoFor(it) }
|
||||
val info = token?.let { NappletConnectCoordinator.infoFor(it) }
|
||||
if (token == null || info == null) {
|
||||
finish()
|
||||
return
|
||||
@@ -83,21 +82,21 @@ class SignerConnectActivity : ComponentActivity() {
|
||||
|
||||
setContent {
|
||||
AmethystTheme {
|
||||
SignerConnectScreen(
|
||||
NappletConnectScreen(
|
||||
info = info,
|
||||
onConnect = { policy ->
|
||||
decided = true
|
||||
SignerConnectCoordinator.complete(token, AppConnectResult.Connected(policy))
|
||||
NappletConnectCoordinator.complete(token, AppConnectResult.Connected(policy))
|
||||
finish()
|
||||
},
|
||||
onBlock = {
|
||||
decided = true
|
||||
SignerConnectCoordinator.complete(token, AppConnectResult.Blocked)
|
||||
NappletConnectCoordinator.complete(token, AppConnectResult.Blocked)
|
||||
finish()
|
||||
},
|
||||
onCancel = {
|
||||
decided = true
|
||||
SignerConnectCoordinator.complete(token, AppConnectResult.Cancelled)
|
||||
NappletConnectCoordinator.complete(token, AppConnectResult.Cancelled)
|
||||
finish()
|
||||
},
|
||||
)
|
||||
@@ -106,14 +105,14 @@ class SignerConnectActivity : ComponentActivity() {
|
||||
}
|
||||
|
||||
override fun finish() {
|
||||
if (!decided) token?.let { SignerConnectCoordinator.cancel(it) }
|
||||
if (!decided) token?.let { NappletConnectCoordinator.cancel(it) }
|
||||
super.finish()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SignerConnectScreen(
|
||||
info: SignerConnectInfo,
|
||||
private fun NappletConnectScreen(
|
||||
info: NappletConnectInfo,
|
||||
onConnect: (AppSignerPolicy) -> Unit,
|
||||
onBlock: () -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
@@ -169,46 +168,12 @@ private fun SignerConnectScreen(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
// Show WHICH account is being connected (avatar + name), not a raw pubkey.
|
||||
if (info.accountName != null) {
|
||||
ConnectedAccountRow(info.accountName, info.accountPicture, info.accountPubKey)
|
||||
} else {
|
||||
Text(
|
||||
info.domain,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// What the app declared it needs (nostrconnect `perms`) — so consent is informed,
|
||||
// not a silent grant. Approving connects and pre-grants exactly these.
|
||||
if (info.requestedPermissions.isNotEmpty()) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Surface(
|
||||
modifier = Modifier.padding(horizontal = 24.dp).fillMaxWidth(),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
) {
|
||||
Column(modifier = Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(
|
||||
stringResource(R.string.nip46_connect_requests_title),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
info.requestedPermissions.forEach { perm ->
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Icon(
|
||||
MaterialSymbols.Check,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
Text(perm, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(
|
||||
info.domain,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
@@ -229,21 +194,21 @@ private fun SignerConnectScreen(
|
||||
) {
|
||||
PolicyOption(
|
||||
selected = selected == AppSignerPolicy.FULL_TRUST,
|
||||
symbol = MaterialSymbols.LockOpen,
|
||||
icon = "❤",
|
||||
label = stringResource(R.string.napplet_policy_full_trust),
|
||||
description = stringResource(R.string.napplet_policy_full_trust_desc),
|
||||
onClick = { selected = AppSignerPolicy.FULL_TRUST },
|
||||
)
|
||||
PolicyOption(
|
||||
selected = selected == AppSignerPolicy.REASONABLE,
|
||||
symbol = MaterialSymbols.Shield,
|
||||
icon = "👍",
|
||||
label = stringResource(R.string.napplet_policy_reasonable),
|
||||
description = stringResource(R.string.napplet_policy_reasonable_desc),
|
||||
onClick = { selected = AppSignerPolicy.REASONABLE },
|
||||
)
|
||||
PolicyOption(
|
||||
selected = selected == AppSignerPolicy.PARANOID,
|
||||
symbol = MaterialSymbols.Lock,
|
||||
icon = "🕶",
|
||||
label = stringResource(R.string.napplet_policy_paranoid),
|
||||
description = stringResource(R.string.napplet_policy_paranoid_desc),
|
||||
onClick = { selected = AppSignerPolicy.PARANOID },
|
||||
@@ -284,7 +249,7 @@ private fun SignerConnectScreen(
|
||||
@Composable
|
||||
private fun PolicyOption(
|
||||
selected: Boolean,
|
||||
symbol: MaterialSymbol,
|
||||
icon: String,
|
||||
label: String,
|
||||
description: String,
|
||||
onClick: () -> Unit,
|
||||
@@ -306,12 +271,7 @@ private fun PolicyOption(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Icon(
|
||||
symbol = symbol,
|
||||
contentDescription = null,
|
||||
tint = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(26.dp),
|
||||
)
|
||||
Text(icon, style = MaterialTheme.typography.headlineSmall)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(label, style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurface)
|
||||
Text(description, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
+12
-42
@@ -18,36 +18,21 @@
|
||||
* 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
|
||||
package com.vitorpamplona.amethyst.napplet
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppConnectResult
|
||||
import com.vitorpamplona.amethyst.commons.napplet.signers.AppConnectResult
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/** Everything the "Connect to Nostr" dialog needs to render. */
|
||||
data class SignerConnectInfo(
|
||||
data class NappletConnectInfo(
|
||||
val appletTitle: String,
|
||||
val coordinate: String,
|
||||
val domain: String,
|
||||
val iconUrl: String? = null,
|
||||
/**
|
||||
* The account the app is connecting to, shown as an avatar + name instead of a raw pubkey. When
|
||||
* [accountName] is null (e.g. napplet/browser paths that don't resolve it) the dialog falls back
|
||||
* to [domain]. [accountPubKey] seeds the robohash avatar fallback when there's no picture.
|
||||
*/
|
||||
val accountName: String? = null,
|
||||
val accountPicture: String? = null,
|
||||
val accountPubKey: String? = null,
|
||||
/**
|
||||
* Human-readable permissions the app declared it needs (from a `nostrconnect://…?perms=` offer),
|
||||
* shown so the user gives INFORMED consent before those ops are pre-granted. Empty for flows that
|
||||
* carry no declaration (bunker connect), which just show the trust picker.
|
||||
*/
|
||||
val requestedPermissions: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -55,9 +40,9 @@ data class SignerConnectInfo(
|
||||
* the Activity resolves the deferred with the user's choice.
|
||||
* A dismissed dialog resolves to [AppConnectResult.Cancelled] — fails closed, no silent grant.
|
||||
*/
|
||||
object SignerConnectCoordinator {
|
||||
object NappletConnectCoordinator {
|
||||
private class Pending(
|
||||
val info: SignerConnectInfo,
|
||||
val info: NappletConnectInfo,
|
||||
val deferred: CompletableDeferred<AppConnectResult>,
|
||||
)
|
||||
|
||||
@@ -65,41 +50,26 @@ object SignerConnectCoordinator {
|
||||
|
||||
suspend fun requestConnect(
|
||||
context: Context,
|
||||
info: SignerConnectInfo,
|
||||
info: NappletConnectInfo,
|
||||
): AppConnectResult {
|
||||
val token = UUID.randomUUID().toString()
|
||||
val deferred = CompletableDeferred<AppConnectResult>()
|
||||
pending[token] = Pending(info, deferred)
|
||||
|
||||
// Fast path when Amethyst already owns the foreground; the full-screen-intent notification
|
||||
// below is what surfaces the dialog when a connect request arrives while backgrounded (see
|
||||
// SignerConsentNotifier). Wrapped because a BAL-blocked launch can throw on some OEMs.
|
||||
runCatching {
|
||||
context.startActivity(
|
||||
Intent(context, SignerConnectActivity::class.java)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
.putExtra(EXTRA_TOKEN, token),
|
||||
)
|
||||
}
|
||||
|
||||
val notificationId =
|
||||
SignerConsentNotifier.show(
|
||||
context = context,
|
||||
activityClass = SignerConnectActivity::class.java,
|
||||
extraKey = EXTRA_TOKEN,
|
||||
token = token,
|
||||
titleRes = R.string.nip46_signer_notif_connect_title,
|
||||
)
|
||||
context.startActivity(
|
||||
Intent(context, NappletConnectActivity::class.java)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
.putExtra(EXTRA_TOKEN, token),
|
||||
)
|
||||
|
||||
return try {
|
||||
deferred.await()
|
||||
} finally {
|
||||
pending.remove(token)
|
||||
SignerConsentNotifier.cancel(context, notificationId)
|
||||
}
|
||||
}
|
||||
|
||||
fun infoFor(token: String): SignerConnectInfo? = pending[token]?.info
|
||||
fun infoFor(token: String): NappletConnectInfo? = pending[token]?.info
|
||||
|
||||
fun complete(
|
||||
token: String,
|
||||
+8
-74
@@ -23,19 +23,15 @@ package com.vitorpamplona.amethyst.napplet
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
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.layout.width
|
||||
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
|
||||
@@ -45,18 +41,11 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
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.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
@@ -65,7 +54,6 @@ import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
|
||||
import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon
|
||||
import com.vitorpamplona.amethyst.commons.napplet.permissions.GrantState
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
|
||||
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
|
||||
|
||||
/**
|
||||
@@ -180,74 +168,20 @@ private fun NappletConsentDialog(
|
||||
)
|
||||
}
|
||||
|
||||
// Operation detail box (may include content preview), plus the full event behind a
|
||||
// toggle: the summary truncates content and cannot spell out every tag, so for kinds
|
||||
// whose payload IS the tags (3, 5, 10000, 10002) this is the only complete disclosure.
|
||||
if (info.operationSummary.isNotBlank() || info.rawData.isNotBlank()) {
|
||||
// Operation detail box (may include content preview)
|
||||
if (info.operationSummary.isNotBlank()) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Surface(
|
||||
modifier = Modifier.padding(horizontal = 24.dp).fillMaxWidth(),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
if (info.operationSummary.isNotBlank()) {
|
||||
SelectionContainer {
|
||||
Text(
|
||||
info.operationSummary,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
// A single-account follow/mute change: show who, so the user recognizes
|
||||
// the face rather than parsing a name they may not read carefully.
|
||||
info.subject?.let { subject ->
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
RobohashFallbackAsyncImage(
|
||||
robot = subject.pubKey,
|
||||
model = subject.pictureUrl,
|
||||
contentDescription = subject.name,
|
||||
modifier = Modifier.size(36.dp).clip(CircleShape),
|
||||
loadProfilePicture = true,
|
||||
loadRobohash = true,
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
subject.name,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (info.rawData.isNotBlank()) {
|
||||
var showRawData by remember { mutableStateOf(false) }
|
||||
if (showRawData) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Surface(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 }) {
|
||||
Text(
|
||||
if (showRawData) {
|
||||
stringResource(R.string.napplet_consent_hide_event)
|
||||
} else {
|
||||
stringResource(R.string.napplet_consent_show_event)
|
||||
},
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
SelectionContainer {
|
||||
Text(
|
||||
info.operationSummary,
|
||||
modifier = Modifier.padding(12.dp),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-20
@@ -36,26 +36,6 @@ data class NappletConsentInfo(
|
||||
/** Whether a persistent "Always allow" choice may be offered (false for per-use caps like payments). */
|
||||
val allowAlways: Boolean,
|
||||
val iconUrl: String? = null,
|
||||
/**
|
||||
* The full unsigned event the applet asked us to sign, pretty-printed, shown behind a
|
||||
* "Show Event" toggle. Blank for requests that sign nothing. [operationSummary] is a lossy
|
||||
* rendering — it truncates content and cannot spell out every tag — so this is the only place
|
||||
* the user can see exactly what a signature would cover.
|
||||
*/
|
||||
val rawData: String = "",
|
||||
/**
|
||||
* The one account a follow/mute change is about, when the change names exactly one. Rendered as
|
||||
* an avatar + name so the user can recognize *who* at a glance instead of reading a bare count.
|
||||
* Null for multi-account edits and every other request.
|
||||
*/
|
||||
val subject: ConsentSubject? = null,
|
||||
)
|
||||
|
||||
/** A single account a consent dialog is about: enough to draw an avatar and a name. */
|
||||
data class ConsentSubject(
|
||||
val pubKey: String,
|
||||
val name: String,
|
||||
val pictureUrl: String?,
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
+4
-200
@@ -21,35 +21,22 @@
|
||||
package com.vitorpamplona.amethyst.napplet
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.PluralsRes
|
||||
import androidx.annotation.StringRes
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletCapability
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity
|
||||
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest
|
||||
import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.ui.pluralStringRes
|
||||
import com.vitorpamplona.quartz.lightning.LnInvoiceUtil
|
||||
import com.vitorpamplona.quartz.nip01Core.core.fastForEach
|
||||
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
|
||||
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent
|
||||
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
|
||||
|
||||
/**
|
||||
* Turns a pending [NappletRequest] into the human-readable [NappletConsentInfo] the consent dialog
|
||||
* shows — the applet's title, the capability label, and a per-operation summary (e.g. a note preview
|
||||
* or a sat amount). Localized via app resources.
|
||||
*
|
||||
* Reads [account] only to diff a proposed replaceable list (follows, relays, mutes) against the copy
|
||||
* already cached there, so the dialog can say what a signature would actually change. It never signs,
|
||||
* mutates, or exposes account state — the values it reads are the user's own public lists.
|
||||
* or a sat amount). Localized via app resources; holds only a [Context], no account state.
|
||||
*/
|
||||
class NappletConsentSummary(
|
||||
private val context: Context,
|
||||
private val account: Account,
|
||||
) {
|
||||
fun info(
|
||||
identity: NappletIdentity,
|
||||
@@ -64,196 +51,16 @@ class NappletConsentSummary(
|
||||
} else {
|
||||
resolveNappletMeta(identity.authorPubKey, identity.identifier, untitled)
|
||||
}
|
||||
val consequence = consequenceFor(request)
|
||||
return NappletConsentInfo(
|
||||
appletTitle = title,
|
||||
coordinate = identity.coordinate,
|
||||
capabilityLabel = context.getString(capability.labelRes()),
|
||||
operationSummary = listOfNotNull(summaryFor(request).ifBlank { null }, consequence?.text).joinToString("\n\n"),
|
||||
operationSummary = summaryFor(request),
|
||||
allowAlways = capability.canGrantAlways,
|
||||
iconUrl = iconUrl,
|
||||
rawData = rawEventFor(request),
|
||||
subject = consequence?.subject,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pretty-prints the unsigned event behind the consent dialog's "Show Event" toggle. Only the
|
||||
* signing requests carry one; everything else has nothing to disclose.
|
||||
*/
|
||||
private fun rawEventFor(request: NappletRequest): String =
|
||||
when (request) {
|
||||
is NappletRequest.Publish -> rawEvent(request.kind, request.tags, request.content, null)
|
||||
is NappletRequest.SignEvent -> rawEvent(request.kind, request.tags, request.content, request.createdAt)
|
||||
else -> ""
|
||||
}
|
||||
|
||||
private fun rawEvent(
|
||||
kind: Int,
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
createdAt: Long?,
|
||||
): String =
|
||||
buildString {
|
||||
append("kind: ").append(kind).append('\n')
|
||||
createdAt?.let { append("created_at: ").append(it).append('\n') }
|
||||
append("tags:")
|
||||
if (tags.isEmpty()) {
|
||||
append(" []\n")
|
||||
} else {
|
||||
append('\n')
|
||||
tags.fastForEach { tag -> append(" ").append(tag.joinToString(", ", "[", "]")).append('\n') }
|
||||
}
|
||||
append("content: ").append(content.ifEmpty { "(empty)" })
|
||||
}
|
||||
|
||||
/** A consequence line, plus the single account it is about when the change names exactly one. */
|
||||
private data class Consequence(
|
||||
val text: String,
|
||||
val subject: ConsentSubject? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* A plain-language warning for the kinds whose payload lives entirely in the tags. Without this
|
||||
* the dialog reads "publish a kind 3 event" while the user is actually about to replace their
|
||||
* whole social graph — the summary would be technically true and practically useless.
|
||||
*/
|
||||
private fun consequenceFor(request: NappletRequest): Consequence? =
|
||||
when (request) {
|
||||
is NappletRequest.Publish -> consequenceFor(request.kind, request.tags)
|
||||
is NappletRequest.SignEvent -> consequenceFor(request.kind, request.tags)
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun consequenceFor(
|
||||
kind: Int,
|
||||
tags: Array<Array<String>>,
|
||||
): Consequence? =
|
||||
when (kind) {
|
||||
ContactListEvent.KIND ->
|
||||
diffOf(
|
||||
current = account.kind3FollowList.getFollowListEvent()?.tags,
|
||||
proposed = tags,
|
||||
tagName = "p",
|
||||
template = R.string.napplet_consent_diff_follows,
|
||||
added = R.plurals.napplet_consent_diff_follow_added,
|
||||
removed = R.plurals.napplet_consent_diff_follow_removed,
|
||||
oneAdded = R.string.napplet_consent_diff_follow_one,
|
||||
oneRemoved = R.string.napplet_consent_diff_unfollow_one,
|
||||
)
|
||||
AdvertisedRelayListEvent.KIND ->
|
||||
diffOf(
|
||||
current = account.nip65RelayList.getNIP65RelayList()?.tags,
|
||||
proposed = tags,
|
||||
tagName = "r",
|
||||
template = R.string.napplet_consent_diff_relays,
|
||||
added = R.plurals.napplet_consent_diff_relay_added,
|
||||
removed = R.plurals.napplet_consent_diff_relay_removed,
|
||||
)
|
||||
// Public entries only: a mute list also carries encrypted ones, which are not in `tags`
|
||||
// and so cannot be diffed here.
|
||||
MuteListEvent.KIND ->
|
||||
diffOf(
|
||||
current = account.muteList.getMuteList()?.tags,
|
||||
proposed = tags,
|
||||
tagName = "p",
|
||||
template = R.string.napplet_consent_diff_mutes,
|
||||
added = R.plurals.napplet_consent_diff_mute_added,
|
||||
removed = R.plurals.napplet_consent_diff_mute_removed,
|
||||
oneAdded = R.string.napplet_consent_diff_mute_one,
|
||||
oneRemoved = R.string.napplet_consent_diff_unmute_one,
|
||||
)
|
||||
// Deletions have no prior version to compare against — the tags are the whole request.
|
||||
DeletionEvent.KIND ->
|
||||
pluralFor(R.plurals.napplet_consent_effect_deletes, countTag(tags, "e") + countTag(tags, "a"))
|
||||
?.let { Consequence(it) }
|
||||
// Any other kind: at least tell the user tags exist and can be inspected, so an empty
|
||||
// content preview never reads as "there is nothing else here".
|
||||
else ->
|
||||
if (tags.isNotEmpty()) {
|
||||
pluralFor(R.plurals.napplet_consent_effect_tags, tags.size)?.let { Consequence(it) }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Describes what a proposed replaceable list changes relative to the copy already on the account.
|
||||
* A bare total ("a list of 12 accounts") hides the dangerous case: the alarming edit is a list
|
||||
* that silently drops 130 follows, and only a diff surfaces that. Falls back to the total when
|
||||
* nothing is cached to compare against.
|
||||
*/
|
||||
private fun diffOf(
|
||||
current: Array<Array<String>>?,
|
||||
proposed: Array<Array<String>>,
|
||||
tagName: String,
|
||||
@StringRes template: Int,
|
||||
@PluralsRes added: Int,
|
||||
@PluralsRes removed: Int,
|
||||
@StringRes oneAdded: Int? = null,
|
||||
@StringRes oneRemoved: Int? = null,
|
||||
): Consequence {
|
||||
val next = valuesOf(proposed, tagName)
|
||||
val previous =
|
||||
current?.let { valuesOf(it, tagName) }
|
||||
?: return Consequence(pluralStringRes(context, R.plurals.napplet_consent_diff_no_baseline, next.size, next.size))
|
||||
|
||||
val addedKeys = next.filter { it !in previous }
|
||||
val removedKeys = previous.filter { it !in next }
|
||||
if (addedKeys.isEmpty() && removedKeys.isEmpty()) {
|
||||
return Consequence(context.getString(R.string.napplet_consent_diff_none))
|
||||
}
|
||||
|
||||
// The overwhelmingly common edit is a single follow/unfollow. Naming and picturing that one
|
||||
// account is far more use than "follows 1 new account" — the user can tell at a glance
|
||||
// whether it is who they expected.
|
||||
if (oneAdded != null && addedKeys.size == 1 && removedKeys.isEmpty()) {
|
||||
subjectOf(addedKeys.first())?.let { return Consequence(context.getString(oneAdded, it.name), it) }
|
||||
}
|
||||
if (oneRemoved != null && removedKeys.size == 1 && addedKeys.isEmpty()) {
|
||||
subjectOf(removedKeys.first())?.let { return Consequence(context.getString(oneRemoved, it.name), it) }
|
||||
}
|
||||
|
||||
val parts = listOfNotNull(pluralFor(added, addedKeys.size), pluralFor(removed, removedKeys.size))
|
||||
val summary =
|
||||
if (parts.size == 2) {
|
||||
context.getString(R.string.napplet_consent_diff_joiner, parts[0], parts[1])
|
||||
} else {
|
||||
parts.first()
|
||||
}
|
||||
return Consequence(context.getString(template, summary))
|
||||
}
|
||||
|
||||
/** Resolves a pubkey to a name + picture for the dialog, or null when the user isn't cached. */
|
||||
private fun subjectOf(pubKey: String): ConsentSubject? {
|
||||
val user = account.cache.getUserIfExists(pubKey) ?: return null
|
||||
return ConsentSubject(
|
||||
pubKey = pubKey,
|
||||
name = user.toBestDisplayName(),
|
||||
pictureUrl = user.profilePicture(),
|
||||
)
|
||||
}
|
||||
|
||||
/** The distinct values of every `[tagName, value, …]` tag. */
|
||||
private fun valuesOf(
|
||||
tags: Array<Array<String>>,
|
||||
tagName: String,
|
||||
): Set<String> {
|
||||
val out = mutableSetOf<String>()
|
||||
tags.fastForEach { if (it.size > 1 && it[0] == tagName) out.add(it[1]) }
|
||||
return out
|
||||
}
|
||||
|
||||
private fun pluralFor(
|
||||
resId: Int,
|
||||
count: Int,
|
||||
): String? = if (count <= 0) null else pluralStringRes(context, resId, count, count)
|
||||
|
||||
private fun countTag(
|
||||
tags: Array<Array<String>>,
|
||||
name: String,
|
||||
): Int = tags.count { it.isNotEmpty() && it[0] == name }
|
||||
|
||||
private fun summaryFor(request: NappletRequest): String =
|
||||
when (request) {
|
||||
is NappletRequest.GetPublicKey -> context.getString(R.string.napplet_consent_get_pubkey)
|
||||
@@ -284,14 +91,11 @@ class NappletConsentSummary(
|
||||
}
|
||||
is NappletRequest.NotifyList, is NappletRequest.NotifyDismiss -> context.getString(R.string.napplet_consent_notify)
|
||||
is NappletRequest.PayInvoice -> {
|
||||
// getAmountInSats returns ZERO (not null, not a throw) for an amountless BOLT11, so a
|
||||
// naive read renders "pay 0 sats" — telling the user a payment is free when the amount
|
||||
// is in fact unspecified and decided by the payee. Treat non-positive as "no amount".
|
||||
val sats = runCatching { LnInvoiceUtil.getAmountInSats(request.invoice).toLong() }.getOrNull()
|
||||
if (sats != null && sats > 0) {
|
||||
if (sats != null) {
|
||||
pluralStringRes(context, R.plurals.napplet_consent_pay_amount, sats.toInt(), sats)
|
||||
} else {
|
||||
context.getString(R.string.napplet_consent_pay_no_amount)
|
||||
context.getString(R.string.napplet_consent_pay)
|
||||
}
|
||||
}
|
||||
is NappletRequest.ResourceBytes -> context.getString(R.string.napplet_consent_resource)
|
||||
|
||||
@@ -39,18 +39,15 @@ import kotlinx.coroutines.launch
|
||||
*/
|
||||
class NappletIdentityWatch(
|
||||
private val scope: CoroutineScope,
|
||||
private val pubKey: (boundPubKey: String) -> Flow<String>,
|
||||
private val pubKey: () -> Flow<String>,
|
||||
) {
|
||||
private var job: Job? = null
|
||||
|
||||
fun start(
|
||||
boundPubKey: String,
|
||||
push: (String) -> Unit,
|
||||
) {
|
||||
fun start(push: (String) -> Unit) {
|
||||
stop()
|
||||
job =
|
||||
scope.launch {
|
||||
pubKey(boundPubKey)
|
||||
pubKey()
|
||||
.distinctUntilChanged()
|
||||
.drop(1)
|
||||
.collect { push(NappletProtocolJson.encodeIdentityChanged(it)) }
|
||||
|
||||
@@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.napplet
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletCapability
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import java.security.SecureRandom
|
||||
|
||||
@@ -46,18 +45,6 @@ object NappletLaunchRegistry {
|
||||
data class Session(
|
||||
val identity: NappletIdentity,
|
||||
val declared: Set<NappletCapability>,
|
||||
/**
|
||||
* The account this surface was launched as. Requests resolve their signer through THIS, not
|
||||
* through whichever account happens to be active when they arrive.
|
||||
*
|
||||
* A full-screen host is a separate activity that an account switch does not tear down, so
|
||||
* resolving live meant its WebView kept account A's cookies while the broker signed as B —
|
||||
* a page showing one identity while another signed, and B's session written into A's
|
||||
* storage jar. Binding here gives both halves of the rule for free: embedded surfaces are
|
||||
* rebuilt on a switch, so they re-mint and follow the active account, while a full-screen
|
||||
* surface keeps the account it was opened with.
|
||||
*/
|
||||
val accountPubKey: HexKey,
|
||||
)
|
||||
|
||||
// Access-ordered + capped so tokens from long-closed napplets can't accumulate without bound. The
|
||||
@@ -73,10 +60,9 @@ object NappletLaunchRegistry {
|
||||
fun register(
|
||||
identity: NappletIdentity,
|
||||
declared: Set<NappletCapability>,
|
||||
accountPubKey: HexKey,
|
||||
): String {
|
||||
val token = ByteArray(32).also(secureRandom::nextBytes).toHexKey()
|
||||
sessions[token] = Session(identity, declared, accountPubKey)
|
||||
sessions[token] = Session(identity, declared)
|
||||
return token
|
||||
}
|
||||
|
||||
|
||||
@@ -120,16 +120,7 @@ object NappletLauncher {
|
||||
// requests back to THIS identity + declared set, regardless of anything the sandbox sends.
|
||||
val identity = NappletIdentity(authorPubKey = authorPubKey, identifier = identifier, aggregateHash = aggregateHash)
|
||||
val declared = profile.declaredCapabilities(requires)
|
||||
// Bound to the account launching it, so the surface keeps signing as that account even if the
|
||||
// user switches while it is open (an embedded surface is rebuilt on a switch and re-mints).
|
||||
// An empty key can never match a loaded account, so a launch with nobody signed in fails
|
||||
// closed at the broker rather than falling back to whoever signs in later.
|
||||
val launchAccountPubKey =
|
||||
Amethyst.instance.sessionManager
|
||||
.loggedInAccount()
|
||||
?.pubKey
|
||||
.orEmpty()
|
||||
val launchToken = NappletLaunchRegistry.register(identity, declared, launchAccountPubKey)
|
||||
val launchToken = NappletLaunchRegistry.register(identity, declared)
|
||||
|
||||
// Resolve the per-site network choice (Tor default; a site can be opted out to the open web).
|
||||
// Locked napplets always keep Tor for their blob fetches — only nSites expose the toggle.
|
||||
@@ -165,9 +156,6 @@ object NappletLauncher {
|
||||
putString(NappletHostContract.EXTRA_HOST_PROFILE, profile.name)
|
||||
putBoolean(NappletHostContract.EXTRA_USE_TOR, useTor)
|
||||
putString(NappletHostContract.EXTRA_THEME, theme)
|
||||
// Opaque per-account storage partition, so a napplet/nSite can't carry one npub's cookies
|
||||
// and localStorage into another. Derived here (the sandbox never sees the pubkey).
|
||||
putString(NappletHostContract.EXTRA_WEBVIEW_PROFILE, NappletWebViewProfiles.current())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+6
-7
@@ -38,13 +38,12 @@ import java.util.concurrent.atomic.AtomicInteger
|
||||
* `relay.eose`. Encodes the `relay.event`/`relay.eose`/`relay.closed` pushes and hands them to the
|
||||
* caller-supplied sink — it never touches the transport itself.
|
||||
*
|
||||
* The account is supplied per [open] by the caller, which resolves it from the requesting surface's
|
||||
* LAUNCH account — not from whoever is signed in at the time. A full-screen surface survives an
|
||||
* account switch, and reading live would have pointed its REQs at the new account's relays while its
|
||||
* signatures still came from the old one. [open] is reached only after the broker authorized the
|
||||
* subscription (RELAY consent).
|
||||
* [account] is read live (so it always targets the currently signed-in account); [open] is reached
|
||||
* only after the broker authorized the subscription (RELAY consent).
|
||||
*/
|
||||
class NappletLiveSubscriptions {
|
||||
class NappletLiveSubscriptions(
|
||||
private val account: () -> Account?,
|
||||
) {
|
||||
private val liveSubs = ConcurrentHashMap<String, LiveSub>()
|
||||
private val liveSeq = AtomicInteger(0)
|
||||
|
||||
@@ -63,9 +62,9 @@ class NappletLiveSubscriptions {
|
||||
fun open(
|
||||
nappletSubId: String,
|
||||
filters: List<Filter>,
|
||||
account: Account?,
|
||||
push: (String) -> Unit,
|
||||
) {
|
||||
val account = account()
|
||||
val relays = account?.homeRelays?.flow?.value ?: emptySet()
|
||||
if (account == null || filters.isEmpty() || relays.isEmpty()) {
|
||||
push(NappletProtocolJson.encodeRelayEose(nappletSubId))
|
||||
|
||||
+325
@@ -0,0 +1,325 @@
|
||||
/*
|
||||
* 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.napplet
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
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.text.selection.SelectionContainer
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
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.platform.LocalConfiguration
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import com.vitorpamplona.amethyst.R
|
||||
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.commons.napplet.signers.SignerOpGrant
|
||||
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
class NappletSignerConsentActivity : ComponentActivity() {
|
||||
private var token: String? = null
|
||||
private var decided = false
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val token = intent.getStringExtra(NappletSignerConsentCoordinator.EXTRA_TOKEN)
|
||||
this.token = token
|
||||
val info = token?.let { NappletSignerConsentCoordinator.infoFor(it) }
|
||||
if (token == null || info == null) {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
|
||||
setContent {
|
||||
AmethystTheme {
|
||||
NappletSignerConsentDialog(
|
||||
info = info,
|
||||
onGrant = { grant ->
|
||||
decided = true
|
||||
NappletSignerConsentCoordinator.complete(token, grant)
|
||||
finish()
|
||||
},
|
||||
onDismiss = {
|
||||
decided = true
|
||||
NappletSignerConsentCoordinator.cancel(token)
|
||||
finish()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun finish() {
|
||||
if (!decided) token?.let { NappletSignerConsentCoordinator.cancel(it) }
|
||||
super.finish()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NappletSignerConsentDialog(
|
||||
info: NappletSignerConsentInfo,
|
||||
onGrant: (SignerOpGrant) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
var showRawData by remember { mutableStateOf(false) }
|
||||
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,
|
||||
)
|
||||
Text(
|
||||
info.coordinate.substringAfter(':', "").ifBlank { info.coordinate.substringBefore(':').take(12) + "…" },
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
|
||||
val hasContent = info.contentPreview.isNotBlank() || info.rawData.isNotBlank()
|
||||
if (hasContent) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Surface(
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(horizontal = 24.dp)
|
||||
.fillMaxWidth(),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
// Primary: always allow this op
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
* 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.napplet
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerOp
|
||||
import com.vitorpamplona.amethyst.commons.napplet.signers.SignerOpGrant
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/** Everything the per-operation consent dialog needs to render. */
|
||||
data class NappletSignerConsentInfo(
|
||||
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,
|
||||
)
|
||||
|
||||
/**
|
||||
* Bridges the broker to the per-operation signer consent UI.
|
||||
* A dismissed dialog resolves to [SignerOpGrant.DenyOnce] — fails closed.
|
||||
*/
|
||||
object NappletSignerConsentCoordinator {
|
||||
private class Pending(
|
||||
val info: NappletSignerConsentInfo,
|
||||
val deferred: CompletableDeferred<SignerOpGrant>,
|
||||
)
|
||||
|
||||
private val pending = ConcurrentHashMap<String, Pending>()
|
||||
|
||||
suspend fun requestConsent(
|
||||
context: Context,
|
||||
info: NappletSignerConsentInfo,
|
||||
): SignerOpGrant {
|
||||
val token = UUID.randomUUID().toString()
|
||||
val deferred = CompletableDeferred<SignerOpGrant>()
|
||||
pending[token] = Pending(info, deferred)
|
||||
|
||||
context.startActivity(
|
||||
Intent(context, NappletSignerConsentActivity::class.java)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
.putExtra(EXTRA_TOKEN, token),
|
||||
)
|
||||
|
||||
return try {
|
||||
deferred.await()
|
||||
} finally {
|
||||
pending.remove(token)
|
||||
}
|
||||
}
|
||||
|
||||
fun infoFor(token: String): NappletSignerConsentInfo? = pending[token]?.info
|
||||
|
||||
fun complete(
|
||||
token: String,
|
||||
grant: SignerOpGrant,
|
||||
) {
|
||||
pending[token]?.deferred?.complete(grant)
|
||||
}
|
||||
|
||||
fun cancel(token: String) {
|
||||
pending[token]?.deferred?.complete(SignerOpGrant.DenyOnce)
|
||||
}
|
||||
|
||||
const val EXTRA_TOKEN = "napplet_signer_consent_token"
|
||||
}
|
||||
@@ -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.napplet
|
||||
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import java.security.MessageDigest
|
||||
|
||||
/**
|
||||
* Mints the opaque per-account WebView storage-profile name the sandbox partitions cookies,
|
||||
* localStorage, IndexedDB and service workers by.
|
||||
*
|
||||
* Every embedded app follows the currently-selected account, and each account gets its OWN storage
|
||||
* jar: switching from A to B hands B a clean jar, switching back to A restores A's session intact
|
||||
* (this is a partition, not a wipe).
|
||||
*
|
||||
* The name is a hash rather than the pubkey because the `:napplet` sandbox must never learn which
|
||||
* account it is running for — it only gets a stable, meaningless token. Stability is what makes
|
||||
* sessions survive a switch, so the derivation must never change once shipped.
|
||||
*
|
||||
* The applying half lives in `:nappletHost` (`NappletWebViewProfile`), which validates the shape
|
||||
* before handing it to `ProfileStore`.
|
||||
*/
|
||||
object NappletWebViewProfiles {
|
||||
/** Domain separator so this hash can never collide with another use of SHA-256(pubkey). */
|
||||
private const val DOMAIN = "amethyst-webview-profile-v1:"
|
||||
|
||||
/** 128 bits of a SHA-256 is far past collision-proof for a handful of on-device accounts. */
|
||||
private const val NAME_LENGTH = 32
|
||||
|
||||
/** The profile name for the account embedded apps currently run as, or null when logged out. */
|
||||
fun current(): String? = forPubKey(Amethyst.instance.nappletAccountScope())
|
||||
|
||||
/** Stable profile name for [pubKey]; null for a blank scope (no account -> shared default jar). */
|
||||
fun forPubKey(pubKey: HexKey): String? {
|
||||
if (pubKey.isBlank()) return null
|
||||
|
||||
return MessageDigest
|
||||
.getInstance("SHA-256")
|
||||
.digest((DOMAIN + pubKey).toByteArray())
|
||||
.toHexKey()
|
||||
.take(NAME_LENGTH)
|
||||
}
|
||||
}
|
||||
@@ -23,20 +23,13 @@ package com.vitorpamplona.amethyst.napplet
|
||||
import android.content.Context
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
|
||||
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletCapability
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity
|
||||
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest
|
||||
import com.vitorpamplona.amethyst.connectedApps.consent.SignerConnectInfo
|
||||
import com.vitorpamplona.amethyst.connectedApps.consent.SignerConsentInfo
|
||||
import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerOp
|
||||
import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.kindNameFor
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
/** Human-readable label for a [NostrSignerOp]. */
|
||||
@@ -45,31 +38,15 @@ fun NostrSignerOp.label(context: Context): String =
|
||||
is NostrSignerOp.SignKind -> context.getString(R.string.napplet_op_sign_kind_named, kindNameFor(context, kind), kind)
|
||||
NostrSignerOp.Encrypt -> context.getString(R.string.napplet_op_encrypt)
|
||||
NostrSignerOp.Decrypt -> context.getString(R.string.napplet_op_decrypt)
|
||||
is NostrSignerOp.DecryptFrom -> context.getString(R.string.napplet_op_decrypt_from, counterpartyLabel(counterparty))
|
||||
}
|
||||
|
||||
/**
|
||||
* A person's display name for a consent prompt: their profile name when we have it cached, otherwise
|
||||
* a shortened npub. Never empty — "read your private messages with <nothing>" would be worse than the
|
||||
* broad wording it replaces.
|
||||
*/
|
||||
fun counterpartyLabel(pubKeyHex: HexKey): String {
|
||||
LocalCache
|
||||
.getUserIfExists(pubKeyHex)
|
||||
?.toBestDisplayName()
|
||||
?.ifBlank { null }
|
||||
?.let { return it }
|
||||
val npub = runCatching { NPub.create(pubKeyHex) }.getOrNull()
|
||||
return if (npub != null) npub.take(12) + "…" else pubKeyHex.take(12) + "…"
|
||||
}
|
||||
|
||||
/** Builds the [SignerConsentInfo] needed by the per-op consent dialog. */
|
||||
/** Builds the [NappletSignerConsentInfo] needed by the per-op consent dialog. */
|
||||
fun buildSignerConsentInfo(
|
||||
context: Context,
|
||||
identity: NappletIdentity,
|
||||
op: NostrSignerOp,
|
||||
request: NappletRequest,
|
||||
): SignerConsentInfo {
|
||||
): NappletSignerConsentInfo {
|
||||
val untitled = context.getString(R.string.napplet_fallback_title, identity.authorPubKey.take(8))
|
||||
val (title, iconUrl) =
|
||||
if (identity.authorPubKey == "browser") {
|
||||
@@ -107,13 +84,7 @@ fun buildSignerConsentInfo(
|
||||
}
|
||||
else -> ""
|
||||
}
|
||||
val previewTemplate =
|
||||
when (request) {
|
||||
is NappletRequest.Publish -> EventTemplate<Event>(TimeUtils.now(), request.kind, request.tags, request.content)
|
||||
is NappletRequest.SignEvent -> EventTemplate<Event>(request.createdAt, request.kind, request.tags, request.content)
|
||||
else -> null
|
||||
}
|
||||
return SignerConsentInfo(
|
||||
return NappletSignerConsentInfo(
|
||||
appletTitle = title,
|
||||
coordinate = identity.coordinate,
|
||||
op = op,
|
||||
@@ -121,21 +92,14 @@ fun buildSignerConsentInfo(
|
||||
contentPreview = preview,
|
||||
rawData = rawData,
|
||||
iconUrl = iconUrl,
|
||||
previewTemplate = previewTemplate,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a [SignerConnectInfo] for the first-connect dialog. [declared] is the capability set the
|
||||
* connection pre-grants as ALLOW_ALWAYS on accept, so it is surfaced as
|
||||
* [SignerConnectInfo.requestedPermissions] — otherwise the dialog would be asking the user to
|
||||
* approve a set it never showed them.
|
||||
*/
|
||||
/** Creates a [NappletConnectInfo] for the first-connect dialog. */
|
||||
fun buildConnectInfo(
|
||||
context: Context,
|
||||
identity: NappletIdentity,
|
||||
declared: Set<NappletCapability> = emptySet(),
|
||||
): SignerConnectInfo {
|
||||
): NappletConnectInfo {
|
||||
val untitled = context.getString(R.string.napplet_fallback_title, identity.authorPubKey.take(8))
|
||||
val (title, iconUrl) =
|
||||
if (identity.authorPubKey == "browser") {
|
||||
@@ -150,18 +114,5 @@ fun buildConnectInfo(
|
||||
} else {
|
||||
identity.identifier.ifBlank { identity.authorPubKey.take(12) + "…" }
|
||||
}
|
||||
// Only the capabilities that actually get pre-granted are listed; SHELL/THEME never prompt and
|
||||
// VALUE is per-use, so listing them would overstate what accepting hands over.
|
||||
val preGranted =
|
||||
declared
|
||||
.filter { it.requiresConsent && !it.requiresPerUseConsent }
|
||||
.map { context.getString(it.labelRes()) }
|
||||
.sorted()
|
||||
return SignerConnectInfo(
|
||||
appletTitle = title,
|
||||
coordinate = identity.coordinate,
|
||||
domain = domain,
|
||||
iconUrl = iconUrl,
|
||||
requestedPermissions = preGranted,
|
||||
)
|
||||
return NappletConnectInfo(appletTitle = title, coordinate = identity.coordinate, domain = domain, iconUrl = iconUrl)
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
package com.vitorpamplona.amethyst.napplet
|
||||
|
||||
import android.content.Context
|
||||
import androidx.core.net.toUri
|
||||
import android.net.Uri
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
@@ -86,7 +86,7 @@ object WebAppNetworkRegistry {
|
||||
}
|
||||
|
||||
/** The host key for [url] (e.g. `vitorpamplona.com`), or the raw string if it has no host. */
|
||||
fun hostKeyOf(url: String): String = runCatching { url.toUri().host }.getOrNull()?.takeIf { it.isNotBlank() } ?: url
|
||||
fun hostKeyOf(url: String): String = runCatching { Uri.parse(url).host }.getOrNull()?.takeIf { it.isNotBlank() } ?: url
|
||||
|
||||
/** Whether the site behind [url] routes through Tor. Defaults to true (Tor) for any site never set. */
|
||||
fun useTor(url: String): Boolean = modes[hostKeyOf(url)] ?: true
|
||||
|
||||
+10
-12
@@ -27,9 +27,6 @@ import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrConnectPrompt
|
||||
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerConsentPrompt
|
||||
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionLedger
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletBroker
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletConsentPrompt
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletIdentityGateway
|
||||
@@ -44,12 +41,15 @@ import com.vitorpamplona.amethyst.commons.napplet.NappletUploadGateway
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletUploadResult
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletWalletGateway
|
||||
import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger
|
||||
import com.vitorpamplona.amethyst.connectedApps.consent.SignerConnectCoordinator
|
||||
import com.vitorpamplona.amethyst.connectedApps.consent.SignerConsentCoordinator
|
||||
import com.vitorpamplona.amethyst.commons.napplet.signers.NostrConnectPrompt
|
||||
import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerConsentPrompt
|
||||
import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerPermissionLedger
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.napplet.NappletConnectCoordinator
|
||||
import com.vitorpamplona.amethyst.napplet.NappletConsentCoordinator
|
||||
import com.vitorpamplona.amethyst.napplet.NappletConsentSummary
|
||||
import com.vitorpamplona.amethyst.napplet.NappletNotificationStore
|
||||
import com.vitorpamplona.amethyst.napplet.NappletSignerConsentCoordinator
|
||||
import com.vitorpamplona.amethyst.napplet.buildConnectInfo
|
||||
import com.vitorpamplona.amethyst.napplet.buildSignerConsentInfo
|
||||
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader
|
||||
@@ -81,9 +81,7 @@ class AccountNappletGateways(
|
||||
private val httpClient: (useProxy: Boolean) -> OkHttpClient,
|
||||
private val signerLedger: NostrSignerPermissionLedger? = null,
|
||||
) {
|
||||
// Takes the account so the consent dialog can diff a proposed replaceable list (follows, relays,
|
||||
// mutes) against the copy already cached here, and say what actually changes.
|
||||
private val consentSummary = NappletConsentSummary(context, account)
|
||||
private val consentSummary = NappletConsentSummary(context)
|
||||
|
||||
// Reuse the app-wide HTTP client so napplet blob fetches inherit the same Tor
|
||||
// routing, Onion-Location discovery/rewriting, Blossom cache and pool as the
|
||||
@@ -140,16 +138,16 @@ class AccountNappletGateways(
|
||||
}
|
||||
|
||||
val connectPrompt =
|
||||
NostrConnectPrompt { identity, declared ->
|
||||
SignerConnectCoordinator.requestConnect(
|
||||
NostrConnectPrompt { identity ->
|
||||
NappletConnectCoordinator.requestConnect(
|
||||
context = context,
|
||||
info = buildConnectInfo(context, identity, declared),
|
||||
info = buildConnectInfo(context, identity),
|
||||
)
|
||||
}
|
||||
|
||||
val signerConsent =
|
||||
NostrSignerConsentPrompt { identity, op, request ->
|
||||
SignerConsentCoordinator.requestConsent(
|
||||
NappletSignerConsentCoordinator.requestConsent(
|
||||
context = context,
|
||||
info = buildSignerConsentInfo(context, identity, op, request),
|
||||
)
|
||||
|
||||
+19
-25
@@ -18,27 +18,21 @@
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.richtext
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import android.util.LruCache
|
||||
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
|
||||
import com.vitorpamplona.quartz.utils.cache.ConcurrentLruCache
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextViewerState
|
||||
import com.vitorpamplona.amethyst.commons.richtext.UrlParser
|
||||
|
||||
/**
|
||||
* The shared, cross-platform cache in front of [RichTextParser]. Both Amethyst
|
||||
* Android and Amethyst Desktop render the same parsed [RichTextViewerState] from
|
||||
* one place, so the same content quoted in multiple notes is only parsed once.
|
||||
*
|
||||
* Lives in `jvmAndroid` because it depends on [ConcurrentLruCache] (a JCA-free,
|
||||
* lock-free-read LRU that is not in `commonMain`). iOS/`commonMain` callers use
|
||||
* the uncached [RichTextParser] directly until a KMP cache is available.
|
||||
*/
|
||||
object CachedRichTextParser {
|
||||
// Global across every feed. Sized to hold the active feed's visible + prefetched
|
||||
// working set plus a few other feeds' recent entries, so pre-parsed bodies survive
|
||||
// until the render reads them and feed switches don't thrash. Each entry is one
|
||||
// note's parsed segments — typically single-digit KB.
|
||||
private val richTextCache = ConcurrentLruCache<Int, RichTextViewerState>(500)
|
||||
private val isMarkdownCache = ConcurrentLruCache<Int, Boolean>(200)
|
||||
// (see PrefetchFeedMedia) working set plus a few other feeds' recent entries, so
|
||||
// pre-parsed bodies survive until the render reads them and feed switches don't
|
||||
// thrash. Each entry is one note's parsed segments — typically single-digit KB.
|
||||
private val richTextCache = LruCache<Int, RichTextViewerState>(500)
|
||||
private val isMarkdownCache = LruCache<Int, Boolean>(200)
|
||||
|
||||
private fun hashCodeCache(
|
||||
content: String,
|
||||
@@ -75,7 +69,7 @@ object CachedRichTextParser {
|
||||
tags: ImmutableListOfLists<String>,
|
||||
callbackUri: String? = null,
|
||||
authorPubKey: String? = null,
|
||||
): RichTextViewerState? = richTextCache.get(hashCodeCache(content, tags, callbackUri, authorPubKey))
|
||||
): RichTextViewerState? = richTextCache[hashCodeCache(content, tags, callbackUri, authorPubKey)]
|
||||
|
||||
fun parseText(
|
||||
content: String,
|
||||
@@ -84,13 +78,13 @@ object CachedRichTextParser {
|
||||
authorPubKey: String? = null,
|
||||
): RichTextViewerState {
|
||||
val key = hashCodeCache(content, tags, callbackUri, authorPubKey)
|
||||
val cached = richTextCache.get(key)
|
||||
val cached = richTextCache[key]
|
||||
return if (cached != null) {
|
||||
cached
|
||||
} else {
|
||||
val newState = RichTextParser().parseText(content, tags, callbackUri, authorPubKey)
|
||||
richTextCache.put(key, newState)
|
||||
newState
|
||||
val newUrls = RichTextParser().parseText(content, tags, callbackUri, authorPubKey)
|
||||
richTextCache.put(key, newUrls)
|
||||
newUrls
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,7 +92,7 @@ object CachedRichTextParser {
|
||||
// notes only pays for the scan once. The decision is purely a function of `content`.
|
||||
fun isMarkdown(content: String): Boolean {
|
||||
val key = content.hashCode()
|
||||
isMarkdownCache.get(key)?.let { return it }
|
||||
isMarkdownCache[key]?.let { return it }
|
||||
val result = computeIsMarkdown(content)
|
||||
isMarkdownCache.put(key, result)
|
||||
return result
|
||||
@@ -302,15 +296,15 @@ object CachedRichTextParser {
|
||||
}
|
||||
|
||||
object CachedUrlParser {
|
||||
private val parsedUrlsCache = ConcurrentLruCache<Int, List<String>>(10)
|
||||
private val parsedUrlsCache = LruCache<Int, List<String>>(10)
|
||||
|
||||
fun cachedParseValidUrls(content: String): List<String>? = parsedUrlsCache.get(content.hashCode())
|
||||
fun cachedParseValidUrls(content: String): List<String> = parsedUrlsCache[content.hashCode()]
|
||||
|
||||
fun parseValidUrls(content: String): List<String> {
|
||||
if (content.isEmpty()) return emptyList()
|
||||
|
||||
val key = content.hashCode()
|
||||
val cached = parsedUrlsCache.get(key)
|
||||
val cached = parsedUrlsCache[key]
|
||||
return if (cached != null) {
|
||||
cached
|
||||
} else {
|
||||
+2
-3
@@ -27,14 +27,13 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.net.toUri
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.MainActivity
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
|
||||
/**
|
||||
* Posts user-visible "starting soon" notifications for NIP-52 appointments the user has RSVP'd
|
||||
* to as ACCEPTED. Mirrors the shape of [com.vitorpamplona.amethyst.service.scheduledposts.AndroidScheduledPostNotifier]
|
||||
* to as ACCEPTED. Mirrors the shape of [com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostNotifier]
|
||||
* so the two notification surfaces stay consistent.
|
||||
*/
|
||||
object CalendarReminderNotifier {
|
||||
@@ -65,7 +64,7 @@ object CalendarReminderNotifier {
|
||||
val tapIntent =
|
||||
Intent(context, MainActivity::class.java).apply {
|
||||
action = Intent.ACTION_VIEW
|
||||
data = deepLink.toUri()
|
||||
data = android.net.Uri.parse(deepLink)
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
}
|
||||
val tapPendingIntent =
|
||||
|
||||
+2
-3
@@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.service.calendar
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.core.content.edit
|
||||
|
||||
/**
|
||||
* Device-wide preferences for the calendar reminder worker.
|
||||
@@ -41,13 +40,13 @@ class CalendarReminderPrefs(
|
||||
fun isEnabled(): Boolean = prefs.getBoolean(KEY_ENABLED, DEFAULT_ENABLED)
|
||||
|
||||
fun setEnabled(enabled: Boolean) {
|
||||
prefs.edit { putBoolean(KEY_ENABLED, enabled) }
|
||||
prefs.edit().putBoolean(KEY_ENABLED, enabled).apply()
|
||||
}
|
||||
|
||||
fun leadMinutes(): Int = prefs.getInt(KEY_LEAD_MINUTES, DEFAULT_LEAD_MINUTES)
|
||||
|
||||
fun setLeadMinutes(minutes: Int) {
|
||||
prefs.edit { putInt(KEY_LEAD_MINUTES, minutes) }
|
||||
prefs.edit().putInt(KEY_LEAD_MINUTES, minutes).apply()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
+1
-2
@@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.service.calendar
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.core.content.edit
|
||||
|
||||
/**
|
||||
* Persistent "I've already notified for this event" set. Backed by [SharedPreferences] because
|
||||
@@ -55,7 +54,7 @@ class CalendarReminderStore(
|
||||
eventId: String,
|
||||
eventStartSeconds: Long,
|
||||
) {
|
||||
prefs.edit { putLong(keyFor(eventId), eventStartSeconds) }
|
||||
prefs.edit().putLong(keyFor(eventId), eventStartSeconds).apply()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+7
-60
@@ -26,11 +26,9 @@ import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.appointmentView
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.resourceusage.UsageKeys
|
||||
import com.vitorpamplona.amethyst.ui.pluralStringRes
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
|
||||
@@ -49,26 +47,15 @@ import java.util.concurrent.TimeUnit
|
||||
* consults [CalendarReminderStore] to skip events that have already been notified for. Run as
|
||||
* a 15-minute periodic worker: that's the WorkManager minimum and matches the resolution of
|
||||
* the reminder UI ("starts in ~15 min" is the smallest interval users perceive as "soon").
|
||||
*
|
||||
* The periodic chain is only kept alive while it can plausibly fire: the ACCEPTED-RSVP
|
||||
* observer in AppModules calls [schedule] when an accepted RSVP lands in LocalCache, and
|
||||
* [doWork] cancels the chain when the cache holds no accepted RSVP that could still start.
|
||||
* LocalCache is memory-only, so a WorkManager wake of a dead process always sees an empty
|
||||
* cache and can never fire a reminder — an unconditional periodic schedule would cold-start
|
||||
* the whole app graph every 15 minutes forever for zero benefit.
|
||||
*/
|
||||
class CalendarReminderWorker(
|
||||
appContext: Context,
|
||||
params: WorkerParameters,
|
||||
) : CoroutineWorker(appContext, params) {
|
||||
override suspend fun doWork(): Result {
|
||||
runCatching { Amethyst.instance.resourceUsage.add(UsageKeys.workerRuns("calendarReminder"), 1) }
|
||||
val prefs = CalendarReminderPrefs(applicationContext)
|
||||
if (!prefs.isEnabled()) {
|
||||
Log.d(TAG) { "Reminders disabled; ending periodic chain." }
|
||||
// The settings toggle re-schedules on enable; no reason to keep
|
||||
// waking the process while the feature is off.
|
||||
cancel(applicationContext)
|
||||
Log.d(TAG) { "Reminders disabled; skipping scan." }
|
||||
return Result.success()
|
||||
}
|
||||
val now = TimeUtils.now()
|
||||
@@ -79,7 +66,12 @@ class CalendarReminderWorker(
|
||||
// multi-account "all logged-in pubkeys" view here, so we accept any RSVP that's
|
||||
// present in cache — the alternative (looking only at the foreground account) would
|
||||
// silently break notifications for account switching during the lead window.
|
||||
val acceptedRsvps = acceptedRsvpsInCache()
|
||||
val acceptedRsvps =
|
||||
LocalCache.addressables
|
||||
.filterIntoSet { _, note ->
|
||||
val e = note.event
|
||||
e is CalendarRSVPEvent && e.status() == RSVPStatusTag.STATUS.ACCEPTED
|
||||
}.mapNotNull { it.event as? CalendarRSVPEvent }
|
||||
|
||||
Log.d(TAG) { "Worker scanning ${acceptedRsvps.size} accepted RSVPs (now=$now, lead=${prefs.leadMinutes()}m)" }
|
||||
|
||||
@@ -118,22 +110,6 @@ class CalendarReminderWorker(
|
||||
|
||||
// Prune entries for events that ended more than a day ago — they can't fire again.
|
||||
store.forgetBefore(now - PRUNE_AGE_SECONDS)
|
||||
|
||||
// Nothing left that could ever fire → end the periodic chain instead of
|
||||
// waking the process every 15 minutes forever. The observers in
|
||||
// AppModules re-schedule the worker the next time a live session sees
|
||||
// an accepted RSVP or a calendar-event update.
|
||||
//
|
||||
// Decide on a FRESH cache snapshot, not the one from the start of the
|
||||
// run: an RSVP accepted while this run was scanning already fired the
|
||||
// observer, whose schedule() uses KEEP and no-ops while this chain
|
||||
// still exists — cancelling on the stale snapshot would kill the chain
|
||||
// with that RSVP's reminder permanently lost (observeNewEvents never
|
||||
// re-fires for an event that is already in cache).
|
||||
if (!couldStillFire(acceptedRsvpsInCache(), TimeUtils.now())) {
|
||||
Log.d(TAG) { "No accepted RSVP can still fire; ending periodic chain." }
|
||||
cancel(applicationContext)
|
||||
}
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
@@ -145,35 +121,6 @@ class CalendarReminderWorker(
|
||||
// more than a day ago; they can't fire again so the entry is pure overhead.
|
||||
private const val PRUNE_AGE_SECONDS = 24L * 60L * 60L
|
||||
|
||||
/** Every ACCEPTED kind-31925 RSVP currently present in LocalCache. */
|
||||
fun acceptedRsvpsInCache(): List<CalendarRSVPEvent> =
|
||||
LocalCache.addressables
|
||||
.filterIntoSet { _, note ->
|
||||
val e = note.event
|
||||
e is CalendarRSVPEvent && e.status() == RSVPStatusTag.STATUS.ACCEPTED
|
||||
}.mapNotNull { it.event as? CalendarRSVPEvent }
|
||||
|
||||
/**
|
||||
* True while at least one accepted RSVP could still produce a reminder:
|
||||
* its target event either starts in the future, or hasn't been fetched
|
||||
* yet (start unknown — the chain must survive until the target
|
||||
* resolves). False means the periodic worker has nothing it could ever
|
||||
* notify about and may cancel its own chain.
|
||||
*/
|
||||
fun couldStillFire(
|
||||
rsvps: Collection<CalendarRSVPEvent>,
|
||||
now: Long,
|
||||
): Boolean =
|
||||
rsvps.any { rsvp ->
|
||||
val targetAddress = rsvp.calendarEventAddress() ?: return@any false
|
||||
val start =
|
||||
LocalCache.addressables
|
||||
.get(targetAddress)
|
||||
?.appointmentView()
|
||||
?.startSeconds
|
||||
start == null || start > now
|
||||
}
|
||||
|
||||
fun schedule(context: Context) {
|
||||
val request =
|
||||
PeriodicWorkRequestBuilder<CalendarReminderWorker>(15, TimeUnit.MINUTES)
|
||||
|
||||
-3
@@ -34,7 +34,6 @@ import android.os.IBinder
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.ServiceCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.nipACWebRtcCalls.CallState
|
||||
import com.vitorpamplona.amethyst.ui.call.CallActivity
|
||||
@@ -62,7 +61,6 @@ class CallForegroundService : Service() {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
Amethyst.instance.callSession.setActive(true)
|
||||
createNotificationChannel()
|
||||
}
|
||||
|
||||
@@ -153,7 +151,6 @@ class CallForegroundService : Service() {
|
||||
// because CallManager.hangup() transitions to Ended and a second
|
||||
// hangup() from Ended state returns immediately.
|
||||
publishHangupBlocking()
|
||||
Amethyst.instance.callSession.setActive(false)
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
|
||||
+1
-19
@@ -26,7 +26,6 @@ import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.nip60Cashu.mintApi.CashuMintOperations
|
||||
import com.vitorpamplona.quartz.nip60Cashu.mintApi.MintHttpClient
|
||||
import com.vitorpamplona.quartz.nip60Cashu.mintApi.MintUrlException
|
||||
import com.vitorpamplona.quartz.nip60Cashu.token.CashuToken
|
||||
import okhttp3.OkHttpClient
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
@@ -46,23 +45,14 @@ import kotlin.coroutines.cancellation.CancellationException
|
||||
* it also picks up NUT-02 per-input fee handling for free.
|
||||
*/
|
||||
class MeltProcessor {
|
||||
/**
|
||||
* @param knownWalletMints the mint URLs of the user's own NIP-60 wallet. A token
|
||||
* pointing at one of those was, by definition, issued by a mint the user
|
||||
* deliberately added, so it is exempt from the private-address block that
|
||||
* [com.vitorpamplona.quartz.nip60Cashu.mintApi.CashuMintUrlValidator] applies
|
||||
* to arbitrary pasted tokens (a self-hosted mint on the LAN is legitimate).
|
||||
*/
|
||||
suspend fun melt(
|
||||
token: CashuToken,
|
||||
lud16: String,
|
||||
okHttpClient: (String) -> OkHttpClient,
|
||||
context: Context,
|
||||
knownWalletMints: Set<String> = emptySet(),
|
||||
): MeltResult {
|
||||
try {
|
||||
val isOwnMint = knownWalletMints.any { it.trim().trimEnd('/').equals(token.mint.trim().trimEnd('/'), ignoreCase = true) }
|
||||
val ops = CashuMintOperations(MintHttpClient(token.mint, userConfigured = isOwnMint, okHttpClient = okHttpClient))
|
||||
val ops = CashuMintOperations(MintHttpClient(token.mint, okHttpClient))
|
||||
val proofs = token.proofs
|
||||
|
||||
// A Lightning address must commit to an amount before we know the
|
||||
@@ -116,14 +106,6 @@ class MeltProcessor {
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
if (e is LightningAddressResolver.LightningAddressError) throw e
|
||||
// The mint URL was refused before any request went out: this is OUR
|
||||
// message, not the mint's, so don't dress it up as "the mint said".
|
||||
if (e is MintUrlException) {
|
||||
throw LightningAddressResolver.LightningAddressError(
|
||||
stringRes(context, R.string.cashu_unsafe_mint_url),
|
||||
stringRes(context, R.string.cashu_unsafe_mint_url_explainer, e.message),
|
||||
)
|
||||
}
|
||||
throw LightningAddressResolver.LightningAddressError(
|
||||
stringRes(context, R.string.cashu_failed_redemption),
|
||||
stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg, e.message),
|
||||
|
||||
-28
@@ -1,28 +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.crashreports
|
||||
|
||||
/**
|
||||
* Developer recipient for every user-initiated diagnostic NIP-17 DM — crash
|
||||
* reports and resource-usage reports both route here. Single definition so a
|
||||
* key rotation can never leave one path DMing the old key.
|
||||
*/
|
||||
const val DEV_REPORT_PUBKEY = "aa9047325603dacd4f8142093567973566de3b1e20a89557b728c3be4c6a844b"
|
||||
+1
-1
@@ -80,7 +80,7 @@ fun DisplayCrashMessages(
|
||||
onClick = {
|
||||
nav.nav {
|
||||
routeToMessage(
|
||||
user = LocalCache.getOrCreateUser(DEV_REPORT_PUBKEY),
|
||||
user = LocalCache.getOrCreateUser("aa9047325603dacd4f8142093567973566de3b1e20a89557b728c3be4c6a844b"),
|
||||
draftMessage = stack,
|
||||
accountViewModel = accountViewModel,
|
||||
expiresDays = 30,
|
||||
|
||||
+16
-12
@@ -34,18 +34,19 @@ class MemoryTrimmingService(
|
||||
var isTrimmingMemoryMutex = AtomicBoolean(false)
|
||||
|
||||
/**
|
||||
* Two-tier pruning keyed to the OS trim levels still delivered since API 34
|
||||
* (the foreground RUNNING_* and deeper MODERATE/COMPLETE levels were deprecated
|
||||
* because apps are no longer notified of them).
|
||||
* Tiered pruning scaled to the OS memory-pressure level.
|
||||
*
|
||||
* Tier 1 — UI hidden (fires on every app switch):
|
||||
* Tier 1 — mild pressure (UI hidden, running-moderate):
|
||||
* Sweep stale WeakRefs, drop expired and superseded-replaceable events.
|
||||
* Safe to run frequently; no UI-visible side effects.
|
||||
*
|
||||
* Tier 2 — background / real reclaim pressure (process on the LRU list):
|
||||
* Tier 1 + drop events from muted/blocked users + old chat messages +
|
||||
* unobserved thread replies / reactions. May cause feeds to re-fetch content
|
||||
* that was scrolled past; triggers recomposition wherever StateFlows cleared.
|
||||
* Tier 2 — low memory (running-low, background):
|
||||
* Tier 1 + old chat messages + unobserved thread replies / reactions.
|
||||
* May cause feeds to re-fetch content that was scrolled past.
|
||||
*
|
||||
* Tier 3 — critical / imminent kill (running-critical, moderate, complete):
|
||||
* Tier 2 + sever all observer links + drop every event from muted/blocked users.
|
||||
* Aggressive; triggers recomposition wherever StateFlows were cleared.
|
||||
*/
|
||||
private fun doTrim(
|
||||
account: Collection<Account>,
|
||||
@@ -59,13 +60,16 @@ class MemoryTrimmingService(
|
||||
cache.pruneExpiredEvents()
|
||||
cache.prunePastVersionsOfReplaceables()
|
||||
|
||||
if (level >= ComponentCallbacks2.TRIM_MEMORY_BACKGROUND) {
|
||||
// Tier 2: real reclaim pressure — drop events from muted/blocked users, old
|
||||
// messages, and unobserved reactions.
|
||||
if (level >= ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW) {
|
||||
// Tier 2: medium pressure — drop events from muted/blocked users
|
||||
account.forEach {
|
||||
cache.pruneHiddenEvents(it)
|
||||
cache.pruneHiddenMessages(it)
|
||||
}
|
||||
}
|
||||
|
||||
if (level >= ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL) {
|
||||
// Tier 3: critical pressure — drop old messages and unobserved reactions
|
||||
val accounts = otherAccounts.mapNotNull { decodePublicKeyAsHexOrNull(it.npub) }.toSet()
|
||||
cache.pruneOldMessages()
|
||||
cache.pruneRepliesAndReactions(accounts)
|
||||
@@ -75,7 +79,7 @@ class MemoryTrimmingService(
|
||||
suspend fun run(
|
||||
account: Collection<Account>,
|
||||
otherAccounts: List<AccountInfo>,
|
||||
level: Int = ComponentCallbacks2.TRIM_MEMORY_BACKGROUND,
|
||||
level: Int = ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL,
|
||||
) {
|
||||
if (isTrimmingMemoryMutex.compareAndSet(false, true)) {
|
||||
Log.d("ServiceManager", "Trimming Memory (level=$level)")
|
||||
|
||||
-302
@@ -1,302 +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.foreground
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.MainActivity
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Shared scaffolding for a foreground service that shields a background job from the
|
||||
* cached-apps freezer while it runs, rendering a live [NotificationCompat.ProgressStyle]
|
||||
* progress card driven by a [StateFlow].
|
||||
*
|
||||
* This is deliberately NOT a single "do everything" service: Android 14+ binds
|
||||
* `foregroundServiceType` to the service's actual behavior (and each type carries its
|
||||
* own permission, budget and start rules), so each workload keeps its own concrete
|
||||
* subclass with the correct [fgsType]. What's shared here is only the boilerplate —
|
||||
* channel setup, start/stop-when-idle, the notification skeleton, tap + cancel intents,
|
||||
* and `onTimeout` — parameterized by a handful of hooks.
|
||||
*
|
||||
* Subclasses supply the [fgsType], the [state] flow to watch, [isActive] to decide when
|
||||
* to stop, [render] to build the card, and [cancelAll] for the cancel action. See
|
||||
* `PowMiningForegroundService` (shortService) and `BlossomSyncForegroundService`
|
||||
* (dataSync) for the two consumers.
|
||||
*/
|
||||
abstract class FlowProgressForegroundService<T> : Service() {
|
||||
protected val scope = CoroutineScope(Dispatchers.Main.immediate + SupervisorJob())
|
||||
private var watchJob: Job? = null
|
||||
|
||||
/** The Android 14+ `ServiceInfo.FOREGROUND_SERVICE_TYPE_*` this service runs as. */
|
||||
protected abstract val fgsType: Int
|
||||
protected abstract val channelId: String
|
||||
protected abstract val channelNameRes: Int
|
||||
protected abstract val channelDescRes: Int
|
||||
protected abstract val notificationId: Int
|
||||
|
||||
/** The intent action that routes back here to cancel everything. */
|
||||
protected abstract val cancelAction: String
|
||||
protected abstract val cancelLabelRes: Int
|
||||
protected open val smallIcon: Int = R.drawable.amethyst
|
||||
|
||||
/** When non-null, re-render the card on this cadence (for clock-driven text like "time left"). */
|
||||
protected open val refreshMs: Long? = null
|
||||
|
||||
protected abstract fun state(): StateFlow<T>
|
||||
|
||||
/** Keep the service (and notification) alive while this is true; stop once it goes false. */
|
||||
protected abstract fun isActive(value: T): Boolean
|
||||
|
||||
protected abstract fun render(value: T): Content
|
||||
|
||||
/** Invoked by the cancel action. */
|
||||
protected abstract fun cancelAll()
|
||||
|
||||
/** Called for every emission before [render]; use to update derived subclass state. */
|
||||
protected open fun onEmission(value: T) {
|
||||
// No-op by default: only subclasses that keep derived state need this hook.
|
||||
}
|
||||
|
||||
/** Only consulted for the [refreshMs] clock loop; skip re-renders when nothing is moving. */
|
||||
protected open fun needsClockRefresh(value: T): Boolean = true
|
||||
|
||||
/** One-time setup once the watch loop starts (e.g. a benchmark). */
|
||||
protected open fun onStarted() {
|
||||
// No-op by default: only subclasses with one-time setup (e.g. a benchmark) override this.
|
||||
}
|
||||
|
||||
/** How to draw the progress bar of the card. */
|
||||
sealed interface Bar {
|
||||
data object Indeterminate : Bar
|
||||
|
||||
/** A single bar filled to [fraction] in `0f..1f`. */
|
||||
data class Determinate(
|
||||
val fraction: Double,
|
||||
) : Bar
|
||||
|
||||
/** [total] equal segments, [done] of them filled — good for "N of M". */
|
||||
data class Segmented(
|
||||
val total: Int,
|
||||
val done: Int,
|
||||
) : Bar
|
||||
}
|
||||
|
||||
data class Content(
|
||||
val title: String,
|
||||
val text: String?,
|
||||
val bar: Bar,
|
||||
)
|
||||
|
||||
private val tapIntent: PendingIntent by lazy {
|
||||
PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
Intent(this, MainActivity::class.java).apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
},
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
}
|
||||
|
||||
private val cancelIntent: PendingIntent by lazy {
|
||||
PendingIntent.getService(
|
||||
this,
|
||||
1,
|
||||
Intent(this, this.javaClass).setAction(cancelAction),
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onStartCommand(
|
||||
intent: Intent?,
|
||||
flags: Int,
|
||||
startId: Int,
|
||||
): Int {
|
||||
// Android's contract: every onStartCommand after startForegroundService must call
|
||||
// startForeground promptly, even on the stop path.
|
||||
runCatching { startForegroundCompat(state().value) }
|
||||
.onFailure {
|
||||
Log.w(logTag(), "startForeground failed; work continues without the service", it)
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
if (intent?.action == cancelAction) {
|
||||
cancelAll()
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
watch()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
/** Foreground-service budget exhausted (shortService ~3 min; dataSync on newer OS). Exit cleanly. */
|
||||
override fun onTimeout(startId: Int) {
|
||||
Log.d(logTag()) { "foreground-service budget exhausted; stopping" }
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
scope.cancel()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun watch() {
|
||||
if (watchJob != null) return
|
||||
onStarted()
|
||||
watchJob =
|
||||
scope.launch {
|
||||
state().collect { value ->
|
||||
onEmission(value)
|
||||
if (!isActive(value)) {
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
} else {
|
||||
updateNotification(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
refreshMs?.let { ms ->
|
||||
scope.launch {
|
||||
while (true) {
|
||||
val v = state().value
|
||||
if (isActive(v) && needsClockRefresh(v)) updateNotification(v)
|
||||
delay(ms)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startForegroundCompat(value: T) {
|
||||
ensureChannel()
|
||||
val notification = buildNotification(value)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
startForeground(notificationId, notification, fgsType)
|
||||
} else {
|
||||
startForeground(notificationId, notification)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateNotification(value: T) {
|
||||
val manager = NotificationManagerCompat.from(this)
|
||||
if (!manager.areNotificationsEnabled()) return
|
||||
try {
|
||||
manager.notify(notificationId, buildNotification(value))
|
||||
} catch (_: SecurityException) {
|
||||
// POST_NOTIFICATIONS revoked mid-flight; the FGS keeps running.
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildNotification(value: T): Notification {
|
||||
val content = render(value)
|
||||
val style =
|
||||
when (val bar = content.bar) {
|
||||
is Bar.Indeterminate -> NotificationCompat.ProgressStyle().setProgressIndeterminate(true)
|
||||
is Bar.Determinate ->
|
||||
if (bar.fraction.isFinite() && bar.fraction in 0.0..1.0) {
|
||||
NotificationCompat
|
||||
.ProgressStyle()
|
||||
.setProgressSegments(listOf(NotificationCompat.ProgressStyle.Segment(100)))
|
||||
.setProgress((bar.fraction * 100).toInt())
|
||||
} else {
|
||||
NotificationCompat.ProgressStyle().setProgressIndeterminate(true)
|
||||
}
|
||||
is Bar.Segmented ->
|
||||
NotificationCompat
|
||||
.ProgressStyle()
|
||||
.setProgressSegments(List(bar.total.coerceAtLeast(1)) { NotificationCompat.ProgressStyle.Segment(1) })
|
||||
.setProgress(bar.done)
|
||||
}
|
||||
|
||||
return NotificationCompat
|
||||
.Builder(this, channelId)
|
||||
.setSmallIcon(smallIcon)
|
||||
.setContentTitle(content.title)
|
||||
.setContentText(content.text)
|
||||
.setStyle(style)
|
||||
.setContentIntent(tapIntent)
|
||||
.addAction(0, stringRes(this, cancelLabelRes), cancelIntent)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun ensureChannel() {
|
||||
val manager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
|
||||
if (manager.getNotificationChannel(channelId) != null) return
|
||||
manager.createNotificationChannel(
|
||||
NotificationChannel(channelId, stringRes(this, channelNameRes), NotificationManager.IMPORTANCE_LOW).apply {
|
||||
description = stringRes(this@FlowProgressForegroundService, channelDescRes)
|
||||
setShowBadge(false)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
protected open fun logTag(): String = this.javaClass.simpleName
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Best-effort start of a [FlowProgressForegroundService] subclass. A start from the
|
||||
* background (e.g. a restore) may be denied — the work then proceeds unprotected and
|
||||
* the service starts on the next foreground trigger.
|
||||
*/
|
||||
fun start(
|
||||
context: Context,
|
||||
clazz: Class<out FlowProgressForegroundService<*>>,
|
||||
tag: String,
|
||||
) {
|
||||
try {
|
||||
context.startForegroundService(Intent(context, clazz))
|
||||
} catch (e: Exception) {
|
||||
Log.w(tag, "Could not start foreground service (backgrounded?); work continues unprotected", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.geohash
|
||||
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.commons.service.georelay.GeoRelayCsvLoader
|
||||
import com.vitorpamplona.amethyst.commons.service.georelay.GeoRelayDirectory
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
|
||||
/**
|
||||
* Process-wide geohash → relay directory, shared by everything that routes
|
||||
* geohash chat traffic (the joined-cell subscription, the chat screen). The live
|
||||
* CSV is fetched once via [ensureLoaded]; until then [closestRelays] falls back
|
||||
* to the small built-in list so routing works offline / on first run.
|
||||
*/
|
||||
object GeohashRelays {
|
||||
// The process-wide directory that GeohashChatChannel.relays() also reads, so a refresh
|
||||
// here immediately improves the relay set every geohash subscription resolves.
|
||||
private val directory = GeoRelayDirectory.shared
|
||||
|
||||
@Volatile private var refreshed = false
|
||||
|
||||
/** Fetches the live directory once. Safe to call repeatedly; subsequent calls are no-ops. */
|
||||
suspend fun ensureLoaded(): Boolean {
|
||||
if (refreshed) return false
|
||||
runCatching {
|
||||
GeoRelayCsvLoader { Amethyst.instance.okHttpClients.getHttpClient(false) }.refresh(directory)
|
||||
}
|
||||
val loaded = directory.size > GeoRelayDirectory.FALLBACK.size
|
||||
refreshed = loaded
|
||||
return loaded
|
||||
}
|
||||
|
||||
/** The relays nearest [geohash]'s center. Synchronous — uses whatever is loaded (fallback if not yet refreshed). */
|
||||
fun closestRelays(geohash: String): List<NormalizedRelayUrl> = directory.closestRelays(geohash)
|
||||
}
|
||||
-95
@@ -1,95 +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.location
|
||||
|
||||
import android.content.Context
|
||||
import android.location.Address
|
||||
import android.location.Geocoder
|
||||
import android.os.Build
|
||||
import androidx.annotation.RequiresApi
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import java.io.IOException
|
||||
|
||||
/**
|
||||
* Forward geocoding: turn a free-text place query (a city, address, or landmark)
|
||||
* into a list of candidate [Address]es so the user can jump the map there.
|
||||
*
|
||||
* The mirror image of [ReverseGeolocation]: on TIRAMISU+ it uses the async
|
||||
* listener overload of [Geocoder.getFromLocationName] (the blocking one is
|
||||
* deprecated there), and falls back to the synchronous call on older devices.
|
||||
* Both branches funnel through [onReady]; a null result means "no backend, an
|
||||
* error, or nothing matched" and the caller should degrade gracefully.
|
||||
*/
|
||||
@Suppress("DEPRECATION")
|
||||
class ForwardGeolocation {
|
||||
companion object {
|
||||
const val MAX_RESULTS = 5
|
||||
|
||||
fun execute(
|
||||
query: String,
|
||||
context: Context,
|
||||
onReady: (List<Address>?) -> Unit,
|
||||
) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
executeAsync(query, context, onReady)
|
||||
} else {
|
||||
onReady(executeSync(query, context))
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
|
||||
fun executeAsync(
|
||||
query: String,
|
||||
context: Context,
|
||||
onReady: (List<Address>?) -> Unit,
|
||||
) {
|
||||
val listener =
|
||||
object : Geocoder.GeocodeListener {
|
||||
override fun onGeocode(addresses: List<Address>) {
|
||||
Log.d("ForwardGeoLocation") { "Found ${addresses.size} addresses for $query" }
|
||||
onReady(addresses)
|
||||
}
|
||||
|
||||
override fun onError(errorMessage: String?) {
|
||||
super.onError(errorMessage)
|
||||
Log.w("ForwardGeoLocation") { "Failure $errorMessage" }
|
||||
onReady(null)
|
||||
}
|
||||
}
|
||||
|
||||
Log.d("ForwardGeoLocation") { "Execute Async $query" }
|
||||
Geocoder(context).getFromLocationName(query, MAX_RESULTS, listener)
|
||||
}
|
||||
|
||||
fun executeSync(
|
||||
query: String,
|
||||
context: Context,
|
||||
): List<Address>? {
|
||||
Log.d("ForwardGeoLocation") { "Execute Sync $query" }
|
||||
return try {
|
||||
Geocoder(context).getFromLocationName(query, MAX_RESULTS)
|
||||
} catch (e: IOException) {
|
||||
Log.w("ForwardGeolocation", "IO Error", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user