mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-07-22 07:48:27 +00:00
Compare commits
323 Commits
claude/amy
...
72b8df7f6d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72b8df7f6d | ||
|
|
d571bee394 | ||
|
|
52ba19d324 | ||
|
|
de114d1621 | ||
|
|
8e7b1b63be | ||
|
|
e6a8f80826 | ||
|
|
c7670a52d4 | ||
|
|
35e61e1d98 | ||
|
|
6730100853 | ||
|
|
69671d0009 | ||
|
|
078758888a | ||
|
|
387bfe99ee | ||
|
|
4df7354ffc | ||
|
|
a11de43400 | ||
|
|
0a228a935b | ||
|
|
9ddf571b11 | ||
|
|
1fee674350 | ||
|
|
50025660a3 | ||
|
|
f67d242f7a | ||
|
|
6033957b1c | ||
|
|
37923d9101 | ||
|
|
a960ceb6a8 | ||
|
|
3fdb976aa9 | ||
|
|
a6b40cfc0f | ||
|
|
b616f69c1d | ||
|
|
57ffb3386d | ||
|
|
a3e239d33f | ||
|
|
b6bf3b8a70 | ||
|
|
41240eeab9 | ||
|
|
5988db010d | ||
|
|
73a58e042d | ||
|
|
e3dae26483 | ||
|
|
86c8d0b12c | ||
|
|
db0ff71432 | ||
|
|
72e27a3530 | ||
|
|
556ef9f880 | ||
|
|
52c863a9c6 | ||
|
|
5e5ecdab8b | ||
|
|
062ab0f012 | ||
|
|
5f5356c0fe | ||
|
|
4e0ac212ee | ||
|
|
63d01c9287 | ||
|
|
fa32ea50a3 | ||
|
|
1a77c95531 | ||
|
|
d3e208322c | ||
|
|
b06184ac49 | ||
|
|
45d33c016e | ||
|
|
9922eef9f7 | ||
|
|
ad66e6c224 | ||
|
|
da451145af | ||
|
|
a5ac06ad6c | ||
|
|
77908b2be0 | ||
|
|
05426d8c66 | ||
|
|
c8f43a1bd4 | ||
|
|
cc94ef9103 | ||
|
|
ad75344c3e | ||
|
|
ef9a891974 | ||
|
|
b2cfb5e7ee | ||
|
|
0601cfafbc | ||
|
|
0214382d0f | ||
|
|
a0ab3ec66d | ||
|
|
15ce992b61 | ||
|
|
8913af7a79 | ||
|
|
ea6762b137 | ||
|
|
0ae6bc6698 | ||
|
|
ba8de9ba21 | ||
|
|
c15774e4e1 | ||
|
|
4e7242a295 | ||
|
|
97b861dd5e | ||
|
|
4ee0416a90 | ||
|
|
a040aa522c | ||
|
|
4255ba3345 | ||
|
|
08bd5d9b4a | ||
|
|
b0668baeec | ||
|
|
62440748a7 | ||
|
|
2d29f7ccda | ||
|
|
5f712b3a8a | ||
|
|
ab2f4d2da8 | ||
|
|
a87049e451 | ||
|
|
894dc7d99b | ||
|
|
717461f4d0 | ||
|
|
f2e42105f7 | ||
|
|
c6a6068486 | ||
|
|
73d59a29bf | ||
|
|
7792c42f1d | ||
|
|
6045e28830 | ||
|
|
c84acddc0c | ||
|
|
5c23f8490d | ||
|
|
f8d8a2b135 | ||
|
|
fac1bf5b5d | ||
|
|
30f4638954 | ||
|
|
0f53eb09a2 | ||
|
|
c9c91a8c98 | ||
|
|
996b800da5 | ||
|
|
2ccd837f30 | ||
|
|
c8be65a02e | ||
|
|
153191e722 | ||
|
|
5fd8be64fd | ||
|
|
7f135a12ab | ||
|
|
095b156953 | ||
|
|
5bce87d76e | ||
|
|
389b460f7a | ||
|
|
dbf15a2146 | ||
|
|
ee3b8786d4 | ||
|
|
49b93db532 | ||
|
|
710c68d6aa | ||
|
|
824f9898de | ||
|
|
fb730d7ea8 | ||
|
|
fe761ab7f2 | ||
|
|
768e19ff9b | ||
|
|
18c0c4291f | ||
|
|
618664c45a | ||
|
|
7b5e5f572b | ||
|
|
f875d2c397 | ||
|
|
b94303a77a | ||
|
|
a87d4688a8 | ||
|
|
fb9ccbaad0 | ||
|
|
b801a6b138 | ||
|
|
7c37a8691c | ||
|
|
1445aea849 | ||
|
|
6ba4ab0a4b | ||
|
|
035c50421c | ||
|
|
c6b2bad0a9 | ||
|
|
4efb2cca98 | ||
|
|
787fd99d1e | ||
|
|
b20efb3147 | ||
|
|
78e641b3b2 | ||
|
|
962d1706dc | ||
|
|
620fc465df | ||
|
|
fea9bcb8ea | ||
|
|
43704772a7 | ||
|
|
8d3a47b8f8 | ||
|
|
82f041b1f5 | ||
|
|
7b32ca1da3 | ||
|
|
6e664b2db6 | ||
|
|
8658dcd512 | ||
|
|
ad2c232868 | ||
|
|
dd6e7e2852 | ||
|
|
850c3e58e4 | ||
|
|
e66550091d | ||
|
|
b1b6190d2f | ||
|
|
bff7592383 | ||
|
|
11590b7081 | ||
|
|
a4713c89c2 | ||
|
|
d8584a7935 | ||
|
|
c28c8af13f | ||
|
|
7ea6920679 | ||
|
|
80be7b909b | ||
|
|
55884f81eb | ||
|
|
7b5d9a0fe4 | ||
|
|
11901df2c6 | ||
|
|
471883a9f2 | ||
|
|
6bcae39b83 | ||
|
|
7e3c01ab08 | ||
|
|
b852a163c7 | ||
|
|
3214095b99 | ||
|
|
c5f10c7518 | ||
|
|
0d534a3149 | ||
|
|
db5170a82e | ||
|
|
4653951b54 | ||
|
|
399bef6bf7 | ||
|
|
8ba0d0c317 | ||
|
|
854c9907e2 | ||
|
|
4cf86ca030 | ||
|
|
c8362fbc18 | ||
|
|
20e86b8998 | ||
|
|
ee5dc25414 | ||
|
|
9fb3603ed6 | ||
|
|
2466132fb7 | ||
|
|
54b2b42d87 | ||
|
|
a25c5f9518 | ||
|
|
84640a5890 | ||
|
|
bc3c3460c0 | ||
|
|
ab6682d521 | ||
|
|
bb75ebf87c | ||
|
|
314c5e41f2 | ||
|
|
2f0c178eab | ||
|
|
5d54a0ff90 | ||
|
|
050ecaaf9f | ||
|
|
af9d5a7278 | ||
|
|
4845e95c9f | ||
|
|
ecbe2c6704 | ||
|
|
da633342bc | ||
|
|
6920dc6e82 | ||
|
|
22396cf583 | ||
|
|
e87309e038 | ||
|
|
4e53f26b38 | ||
|
|
7dcf2afa0f | ||
|
|
9d8e07fbee | ||
|
|
9bc436b0c2 | ||
|
|
69a5b3be00 | ||
|
|
2cfac578fe | ||
|
|
67e02618e2 | ||
|
|
4f661a501f | ||
|
|
5ad3e143e4 | ||
|
|
6c01ad2b10 | ||
|
|
d0f2f08e19 | ||
|
|
ed4b851518 | ||
|
|
3e9da48c8a | ||
|
|
91765c56aa | ||
|
|
bb03cd2a3c | ||
|
|
26b24c9125 | ||
|
|
cd5060e5dc | ||
|
|
bdbf12f8be | ||
|
|
699c2dc7e5 | ||
|
|
d2e18cb18d | ||
|
|
a99fc59de8 | ||
|
|
0ac31a7919 | ||
|
|
7f99cbc3d6 | ||
|
|
451e27e38d | ||
|
|
4d199ab28a | ||
|
|
33f4c648e9 | ||
|
|
0feb0072a1 | ||
|
|
ee217a42dc | ||
|
|
c4957ee8c0 | ||
|
|
c18256a175 | ||
|
|
14c9f99bac | ||
|
|
fd4d9c10f7 | ||
|
|
60a226891c | ||
|
|
62c2d7755d | ||
|
|
fb471a2e6b | ||
|
|
05f7da6814 | ||
|
|
71d15bf916 | ||
|
|
3b96dc2f82 | ||
|
|
a9ff6f5fbb | ||
|
|
b2c78d6b76 | ||
|
|
a839ef226a | ||
|
|
870aeb4b99 | ||
|
|
331dca79cd | ||
|
|
90acafe389 | ||
|
|
23597ab883 | ||
|
|
ed9e02ac7f | ||
|
|
3316dfcd7a | ||
|
|
880a980f82 | ||
|
|
cbcb8d42ba | ||
|
|
2fee4e800f | ||
|
|
1d5b08a128 | ||
|
|
602c6a90b2 | ||
|
|
891e6ced91 | ||
|
|
60d492e2bc | ||
|
|
db586e30d3 | ||
|
|
40acd69781 | ||
|
|
dc157e32d2 | ||
|
|
1daa871d95 | ||
|
|
3254b90b19 | ||
|
|
76dd0a0f16 | ||
|
|
ede4fea1cd | ||
|
|
56c99bb7b6 | ||
|
|
e3e85994f2 | ||
|
|
7649c7f94d | ||
|
|
5f65405b08 | ||
|
|
c8ff1bb18f | ||
|
|
9717af28ee | ||
|
|
f759ef6ca7 | ||
|
|
2ac9cc81f7 | ||
|
|
2f3c09c41d | ||
|
|
f380d92498 | ||
|
|
d1f267a2a3 | ||
|
|
f28b6381e3 | ||
|
|
bf3e0df24d | ||
|
|
17c014ac1b | ||
|
|
fd430ae429 | ||
|
|
10d88afea3 | ||
|
|
ae6e1e9c2d | ||
|
|
cf4ea766aa | ||
|
|
e2128d2b6e | ||
|
|
6dddb3b9c9 | ||
|
|
ef7c10cef9 | ||
|
|
7b5ddd3637 | ||
|
|
98c53ed907 | ||
|
|
13bc33ad70 | ||
|
|
0f1ced8682 | ||
|
|
1ebc4f0ada | ||
|
|
5d9d75e2c4 | ||
|
|
3a85a4f982 | ||
|
|
5f723bf607 | ||
|
|
62c82aef68 | ||
|
|
fd5556609f | ||
|
|
1d8b7d7c8f | ||
|
|
a039f6adbb | ||
|
|
98c18a9fcf | ||
|
|
bb56bf6a73 | ||
|
|
e353467b6f | ||
|
|
0734fdb8de | ||
|
|
052459567c | ||
|
|
565342d7bf | ||
|
|
2a1634bb07 | ||
|
|
6151a2df25 | ||
|
|
99f0c61f72 | ||
|
|
4fa41f1a67 | ||
|
|
00ec826e8b | ||
|
|
5fa8a0a2aa | ||
|
|
9a0af15f36 | ||
|
|
cdf43db0eb | ||
|
|
e69b59a255 | ||
|
|
2976afc83b | ||
|
|
31cd6c8bb7 | ||
|
|
5c73a7c1f7 | ||
|
|
ac6697330e | ||
|
|
684ce26f63 | ||
|
|
4998d7d460 | ||
|
|
f9d9691017 | ||
|
|
4676d176ec | ||
|
|
d8d185b928 | ||
|
|
3ed8779fff | ||
|
|
cceac4478a | ||
|
|
2332384897 | ||
|
|
922a5841d0 | ||
|
|
cd3e1353e7 | ||
|
|
c20cc2b514 | ||
|
|
d06b1c7f2c | ||
|
|
fe4e881df6 | ||
|
|
994bdae2d1 | ||
|
|
9667168a6a | ||
|
|
dea711596e | ||
|
|
d46ac987e8 | ||
|
|
9f32f72a41 | ||
|
|
c3a1762637 | ||
|
|
81aea57ddb | ||
|
|
dc0840264d | ||
|
|
b1227fa7cb | ||
|
|
a1b7cad85c | ||
|
|
6ac8e475df |
@@ -302,6 +302,21 @@ Do this before considering the task complete.
|
||||
`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
|
||||
|
||||
@@ -7,7 +7,9 @@ description: Use when comparing Android strings.xml locale files to find untrans
|
||||
|
||||
## Overview
|
||||
|
||||
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.
|
||||
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).
|
||||
|
||||
## When to Use
|
||||
|
||||
@@ -15,6 +17,56 @@ Extract string resource keys from the default `values/strings.xml` that are abse
|
||||
- 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.
|
||||
@@ -53,9 +105,25 @@ 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
|
||||
@@ -153,8 +221,10 @@ 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.
|
||||
for f in amethyst/src/main/res/values/strings.xml amethyst/src/main/res/values-*/strings.xml; do
|
||||
# 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
|
||||
awk -v file="$f" '
|
||||
/<plurals/ { in_plurals = 1; name = $0; sub(/.*name="/, "", name); sub(/".*/, "", name) }
|
||||
in_plurals && /quantity="one"/ {
|
||||
@@ -173,7 +243,9 @@ 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; do
|
||||
for f in amethyst/src/main/res/values/strings.xml amethyst/src/main/res/values-*/strings.xml \
|
||||
commons/src/commonMain/composeResources/values/strings.xml \
|
||||
commons/src/commonMain/composeResources/values-*/strings.xml; do
|
||||
# Skip Arabic and Welsh — they natively use the zero category.
|
||||
case "$f" in
|
||||
*values-ar*|*values-cy*) continue ;;
|
||||
@@ -260,14 +332,48 @@ 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`)
|
||||
|
||||
10
.github/workflows/crowdin.yml
vendored
10
.github/workflows/crowdin.yml
vendored
@@ -41,6 +41,15 @@ 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
|
||||
@@ -61,6 +70,7 @@ 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'
|
||||
|
||||
@@ -17,7 +17,7 @@ Join the social network you control.
|
||||
[](https://central.sonatype.com/artifact/com.vitorpamplona.quartz/quartz)
|
||||
[](https://jitpack.io/#vitorpamplona/amethyst)
|
||||
[](https://github.com/vitorpamplona/amethyst/actions/workflows/build.yml)
|
||||
[](/LICENSE)
|
||||
[](/LICENSE)
|
||||
[](https://deepwiki.com/vitorpamplona/amethyst)
|
||||
|
||||
## Download and Install
|
||||
|
||||
86
SKILL.md
86
SKILL.md
@@ -24,7 +24,9 @@ Build customized Amethyst Nostr clients for Android. Fork, rebrand, customize, a
|
||||
|
||||
2. **Android SDK**
|
||||
- Command-line tools from https://developer.android.com/studio#command-line-tools-only
|
||||
- Required components: build-tools, platform-tools, platforms;android-35
|
||||
- Required components: build-tools, platform-tools, platforms;android-37
|
||||
- The exact SDK level is `android-compileSdk` in `gradle/libs.versions.toml` —
|
||||
check there if this number has drifted.
|
||||
|
||||
3. **Git** for cloning the repository
|
||||
|
||||
@@ -66,48 +68,54 @@ keyPassword=your-password
|
||||
|
||||
### 3. Configure Signing
|
||||
|
||||
Add to `amethyst/build.gradle` inside the `android {}` block:
|
||||
Add to `amethyst/build.gradle.kts` inside the `android {}` block:
|
||||
|
||||
```gradle
|
||||
def keystorePropertiesFile = rootProject.file("keystore.properties")
|
||||
def keystoreProperties = new Properties()
|
||||
```kotlin
|
||||
val keystorePropertiesFile = rootProject.file("keystore.properties")
|
||||
val keystoreProperties = Properties()
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
|
||||
keystorePropertiesFile.inputStream().use { keystoreProperties.load(it) }
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
release {
|
||||
create("release") {
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
storeFile rootProject.file(keystoreProperties['storeFile'])
|
||||
storePassword keystoreProperties['storePassword']
|
||||
keyAlias keystoreProperties['keyAlias']
|
||||
keyPassword keystoreProperties['keyPassword']
|
||||
storeFile = rootProject.file(keystoreProperties["storeFile"] as String)
|
||||
storePassword = keystoreProperties["storePassword"] as String
|
||||
keyAlias = keystoreProperties["keyAlias"] as String
|
||||
keyPassword = keystoreProperties["keyPassword"] as String
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This needs `import java.util.Properties` at the top of the file.
|
||||
|
||||
Update the release buildType to use the signing config:
|
||||
```gradle
|
||||
```kotlin
|
||||
buildTypes {
|
||||
release {
|
||||
signingConfig signingConfigs.release
|
||||
getByName("release") {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
// ... existing config
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Verify with `./gradlew :amethyst:signingReport` — the release variants should
|
||||
report your keystore rather than `~/.android/debug.keystore`.
|
||||
|
||||
### 4. Disable Google Services (Required for F-Droid)
|
||||
|
||||
**⚠️ CRITICAL:** The Google Services plugin fails when you change the package name. For F-Droid builds, disable it.
|
||||
|
||||
Edit `amethyst/build.gradle`, comment out the plugin:
|
||||
```gradle
|
||||
Edit `amethyst/build.gradle.kts`, comment out the plugin:
|
||||
```kotlin
|
||||
plugins {
|
||||
alias(libs.plugins.androidApplication)
|
||||
alias(libs.plugins.jetbrainsKotlinAndroid)
|
||||
// alias(libs.plugins.googleServices) // DISABLED for F-Droid
|
||||
alias(libs.plugins.jetbrainsComposeCompiler)
|
||||
alias(libs.plugins.serialization)
|
||||
alias(libs.plugins.googleKsp)
|
||||
}
|
||||
```
|
||||
|
||||
@@ -141,8 +149,8 @@ Edit `amethyst/src/main/res/values/strings.xml`:
|
||||
|
||||
### Change Package ID
|
||||
|
||||
Edit `amethyst/build.gradle`:
|
||||
```gradle
|
||||
Edit `amethyst/build.gradle.kts`:
|
||||
```kotlin
|
||||
android {
|
||||
defaultConfig {
|
||||
applicationId = "com.yourcompany.yourapp"
|
||||
@@ -152,8 +160,8 @@ android {
|
||||
|
||||
### Change Project Name
|
||||
|
||||
Edit `settings.gradle`:
|
||||
```gradle
|
||||
Edit `settings.gradle.kts`:
|
||||
```kotlin
|
||||
rootProject.name = "YourAppName"
|
||||
```
|
||||
|
||||
@@ -167,36 +175,28 @@ Replace icon files in:
|
||||
|
||||
Make your app identify itself on posts with `["client", "YourAppName"]`.
|
||||
|
||||
**1. Create tag builder extension:**
|
||||
You do **not** need to add the tag per event type. The client tag is applied
|
||||
centrally by `NostrSignerWithClientTag`, a signer decorator that appends the tag
|
||||
to everything it signs (and respects the user's "add client tag" privacy
|
||||
setting). Changing the name is a one-constant edit:
|
||||
|
||||
Create `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/clientTag/TagArrayBuilderExt.kt`:
|
||||
Edit `amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt`:
|
||||
```kotlin
|
||||
package com.vitorpamplona.quartz.nip01Core.tags.clientTag
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
|
||||
fun <T : Event> TagArrayBuilder<T>.client(clientName: String) =
|
||||
addUnique(arrayOf(ClientTag.TAG_NAME, clientName))
|
||||
const val CLIENT_TAG_NAME = "YourAppName"
|
||||
```
|
||||
|
||||
**2. Add to TextNoteEvent:**
|
||||
That constant is passed to `NostrSignerWithClientTag` when the account's signer
|
||||
is built, so every signed event carries your name.
|
||||
|
||||
Edit `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/TextNoteEvent.kt`:
|
||||
|
||||
Add import:
|
||||
```kotlin
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.clientTag.client
|
||||
```
|
||||
|
||||
In both `build()` functions, add after `alt(...)`:
|
||||
```kotlin
|
||||
client("YourAppName")
|
||||
```
|
||||
The tag itself lives in
|
||||
`quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/`
|
||||
(`ClientTag`, `TagArrayBuilderExt`, `NostrSignerWithClientTag`) — you only need to
|
||||
touch it if you want the optional NIP-89 handler address / relay hint variants.
|
||||
|
||||
### Modify Default Relays
|
||||
|
||||
Edit relay configuration in `quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/` or the UI settings files.
|
||||
Edit `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/defaults/Constants.kt`
|
||||
(see also `AmethystDefaults.kt` and `DefaultDmIndexerRelays.kt` in the same folder).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
||||
@@ -391,6 +391,10 @@ 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)
|
||||
|
||||
@@ -563,6 +567,7 @@ 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)
|
||||
|
||||
184
amethyst/plans/2026-07-16-nip46-signer-device-checklist.md
Normal file
184
amethyst/plans/2026-07-16-nip46-signer-device-checklist.md
Normal file
@@ -0,0 +1,184 @@
|
||||
# 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.
|
||||
201
amethyst/plans/2026-07-18-nip29-group-chat-subscriptions.md
Normal file
201
amethyst/plans/2026-07-18-nip29-group-chat-subscriptions.md
Normal file
@@ -0,0 +1,201 @@
|
||||
# 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.
|
||||
177
amethyst/plans/2026-07-18-nip29-group-chat-test-plan.md
Normal file
177
amethyst/plans/2026-07-18-nip29-group-chat-test-plan.md
Normal file
@@ -0,0 +1,177 @@
|
||||
# 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.
|
||||
190
amethyst/plans/2026-07-20-v1.13.0-release-qa.md
Normal file
190
amethyst/plans/2026-07-20-v1.13.0-release-qa.md
Normal file
@@ -0,0 +1,190 @@
|
||||
# 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.
|
||||
@@ -54,6 +54,7 @@
|
||||
<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 -->
|
||||
@@ -151,6 +152,14 @@
|
||||
<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" />
|
||||
@@ -365,6 +374,15 @@
|
||||
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"
|
||||
@@ -463,14 +481,14 @@
|
||||
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
|
||||
<!-- First-connect "Connect to Nostr" dialog. -->
|
||||
<activity
|
||||
android:name=".napplet.NappletConnectActivity"
|
||||
android:name=".connectedApps.consent.SignerConnectActivity"
|
||||
android:exported="false"
|
||||
android:excludeFromRecents="true"
|
||||
android:launchMode="singleTop"
|
||||
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
|
||||
<!-- Per-operation signer consent dialog. -->
|
||||
<activity
|
||||
android:name=".napplet.NappletSignerConsentActivity"
|
||||
android:name=".connectedApps.consent.SignerConsentActivity"
|
||||
android:exported="false"
|
||||
android:excludeFromRecents="true"
|
||||
android:launchMode="singleTop"
|
||||
|
||||
@@ -32,6 +32,9 @@ 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
|
||||
|
||||
/**
|
||||
@@ -95,6 +98,10 @@ 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)
|
||||
|
||||
@@ -27,6 +27,7 @@ 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
|
||||
@@ -35,6 +36,8 @@ import com.vitorpamplona.amethyst.commons.service.lnurl.OkHttpLnurlEndpointResol
|
||||
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
|
||||
@@ -51,7 +54,6 @@ 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.napplet.DataStoreNostrSignerPermissionStore
|
||||
import com.vitorpamplona.amethyst.service.calendar.CalendarReminderPrefs
|
||||
import com.vitorpamplona.amethyst.service.calendar.CalendarReminderWorker
|
||||
import com.vitorpamplona.amethyst.service.cast.CastRegistry
|
||||
@@ -83,6 +85,7 @@ 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.notifyCommand.model.NotifyCoordinator
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.RelaySubscriptionsCoordinator
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState
|
||||
@@ -106,6 +109,8 @@ 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.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
|
||||
@@ -690,10 +695,37 @@ class AppModules(
|
||||
// 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 ?: "" }
|
||||
|
||||
// Singleton stores for napplet permissions — DataStore v1 enforces one instance per file.
|
||||
val nappletPermissionStore by lazy { DataStoreNappletPermissionStore(appContext) }
|
||||
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 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)
|
||||
|
||||
@@ -749,6 +781,10 @@ 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(
|
||||
@@ -791,6 +827,14 @@ class AppModules(
|
||||
}
|
||||
}
|
||||
|
||||
/** 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)
|
||||
}
|
||||
@@ -810,6 +854,8 @@ class AppModules(
|
||||
rootFilesDir = { appContext.filesDir },
|
||||
powQueue = { powPublishQueue },
|
||||
meterSigner = { MeteringNostrSigner(it, resourceUsage) },
|
||||
signerPermissionStore = signerPermissionStore,
|
||||
nip46ClientStore = nip46ClientStore,
|
||||
)
|
||||
|
||||
val sessionManager =
|
||||
|
||||
@@ -25,6 +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
|
||||
@@ -37,8 +38,10 @@ 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
|
||||
@@ -54,6 +57,7 @@ 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
|
||||
@@ -108,7 +112,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"
|
||||
@@ -161,7 +171,10 @@ private object PrefKeys {
|
||||
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"
|
||||
@@ -170,6 +183,10 @@ private object PrefKeys {
|
||||
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"
|
||||
@@ -225,6 +242,19 @@ object LocalPreferences {
|
||||
// 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))
|
||||
}
|
||||
@@ -464,7 +494,13 @@ 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))
|
||||
@@ -547,7 +583,10 @@ object LocalPreferences {
|
||||
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)
|
||||
@@ -560,6 +599,7 @@ object LocalPreferences {
|
||||
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)
|
||||
@@ -674,7 +714,13 @@ 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)
|
||||
@@ -686,6 +732,7 @@ object LocalPreferences {
|
||||
?: 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)
|
||||
@@ -725,7 +772,10 @@ object LocalPreferences {
|
||||
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)
|
||||
@@ -785,7 +835,10 @@ object LocalPreferences {
|
||||
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 {
|
||||
@@ -837,7 +890,10 @@ object LocalPreferences {
|
||||
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()
|
||||
@@ -853,7 +909,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),
|
||||
@@ -900,6 +962,7 @@ object LocalPreferences {
|
||||
defaultRelayAuthPolicy = MutableStateFlow(defaultRelayAuthPolicy),
|
||||
relayGroupViewMode = MutableStateFlow(relayGroupViewMode),
|
||||
concordViewMode = MutableStateFlow(concordViewMode),
|
||||
enabledChatFeeds = MutableStateFlow(enabledChatFeeds),
|
||||
relayAuthTrustMyRelaysAndVenues = MutableStateFlow(relayAuthTrustMyRelays),
|
||||
relayAuthTrustReadFollows = MutableStateFlow(relayAuthTrustReadFollows),
|
||||
relayAuthTrustMessageFollows = MutableStateFlow(relayAuthTrustMessageFollows),
|
||||
@@ -924,7 +987,10 @@ object LocalPreferences {
|
||||
backupGeohashList = latestGeohashListResolved,
|
||||
backupEphemeralChatList = latestEphemeralListResolved,
|
||||
backupRelayGroupList = latestRelayGroupListResolved,
|
||||
backupConcordList = latestConcordListResolved,
|
||||
backupTrustProviderList = latestTrustProviderListResolved,
|
||||
backupKeyPackageRelayList = latestKeyPackageRelayListResolved,
|
||||
backupFavoriteAlgoFeedsList = latestFavoriteAlgoFeedsListResolved,
|
||||
lastReadPerRoute = MutableStateFlow(lastReadPerRouteResolved),
|
||||
hasDonatedInVersion = MutableStateFlow(hasDonatedInVersion),
|
||||
dismissedPollNoteIds = MutableStateFlow(dismissedPollNoteIds),
|
||||
|
||||
@@ -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.napplet
|
||||
package com.vitorpamplona.amethyst.connectedApps
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
@@ -26,12 +26,14 @@ 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.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.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.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
|
||||
|
||||
@@ -102,21 +104,24 @@ class DataStoreNostrSignerPermissionStore(
|
||||
storeFor(coordinate).edit { it.remove(opKey(op)) }
|
||||
}
|
||||
|
||||
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
|
||||
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
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
override suspend fun allOpDecisions(coordinate: String): Map<String, NostrOpDecision> {
|
||||
val prefs = storeFor(coordinate).data.first()
|
||||
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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.napplet
|
||||
package com.vitorpamplona.amethyst.connectedApps.consent
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
@@ -58,23 +58,24 @@ 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 NappletConnectActivity : ComponentActivity() {
|
||||
class SignerConnectActivity : ComponentActivity() {
|
||||
private var token: String? = null
|
||||
private var decided = false
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val token = intent.getStringExtra(NappletConnectCoordinator.EXTRA_TOKEN)
|
||||
val token = intent.getStringExtra(SignerConnectCoordinator.EXTRA_TOKEN)
|
||||
this.token = token
|
||||
val info = token?.let { NappletConnectCoordinator.infoFor(it) }
|
||||
val info = token?.let { SignerConnectCoordinator.infoFor(it) }
|
||||
if (token == null || info == null) {
|
||||
finish()
|
||||
return
|
||||
@@ -82,21 +83,21 @@ class NappletConnectActivity : ComponentActivity() {
|
||||
|
||||
setContent {
|
||||
AmethystTheme {
|
||||
NappletConnectScreen(
|
||||
SignerConnectScreen(
|
||||
info = info,
|
||||
onConnect = { policy ->
|
||||
decided = true
|
||||
NappletConnectCoordinator.complete(token, AppConnectResult.Connected(policy))
|
||||
SignerConnectCoordinator.complete(token, AppConnectResult.Connected(policy))
|
||||
finish()
|
||||
},
|
||||
onBlock = {
|
||||
decided = true
|
||||
NappletConnectCoordinator.complete(token, AppConnectResult.Blocked)
|
||||
SignerConnectCoordinator.complete(token, AppConnectResult.Blocked)
|
||||
finish()
|
||||
},
|
||||
onCancel = {
|
||||
decided = true
|
||||
NappletConnectCoordinator.complete(token, AppConnectResult.Cancelled)
|
||||
SignerConnectCoordinator.complete(token, AppConnectResult.Cancelled)
|
||||
finish()
|
||||
},
|
||||
)
|
||||
@@ -105,14 +106,14 @@ class NappletConnectActivity : ComponentActivity() {
|
||||
}
|
||||
|
||||
override fun finish() {
|
||||
if (!decided) token?.let { NappletConnectCoordinator.cancel(it) }
|
||||
if (!decided) token?.let { SignerConnectCoordinator.cancel(it) }
|
||||
super.finish()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NappletConnectScreen(
|
||||
info: NappletConnectInfo,
|
||||
private fun SignerConnectScreen(
|
||||
info: SignerConnectInfo,
|
||||
onConnect: (AppSignerPolicy) -> Unit,
|
||||
onBlock: () -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
@@ -168,12 +169,46 @@ private fun NappletConnectScreen(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Text(
|
||||
info.domain,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
@@ -194,21 +229,21 @@ private fun NappletConnectScreen(
|
||||
) {
|
||||
PolicyOption(
|
||||
selected = selected == AppSignerPolicy.FULL_TRUST,
|
||||
icon = "❤",
|
||||
symbol = MaterialSymbols.LockOpen,
|
||||
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,
|
||||
icon = "👍",
|
||||
symbol = MaterialSymbols.Shield,
|
||||
label = stringResource(R.string.napplet_policy_reasonable),
|
||||
description = stringResource(R.string.napplet_policy_reasonable_desc),
|
||||
onClick = { selected = AppSignerPolicy.REASONABLE },
|
||||
)
|
||||
PolicyOption(
|
||||
selected = selected == AppSignerPolicy.PARANOID,
|
||||
icon = "🕶",
|
||||
symbol = MaterialSymbols.Lock,
|
||||
label = stringResource(R.string.napplet_policy_paranoid),
|
||||
description = stringResource(R.string.napplet_policy_paranoid_desc),
|
||||
onClick = { selected = AppSignerPolicy.PARANOID },
|
||||
@@ -249,7 +284,7 @@ private fun NappletConnectScreen(
|
||||
@Composable
|
||||
private fun PolicyOption(
|
||||
selected: Boolean,
|
||||
icon: String,
|
||||
symbol: MaterialSymbol,
|
||||
label: String,
|
||||
description: String,
|
||||
onClick: () -> Unit,
|
||||
@@ -271,7 +306,12 @@ private fun PolicyOption(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text(icon, style = MaterialTheme.typography.headlineSmall)
|
||||
Icon(
|
||||
symbol = symbol,
|
||||
contentDescription = null,
|
||||
tint = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(26.dp),
|
||||
)
|
||||
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)
|
||||
@@ -18,21 +18,36 @@
|
||||
* 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
|
||||
package com.vitorpamplona.amethyst.connectedApps.consent
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import com.vitorpamplona.amethyst.commons.napplet.signers.AppConnectResult
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.connectedApps.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 NappletConnectInfo(
|
||||
data class SignerConnectInfo(
|
||||
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(),
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -40,9 +55,9 @@ data class NappletConnectInfo(
|
||||
* the Activity resolves the deferred with the user's choice.
|
||||
* A dismissed dialog resolves to [AppConnectResult.Cancelled] — fails closed, no silent grant.
|
||||
*/
|
||||
object NappletConnectCoordinator {
|
||||
object SignerConnectCoordinator {
|
||||
private class Pending(
|
||||
val info: NappletConnectInfo,
|
||||
val info: SignerConnectInfo,
|
||||
val deferred: CompletableDeferred<AppConnectResult>,
|
||||
)
|
||||
|
||||
@@ -50,26 +65,41 @@ object NappletConnectCoordinator {
|
||||
|
||||
suspend fun requestConnect(
|
||||
context: Context,
|
||||
info: NappletConnectInfo,
|
||||
info: SignerConnectInfo,
|
||||
): AppConnectResult {
|
||||
val token = UUID.randomUUID().toString()
|
||||
val deferred = CompletableDeferred<AppConnectResult>()
|
||||
pending[token] = Pending(info, deferred)
|
||||
|
||||
context.startActivity(
|
||||
Intent(context, NappletConnectActivity::class.java)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
.putExtra(EXTRA_TOKEN, token),
|
||||
)
|
||||
// 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,
|
||||
)
|
||||
|
||||
return try {
|
||||
deferred.await()
|
||||
} finally {
|
||||
pending.remove(token)
|
||||
SignerConsentNotifier.cancel(context, notificationId)
|
||||
}
|
||||
}
|
||||
|
||||
fun infoFor(token: String): NappletConnectInfo? = pending[token]?.info
|
||||
fun infoFor(token: String): SignerConnectInfo? = pending[token]?.info
|
||||
|
||||
fun complete(
|
||||
token: String,
|
||||
@@ -0,0 +1,591 @@
|
||||
/*
|
||||
* 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
/*
|
||||
* 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"
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
/*
|
||||
* 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
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/*
|
||||
* 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,10 +22,14 @@ 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
|
||||
|
||||
/**
|
||||
@@ -50,12 +54,28 @@ object BrowserIconRegistry {
|
||||
|
||||
@Volatile private var iconDir: File? = null
|
||||
|
||||
/** Binds the app context and indexes already-stored icons. Idempotent. */
|
||||
// Disk work runs here, never on the caller's thread. Both entry points are reached from threads
|
||||
// that must not block: init() from app startup and record() from the broker's IPC handler, which
|
||||
// is the main looper — StrictMode flagged the write, and a slow filesystem would have stalled the
|
||||
// UI while a favicon was saved.
|
||||
private val io = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
/**
|
||||
* Binds the app context and indexes already-stored icons. Idempotent.
|
||||
*
|
||||
* [iconDir] is published synchronously so [iconModelFor] and [record] work immediately; only the
|
||||
* directory scan is deferred. Until it lands [keys] is empty, so an icon simply renders its
|
||||
* placeholder for one frame and then recomposes — [keys] is a StateFlow precisely so that arrival
|
||||
* drives recomposition.
|
||||
*/
|
||||
fun init(context: Context) {
|
||||
if (iconDir != null) return
|
||||
val dir = File(context.applicationContext.filesDir, DIR).apply { mkdirs() }
|
||||
val dir = File(context.applicationContext.filesDir, DIR)
|
||||
iconDir = dir
|
||||
_keys.value = dir.listFiles()?.mapNotNull { it.name.removeSuffix(PNG).takeIf { n -> n.isNotBlank() } }?.toSet() ?: emptySet()
|
||||
io.launch {
|
||||
dir.mkdirs()
|
||||
_keys.value = dir.listFiles()?.mapNotNull { it.name.removeSuffix(PNG).takeIf { n -> n.isNotBlank() } }?.toSet() ?: emptySet()
|
||||
}
|
||||
}
|
||||
|
||||
/** Persists [bytes] as the favicon for [host] and marks it available. Called from the broker on IPC. */
|
||||
@@ -66,11 +86,17 @@ object BrowserIconRegistry {
|
||||
val dir = iconDir ?: return
|
||||
if (host.isBlank() || bytes.isEmpty()) return
|
||||
val key = sanitize(host)
|
||||
try {
|
||||
File(dir, key + PNG).writeBytes(bytes)
|
||||
_keys.update { it + key }
|
||||
} catch (e: Exception) {
|
||||
Log.w("BrowserIconRegistry", "Failed to store favicon for $host", e)
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ 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
|
||||
@@ -92,9 +93,20 @@ object FavoriteAppLauncher {
|
||||
}
|
||||
val isFavorite = FavoriteAppsRegistry.isFavorite("url:$url")
|
||||
val intent =
|
||||
NappletBrowserActivity.intent(context, url, proxyPort, useTor, theme = theme, isFavorite = isFavorite).apply {
|
||||
if (context !is Activity) addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
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)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,11 @@ import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.commons.actions.ConcordActions
|
||||
import com.vitorpamplona.amethyst.commons.actions.ConcordModeration
|
||||
import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle
|
||||
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.NostrSignerPermissionLedger
|
||||
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionStore
|
||||
import com.vitorpamplona.amethyst.commons.marmot.MarmotManager
|
||||
import com.vitorpamplona.amethyst.commons.model.IAccount
|
||||
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel
|
||||
@@ -54,6 +59,7 @@ import com.vitorpamplona.amethyst.commons.model.nip72Communities.CommunityListDe
|
||||
import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.ContactCardDecryptionCache
|
||||
import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.ContactCardsState
|
||||
import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.TrustProviderListDecryptionCache
|
||||
import com.vitorpamplona.amethyst.commons.model.privateChats.hasEncryptedContent
|
||||
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendError
|
||||
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendResult
|
||||
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendStage
|
||||
@@ -90,6 +96,7 @@ import com.vitorpamplona.amethyst.model.nip03Timestamp.OtsState
|
||||
import com.vitorpamplona.amethyst.model.nip17Dms.DmInboxRelayState
|
||||
import com.vitorpamplona.amethyst.model.nip17Dms.DmRelayListState
|
||||
import com.vitorpamplona.amethyst.model.nip30CustomEmojis.OwnedEmojiPacksState
|
||||
import com.vitorpamplona.amethyst.model.nip46Signer.Nip46SignerState
|
||||
import com.vitorpamplona.amethyst.model.nip47WalletConnect.NwcSignerState
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.BookmarkListState
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.GitRepositoryListState
|
||||
@@ -202,6 +209,7 @@ import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayLoadingCursors
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
@@ -356,6 +364,7 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import java.math.BigDecimal
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import com.vitorpamplona.quartz.experimental.nip95.header.thumbhash as nip95thumbhash
|
||||
import com.vitorpamplona.quartz.experimental.profileGallery.thumbhash as galleryThumbhash
|
||||
@@ -365,6 +374,14 @@ private const val ONCHAIN_BACKEND_NOT_CONFIGURED = "Bitcoin chain backend is not
|
||||
/** Name of the default Concord community Admin role minted by "Make admin". */
|
||||
private const val CONCORD_ADMIN_ROLE = "Admin"
|
||||
|
||||
/**
|
||||
* How often a joined Concord community's stored invite link is re-resolved to check whether
|
||||
* we were left out of a Refounding (see `recoverStrandedConcordCommunities`). Stranding is
|
||||
* rare and silent, so this trades detection latency for not turning the revision tick into a
|
||||
* relay-fetch loop.
|
||||
*/
|
||||
private const val RECOVERY_CHECK_INTERVAL_MS = 15 * 60 * 1000L
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
@Stable
|
||||
class Account(
|
||||
@@ -384,6 +401,8 @@ class Account(
|
||||
val marmotKeyPackageStore: com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore? = null,
|
||||
val powQueue: () -> PoWPublishQueue? = { null },
|
||||
relayAuthPermissionStore: RelayAuthPermissionStore = InMemoryRelayAuthPermissionStore(),
|
||||
signerPermissionStore: NostrSignerPermissionStore = InMemoryNostrSignerPermissionStore(),
|
||||
nip46ClientStore: Nip46ClientStore = InMemoryNip46ClientStore(),
|
||||
) : IAccount {
|
||||
private var userProfileCache: User? = null
|
||||
|
||||
@@ -438,6 +457,25 @@ class Account(
|
||||
val nip65RelayList = Nip65RelayListState(signer, cache, scope, settings)
|
||||
val localRelayList = LocalRelayListState(signer, cache, scope, settings)
|
||||
|
||||
/** Connected-Apps signer permission ledger, shared by napplets and the NIP-46 bunker. */
|
||||
val signerPermissionLedger = NostrSignerPermissionLedger(signerPermissionStore)
|
||||
|
||||
/**
|
||||
* Runs this account as a NIP-46 remote signer for other apps when
|
||||
* [AccountSettings.nip46SignerEnabled] is on, listening on the inbox relays
|
||||
* and dispatching to [signer] (see [Nip46SignerState]).
|
||||
*/
|
||||
val nip46Signer =
|
||||
Nip46SignerState(
|
||||
signer = signer,
|
||||
client = client,
|
||||
ledger = signerPermissionLedger,
|
||||
clientStore = nip46ClientStore,
|
||||
inboxRelays = nip65RelayList.inboxFlow,
|
||||
scope = scope,
|
||||
settings = settings,
|
||||
)
|
||||
|
||||
val forwardKind0ToLocalRelay = ForwardKind0ToLocalRelayState(client, localRelayList, settings)
|
||||
|
||||
val dmRelayList = DmRelayListState(signer, cache, scope, settings)
|
||||
@@ -623,6 +661,11 @@ class Account(
|
||||
val dmRelays = DmInboxRelayState(dmRelayList, nip65RelayList, privateStorageRelayList, localRelayList, scope)
|
||||
val notificationRelays = NotificationInboxRelayState(nip65RelayList, localRelayList, scope)
|
||||
|
||||
// Account-level notification history paging cursors (one scope per account): how far back each
|
||||
// notification relay has been paged by until+limit. Held here so they share the account's lifetime;
|
||||
// the history loader ([AccountNotificationsHistoryEoseManager]) binds its orchestrator to these.
|
||||
val notificationHistory = RelayLoadingCursors()
|
||||
|
||||
val cashuWalletState =
|
||||
com.vitorpamplona.amethyst.model.nip60Cashu.CashuWalletState(
|
||||
pubKey = signer.pubKey,
|
||||
@@ -1567,12 +1610,26 @@ class Account(
|
||||
hash: HexKey,
|
||||
size: Long,
|
||||
alt: String,
|
||||
) = blossomServers.createBlossomUploadAuth(hash, size, alt)
|
||||
servers: List<String> = emptyList(),
|
||||
) = blossomServers.createBlossomUploadAuth(hash, size, alt, servers)
|
||||
|
||||
suspend fun createBlossomMediaAuth(
|
||||
hash: HexKey,
|
||||
size: Long,
|
||||
alt: String,
|
||||
servers: List<String> = emptyList(),
|
||||
) = blossomServers.createBlossomMediaAuth(hash, size, alt, servers)
|
||||
|
||||
suspend fun createBlossomDeleteAuth(
|
||||
hash: HexKey,
|
||||
alt: String,
|
||||
) = blossomServers.createBlossomDeleteAuth(hash, alt)
|
||||
servers: List<String> = emptyList(),
|
||||
) = blossomServers.createBlossomDeleteAuth(hash, alt, servers)
|
||||
|
||||
suspend fun createBlossomListAuth(
|
||||
alt: String,
|
||||
servers: List<String> = emptyList(),
|
||||
) = blossomServers.createBlossomListAuth(alt, servers)
|
||||
|
||||
suspend fun boost(note: Note) {
|
||||
val powDifficulty = powDifficultyFor(RepostEvent.KIND)
|
||||
@@ -2048,6 +2105,21 @@ class Account(
|
||||
* both explain what went wrong and decide whether a retry could ever help — a
|
||||
* bundle we can't open (e.g. minted by a newer client) must not strand the user
|
||||
* on a spinner that retries forever.
|
||||
*
|
||||
* A bundle whose `expires_at` has passed is rejected with
|
||||
* [ConcordInviteResult.Expired]. Expiry is resolved inside
|
||||
* [ConcordActions.classifyInvite], so it is enforced on every redeem path rather
|
||||
* than being a field nobody reads.
|
||||
*
|
||||
* **This must only ever be called from an explicit user action.** It contacts
|
||||
* relay URLs carried in the link (chosen by whoever minted it) and publishes a
|
||||
* Guestbook JOIN signed by this account, so calling it on deep-link arrival would
|
||||
* leak the user's IP and enroll them without consent — see `ConcordInviteScreen`.
|
||||
*
|
||||
* If the resolved community is already in the joined list, this returns
|
||||
* [ConcordInviteResult.Joined] without re-following or re-announcing a Guestbook
|
||||
* JOIN, so reopening an old invite for a community you're already in simply takes
|
||||
* you to it.
|
||||
*/
|
||||
suspend fun joinConcordViaInvite(url: String): ConcordInviteResult {
|
||||
if (!isWriteable()) return ConcordInviteResult.InvalidLink
|
||||
@@ -2066,11 +2138,20 @@ class Account(
|
||||
val bundle =
|
||||
when (val status = ConcordActions.classifyInvite(wraps, parsed.fragment.token)) {
|
||||
is InviteBundleStatus.Live -> status.invite
|
||||
is InviteBundleStatus.Expired -> return ConcordInviteResult.Expired
|
||||
InviteBundleStatus.Revoked -> return ConcordInviteResult.Revoked
|
||||
InviteBundleStatus.Unreadable -> return ConcordInviteResult.Incompatible
|
||||
InviteBundleStatus.Absent -> return ConcordInviteResult.NotReachable
|
||||
}
|
||||
|
||||
// Already a member? Just take the user to the community. Re-following and re-announcing a
|
||||
// Guestbook JOIN (kind 3306) would spam the community relays with a fresh join every time an
|
||||
// old invite is reopened, so short-circuit to Joined — the screen forwards to the community
|
||||
// either way ("take me there", not "join again").
|
||||
if (concordChannelList.liveCommunities.value.any { it.id == bundle.communityId }) {
|
||||
return ConcordInviteResult.Joined(bundle.communityId)
|
||||
}
|
||||
|
||||
val entry =
|
||||
ConcordCommunityListEntry(
|
||||
id = bundle.communityId,
|
||||
@@ -2081,6 +2162,10 @@ class Account(
|
||||
relays = bundle.relays,
|
||||
name = bundle.name,
|
||||
addedAt = TimeUtils.now() * 1000,
|
||||
// Anchor for stranded recovery: keep the link we joined through, domain-agnostic, so a
|
||||
// Refounding that leaves us out of the recipient set is recoverable later. See
|
||||
// recoverStrandedConcordCommunities().
|
||||
inviteRef = ConcordActions.bareInviteRef(url),
|
||||
)
|
||||
joinConcordCommunity(entry)
|
||||
return ConcordInviteResult.Joined(bundle.communityId)
|
||||
@@ -2289,7 +2374,7 @@ class Account(
|
||||
): Boolean {
|
||||
val session = concordSessions.sessionFor(communityId) ?: return false
|
||||
if (!isWriteable()) return false
|
||||
val wrap = ConcordModeration.grant(signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, roleIds, session.controlEditions(), TimeUtils.now())
|
||||
val wrap = ConcordModeration.grant(signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, roleIds, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
|
||||
publishConcordWrap(session.entry, wrap)
|
||||
return true
|
||||
}
|
||||
@@ -2351,12 +2436,12 @@ class Account(
|
||||
val roleIdHex =
|
||||
existing?.key ?: run {
|
||||
val roleId = RandomInstance.bytes(32)
|
||||
val roleWrap = ConcordModeration.defineRole(signer, cp, roleId, concordAdminRole(), session.controlEditions(), TimeUtils.now())
|
||||
val roleWrap = ConcordModeration.defineRole(signer, cp, roleId, concordAdminRole(), session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
|
||||
publishConcordWrap(session.entry, roleWrap)
|
||||
roleId.toHexKey()
|
||||
}
|
||||
|
||||
val grantWrap = ConcordModeration.grant(signer, cp, communityId.hexToByteArray(), member, listOf(roleIdHex), session.controlEditions(), TimeUtils.now())
|
||||
val grantWrap = ConcordModeration.grant(signer, cp, communityId.hexToByteArray(), member, listOf(roleIdHex), session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
|
||||
publishConcordWrap(session.entry, grantWrap)
|
||||
return true
|
||||
}
|
||||
@@ -2368,17 +2453,26 @@ class Account(
|
||||
): Boolean {
|
||||
val session = concordSessions.sessionFor(communityId) ?: return false
|
||||
if (!isWriteable()) return false
|
||||
val grantWrap = ConcordModeration.grant(signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, emptyList(), session.controlEditions(), TimeUtils.now())
|
||||
val grantWrap = ConcordModeration.grant(signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, emptyList(), session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
|
||||
publishConcordWrap(session.entry, grantWrap)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* If [note] is a Concord channel message whose author this account is allowed to
|
||||
* ban — the actor is the owner or holds the BAN permission, and the target is
|
||||
* neither the owner nor the actor — returns `(communityId, memberHex)`. Null
|
||||
* otherwise, so the UI shows the Ban action only when it would actually take
|
||||
* effect on fold.
|
||||
* ban — the actor outranks the target and holds the BAN permission, and the target
|
||||
* is neither the owner nor the actor — returns `(communityId, memberHex)`. Null
|
||||
* otherwise, so the UI offers Ban only where we are willing to act.
|
||||
*
|
||||
* The rank half is ours alone. CORD-04 rank-gates role grants (`canActOn`) but the
|
||||
* BANLIST is a single whole-list entity, so neither this client's fold nor Armada's
|
||||
* rank-checks the *contents* of a banlist edition — both gate only on the author's
|
||||
* BAN bit (Armada: `banlistGate` → `isAuthorized(.., Permissions.BAN)`, while its
|
||||
* role path uses the rank-aware `canActOnPosition`). A moderator's ban of an admin
|
||||
* above them is therefore *accepted* by every client today. Since we cannot refuse
|
||||
* such a ban without diverging from Armada, we at least refuse to author one — this
|
||||
* restricts what we write, never what we accept, so it cannot split consensus.
|
||||
* Enforcing it on the fold needs a spec change; see the QA plan's open findings.
|
||||
*/
|
||||
fun concordBanTarget(note: Note): Pair<String, HexKey>? {
|
||||
val channel = note.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel } ?: return null
|
||||
@@ -2392,7 +2486,11 @@ class Account(
|
||||
?.value
|
||||
?.authority ?: return null
|
||||
if (authority.isOwner(author)) return null
|
||||
val canBan = authority.isOwner(signer.pubKey) || authority.effectivePermissions(signer.pubKey).has(ConcordPermissions.BAN)
|
||||
// The owner short-circuits rather than going through canActOn: canActOn starts at
|
||||
// hasPermission, which is false while banned, and a rogue BAN holder *can* currently put
|
||||
// the owner on the banlist (see the KDoc) — routing the owner through it would let them be
|
||||
// locked out of moderating their own community.
|
||||
val canBan = authority.isOwner(signer.pubKey) || authority.canActOn(signer.pubKey, author, ConcordPermissions.BAN)
|
||||
return if (canBan) communityId to author else null
|
||||
}
|
||||
|
||||
@@ -2403,7 +2501,7 @@ class Account(
|
||||
): Boolean {
|
||||
val session = concordSessions.sessionFor(communityId) ?: return false
|
||||
if (!isWriteable()) return false
|
||||
val wrap = ConcordModeration.ban(signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, session.controlEditions(), TimeUtils.now())
|
||||
val wrap = ConcordModeration.ban(signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
|
||||
publishConcordWrap(session.entry, wrap)
|
||||
return true
|
||||
}
|
||||
@@ -2415,7 +2513,7 @@ class Account(
|
||||
): Boolean {
|
||||
val session = concordSessions.sessionFor(communityId) ?: return false
|
||||
if (!isWriteable()) return false
|
||||
val wrap = ConcordModeration.unban(signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, session.controlEditions(), TimeUtils.now())
|
||||
val wrap = ConcordModeration.unban(signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
|
||||
publishConcordWrap(session.entry, wrap)
|
||||
return true
|
||||
}
|
||||
@@ -2429,7 +2527,7 @@ class Account(
|
||||
/**
|
||||
* Remove [removed] from the community absolutely (CORD-06 Refounding): ban them,
|
||||
* roll the `community_root`, re-key every retained member (Guestbook membership ∪
|
||||
* the privileged roster ∪ self) via kind-3303 blobs, and republish the compacted
|
||||
* observed authors ∪ the privileged roster ∪ self) via kind-3303 blobs, and republish the compacted
|
||||
* Control Plane under the new root. A removed member keeps the prior root (so
|
||||
* their history stays readable) but receives no blob, so they can never decrypt
|
||||
* anything published after the rotation.
|
||||
@@ -2454,14 +2552,23 @@ class Account(
|
||||
// and thus the new epoch — carries the ban. publishConcordWrap folds it in locally
|
||||
// first, so each subsequent edition chains onto the updated banlist head.
|
||||
for (target in removedLower) {
|
||||
val banWrap = ConcordModeration.ban(signer, session.controlPlaneKey(), communityId.hexToByteArray(), target, session.controlEditions(), TimeUtils.now())
|
||||
val banWrap = ConcordModeration.ban(signer, session.controlPlaneKey(), communityId.hexToByteArray(), target, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
|
||||
publishConcordWrap(session.entry, banWrap)
|
||||
}
|
||||
|
||||
// 2. Recipient set: everyone we're keeping — Guestbook joins ∪ roster ∪ self, minus the
|
||||
// removed and the already-banned.
|
||||
// 2. Recipient set: everyone we're keeping, minus the removed and the already-banned.
|
||||
// Uses allMembers() — Guestbook joins ∪ OBSERVED AUTHORS ∪ roster ∪ owner — not just the
|
||||
// Guestbook set. Most members never send a Guestbook Join (Amethyst announces one, other
|
||||
// clients need not), so building the set without observed authors silently expelled every
|
||||
// member who had only ever posted: they hold no role, receive no blob, and the Refounding
|
||||
// strands them. That mainly hit cross-client communities, where Armada members are the
|
||||
// bulk of the roster.
|
||||
//
|
||||
// Still a floor, not a census (see allMembers): a member who joined without a Guestbook
|
||||
// motion, holds no role, and has never posted leaves no trace to find, so a Refounding
|
||||
// cannot re-key them. Stranded recovery is what gets those members back.
|
||||
val recipients =
|
||||
(session.members.value + authority.roleHolders() + state.ownerPubKey + signer.pubKey)
|
||||
(session.allMembers() + signer.pubKey)
|
||||
.mapTo(HashSet()) { it.lowercase() }
|
||||
.apply {
|
||||
removeAll(removedLower)
|
||||
@@ -2529,6 +2636,13 @@ class Account(
|
||||
relays = entry.relays,
|
||||
name = entry.name,
|
||||
addedAt = entry.addedAt,
|
||||
// The invite_ref anchor must survive a rotation, or the *next* Refounding we're left
|
||||
// out of would be unrecoverable.
|
||||
inviteRef = entry.inviteRef,
|
||||
excludedAtEpoch = entry.excludedAtEpoch,
|
||||
// Unknown keys another client wrote (Armada's list is `[k: string]: unknown`)
|
||||
// must survive our rotation write, or we delete their data on every rekey.
|
||||
residue = entry.residue,
|
||||
)
|
||||
sendMyPublicAndPrivateOutbox(concordChannelList.follow(next))
|
||||
announceConcordGuestbookJoin(next, inviteCreator = null, inviteLabel = null)
|
||||
@@ -2537,10 +2651,23 @@ class Account(
|
||||
/**
|
||||
* Drain any buffered inbound base-rotation rekeys (CORD-06 receive path): for
|
||||
* each joined community, look for our new root among the kind-3303 wraps seen at
|
||||
* our next base-rekey address. If a role-authorized rotator (owner or a current
|
||||
* BAN-holder) delivered us one, adopt it. Idempotent — once adopted, the session
|
||||
* rebuilds at the new epoch and its next-rekey address moves on, so a stale wrap
|
||||
* never re-triggers. Called on every Concord revision tick.
|
||||
* our next base-rekey address. If a role-authorized rotator (owner or a current,
|
||||
* non-banned BAN-holder) delivered us one, adopt it. Idempotent — once adopted, the
|
||||
* session rebuilds at the new epoch and its next-rekey address moves on, so a stale
|
||||
* wrap never re-triggers. Called on every Concord revision tick.
|
||||
*
|
||||
* Authority is the roster, never key possession: any non-banned BAN-holder may
|
||||
* rotate, including for the owner. The owner deliberately does NOT refuse a root
|
||||
* authored by someone else — refusing would strand the owner alone on the dead
|
||||
* epoch whenever an admin legitimately rotates, and would diverge from Armada,
|
||||
* which forks a community across clients. Self-escalation to BAN is prevented
|
||||
* upstream by the role rank gate in AuthorityResolver.
|
||||
*
|
||||
* A rotation carries only (newRoot, newEpoch, rotator); there is no recipient list,
|
||||
* so a receiver cannot tell who was left out, and a BAN-holder can evict anyone (the
|
||||
* owner included) by omission — nothing on this receive path can prevent it. The
|
||||
* cure is after the fact: see [recoverStrandedConcordCommunities], which re-resolves
|
||||
* the invite link the membership was joined through and merges forward.
|
||||
*/
|
||||
private suspend fun drainConcordRekeys() {
|
||||
if (!isWriteable()) return
|
||||
@@ -2558,12 +2685,76 @@ class Account(
|
||||
) ?: continue
|
||||
if (received.newEpoch <= entry.rootEpoch) continue
|
||||
val authority = session.state.value?.authority ?: continue
|
||||
val authorized = authority.isOwner(received.rotator) || authority.effectivePermissions(received.rotator).has(ConcordPermissions.BAN)
|
||||
|
||||
// hasPermission, not effectivePermissions: the latter ignores the banlist, so a BAN-holder
|
||||
// who has themselves been banned could still rotate the whole community.
|
||||
val authorized = authority.isOwner(received.rotator) || authority.hasPermission(received.rotator, ConcordPermissions.BAN)
|
||||
if (!authorized) continue
|
||||
adoptConcordRoot(entry, received.newRoot, received.newEpoch)
|
||||
}
|
||||
}
|
||||
|
||||
// Last time we re-resolved each community's invite_ref, so the recovery sweep rides the
|
||||
// Concord revision tick (which fires on every structural change) without turning it into a
|
||||
// relay-fetch loop.
|
||||
private val lastConcordRecoveryCheck = ConcurrentHashMap<String, Long>()
|
||||
|
||||
/**
|
||||
* Stranded recovery (CORD-05/06 receive path). A Refounding carries only
|
||||
* `(newRoot, newEpoch, rotator)` — **no recipient list** — so a member simply left
|
||||
* out of the rekey recipient set receives nothing and sits on the dead epoch
|
||||
* forever while everyone else moves on. This happens to any member, the owner
|
||||
* included, and [drainConcordRekeys] cannot prevent it: there is no message to
|
||||
* miss detecting.
|
||||
*
|
||||
* The way back is the invite link the membership was joined through
|
||||
* ([ConcordCommunityListEntry.inviteRef], persisted by [joinConcordViaInvite] and
|
||||
* carried through every rotation by [adoptConcordRoot]). The community keeps
|
||||
* re-minting its bundle at that same addressable coordinate, so a bundle there at
|
||||
* a **strictly higher** epoch than ours proves we were left behind — and carries
|
||||
* the new root. Same or lower epoch is a no-op. Memberships with no link (direct
|
||||
* invites, legacy entries) are inert here; that is expected, not an error.
|
||||
*
|
||||
* The merge itself ([ConcordActions.recoverStranded]) is epoch-monotonic and keeps
|
||||
* both the `invite_ref` anchor (so the *next* exclusion is recoverable too) and the
|
||||
* entry's [HeldRoot]s (so prior-epoch history the member legitimately holds stays
|
||||
* derivable). We then re-announce the Guestbook at the new epoch, exactly as an
|
||||
* ordinary rotation does, so the recovered member is visible to whoever refounds
|
||||
* next instead of being silently dropped again.
|
||||
*
|
||||
* Called on the Concord revision tick, but rate-limited per community
|
||||
* ([RECOVERY_CHECK_INTERVAL_MS]) — a tick with nothing to do costs a map lookup.
|
||||
*/
|
||||
private suspend fun recoverStrandedConcordCommunities() {
|
||||
if (!isWriteable()) return
|
||||
val now = TimeUtils.nowMillis()
|
||||
for (entry in concordChannelList.liveCommunities.value) {
|
||||
val inviteRef = entry.inviteRef ?: continue
|
||||
val last = lastConcordRecoveryCheck[entry.id]
|
||||
if (last != null && now - last < RECOVERY_CHECK_INTERVAL_MS) continue
|
||||
lastConcordRecoveryCheck[entry.id] = now
|
||||
|
||||
val parsed = ConcordActions.parseInviteLink(inviteRef) ?: continue
|
||||
val relays =
|
||||
(
|
||||
parsed.fragment.relays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } +
|
||||
entry.relays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }
|
||||
).toSet()
|
||||
if (relays.isEmpty()) continue
|
||||
|
||||
val filters = relays.associateWith { listOf(ConcordActions.bundleFilter(parsed.linkSignerPubKey)) }
|
||||
val wraps = client.fetchAll(filters = filters)
|
||||
// Only a live bundle recovers: an expired/revoked link is not a rotation we missed.
|
||||
val bundle = (ConcordActions.classifyInvite(wraps, parsed.fragment.token) as? InviteBundleStatus.Live)?.invite ?: continue
|
||||
|
||||
val merged = ConcordActions.recoverStranded(entry, bundle) ?: continue
|
||||
if (!adoptedConcordRotations.add("${entry.id}:${merged.rootEpoch}")) continue
|
||||
Log.i("Concord", "Stranded recovery: ${entry.id} ${entry.rootEpoch} -> ${merged.rootEpoch}")
|
||||
sendMyPublicAndPrivateOutbox(concordChannelList.follow(merged))
|
||||
announceConcordGuestbookJoin(merged, inviteCreator = null, inviteLabel = null)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the community metadata (name / icon / description / relays) with a new
|
||||
* Control-Plane edition. Honored on fold only when this account holds
|
||||
@@ -2580,7 +2771,7 @@ class Account(
|
||||
val session = concordSessions.sessionFor(communityId) ?: return false
|
||||
if (!isWriteable()) return false
|
||||
val metadata = MetadataEntity(name = name, icon = icon, banner = banner, description = description, relays = relays)
|
||||
val wrap = ConcordModeration.editMetadata(signer, session.controlPlaneKey(), communityId.hexToByteArray(), metadata, session.controlEditions(), TimeUtils.now())
|
||||
val wrap = ConcordModeration.editMetadata(signer, session.controlPlaneKey(), communityId.hexToByteArray(), metadata, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
|
||||
publishConcordWrap(session.entry, wrap)
|
||||
return true
|
||||
}
|
||||
@@ -2598,7 +2789,7 @@ class Account(
|
||||
if (!isWriteable()) return false
|
||||
val channelId = RandomInstance.bytes(32)
|
||||
val channel = ChannelEntity(name = name.trim())
|
||||
val wrap = ConcordModeration.defineChannel(signer, session.controlPlaneKey(), channelId, channel, session.controlEditions(), TimeUtils.now())
|
||||
val wrap = ConcordModeration.defineChannel(signer, session.controlPlaneKey(), channelId, channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
|
||||
publishConcordWrap(session.entry, wrap)
|
||||
return true
|
||||
}
|
||||
@@ -2611,8 +2802,16 @@ class Account(
|
||||
): Boolean {
|
||||
val session = concordSessions.sessionFor(communityId) ?: return false
|
||||
if (!isWriteable()) return false
|
||||
val channel = ChannelEntity(name = name.trim())
|
||||
val wrap = ConcordModeration.defineChannel(signer, session.controlPlaneKey(), channelIdHex.hexToByteArray(), channel, session.controlEditions(), TimeUtils.now())
|
||||
// Carry the standing definition forward and change only the name. A ChannelEntity built from
|
||||
// scratch defaults `private` and `voice` to false, so renaming a private channel used to
|
||||
// publish an edition declaring it PUBLIC — and a voice channel became a text channel.
|
||||
val standing =
|
||||
session.state.value
|
||||
?.channels
|
||||
?.get(channelIdHex)
|
||||
?.definition
|
||||
val channel = ChannelEntity(name = name.trim(), private = standing?.private ?: false, voice = standing?.voice ?: false)
|
||||
val wrap = ConcordModeration.defineChannel(signer, session.controlPlaneKey(), channelIdHex.hexToByteArray(), channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
|
||||
publishConcordWrap(session.entry, wrap)
|
||||
return true
|
||||
}
|
||||
@@ -2625,8 +2824,15 @@ class Account(
|
||||
): Boolean {
|
||||
val session = concordSessions.sessionFor(communityId) ?: return false
|
||||
if (!isWriteable()) return false
|
||||
val channel = ChannelEntity(name = name.trim(), deleted = true)
|
||||
val wrap = ConcordModeration.defineChannel(signer, session.controlPlaneKey(), channelIdHex.hexToByteArray(), channel, session.controlEditions(), TimeUtils.now())
|
||||
// Same as rename: preserve the standing flags so a tombstone does not also silently
|
||||
// reclassify the channel it retires.
|
||||
val standing =
|
||||
session.state.value
|
||||
?.channels
|
||||
?.get(channelIdHex)
|
||||
?.definition
|
||||
val channel = ChannelEntity(name = name.trim(), private = standing?.private ?: false, voice = standing?.voice ?: false, deleted = true)
|
||||
val wrap = ConcordModeration.defineChannel(signer, session.controlPlaneKey(), channelIdHex.hexToByteArray(), channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
|
||||
publishConcordWrap(session.entry, wrap)
|
||||
return true
|
||||
}
|
||||
@@ -4862,7 +5068,10 @@ class Account(
|
||||
else -> event.content
|
||||
}
|
||||
} else {
|
||||
event.content
|
||||
// A read-only (npub-only) account holds no key, so nothing above can run. Returning
|
||||
// `content` verbatim would push the raw NIP-04/NIP-44 base64 blob straight into the
|
||||
// UI (chat bubbles, Messages previews, ...). Callers treat null as "not readable".
|
||||
if (event.hasEncryptedContent()) null else event.content
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4889,6 +5098,11 @@ class Account(
|
||||
draftsDecryptionCache.cachedDraft(event)?.content
|
||||
}
|
||||
|
||||
// Encrypted kinds that reached here did so because this account is not writeable
|
||||
// (every branch above is gated on isWriteable). Their `content` is ciphertext —
|
||||
// hand back null rather than let the blob render. See cachedDecryptContent.
|
||||
event != null && event.hasEncryptedContent() -> null
|
||||
|
||||
else -> {
|
||||
event?.content
|
||||
}
|
||||
@@ -5319,6 +5533,9 @@ class Account(
|
||||
refreshConcordChannelIndex()
|
||||
// A revision also bumps when a base-rotation rekey lands; adopt ours if present.
|
||||
runCatching { drainConcordRekeys() }.onFailure { Log.w("Concord", "rekey drain failed", it) }
|
||||
// A rotation we were *excluded* from produces no rekey to drain, so it can only be
|
||||
// found by re-resolving the invite link we joined through. Rate-limited internally.
|
||||
runCatching { recoverStrandedConcordCommunities() }.onFailure { Log.w("Concord", "stranded recovery failed", it) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ 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
|
||||
@@ -126,6 +127,14 @@ 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 ")
|
||||
|
||||
@@ -185,6 +194,45 @@ 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].
|
||||
@@ -281,6 +329,9 @@ class AccountSettings(
|
||||
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),
|
||||
@@ -317,6 +368,20 @@ class AccountSettings(
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
// ---
|
||||
@@ -582,6 +647,35 @@ 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)
|
||||
@@ -589,6 +683,20 @@ 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()
|
||||
|
||||
@@ -47,6 +47,13 @@ sealed interface ConcordInviteResult {
|
||||
*/
|
||||
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
|
||||
|
||||
@@ -1626,9 +1626,9 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
): Boolean {
|
||||
val note = getOrCreateNote(event.id)
|
||||
|
||||
// Approval notes are badge-rendered directly in community feeds
|
||||
// (BadgeBox has no repost-style indirection for them), so without
|
||||
// this attribution their relay list stays empty forever.
|
||||
// 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)
|
||||
@@ -2006,9 +2006,20 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
if (note.event == null) return
|
||||
|
||||
if (relay != null) {
|
||||
// Normal arrival: the group only exists on its host relay and the
|
||||
// filters are host-pinned, so the serving relay is the group's key.
|
||||
getOrCreateRelayGroupChannel(GroupId(groupId, relay)).addNote(note, relay)
|
||||
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
|
||||
@@ -2022,6 +2033,12 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
}
|
||||
}
|
||||
|
||||
/** 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
|
||||
@@ -2036,7 +2053,15 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
if (note.event == null) return
|
||||
|
||||
if (relay != null) {
|
||||
getOrCreateRelayGroupChannel(GroupId(groupId, relay)).addThread(note)
|
||||
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
|
||||
|
||||
@@ -21,51 +21,71 @@
|
||||
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
|
||||
|
||||
@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"),
|
||||
)
|
||||
/**
|
||||
* 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)
|
||||
@Stable
|
||||
open class Resource(
|
||||
var resourceType: String? = null,
|
||||
var id: String = "",
|
||||
)
|
||||
abstract class Resource {
|
||||
abstract val resourceType: String?
|
||||
abstract val 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(
|
||||
resourceType: String? = null,
|
||||
id: String = "",
|
||||
override val resourceType: String? = null,
|
||||
override val id: String = "",
|
||||
var active: Boolean? = null,
|
||||
var name: ArrayList<HumanName> = arrayListOf(),
|
||||
var gender: String? = null,
|
||||
) : Resource(resourceType, id)
|
||||
) : Resource()
|
||||
|
||||
@Serializable
|
||||
@Stable
|
||||
class Patient(
|
||||
resourceType: String? = null,
|
||||
id: String = "",
|
||||
override val resourceType: String? = null,
|
||||
override val id: String = "",
|
||||
var active: Boolean? = null,
|
||||
var name: ArrayList<HumanName> = arrayListOf(),
|
||||
var gender: String? = null,
|
||||
) : Resource(resourceType, id)
|
||||
) : Resource()
|
||||
|
||||
@Serializable
|
||||
@Stable
|
||||
class HumanName(
|
||||
var use: String? = null,
|
||||
@@ -75,19 +95,21 @@ class HumanName(
|
||||
fun assembleName(): String = given.joinToString(" ") + " " + family
|
||||
}
|
||||
|
||||
@Serializable
|
||||
@Stable
|
||||
class Bundle(
|
||||
resourceType: String? = null,
|
||||
id: String = "",
|
||||
override val resourceType: String? = null,
|
||||
override val id: String = "",
|
||||
var type: String? = null,
|
||||
var created: String? = null,
|
||||
var entry: List<Resource> = arrayListOf(),
|
||||
) : Resource(resourceType, id)
|
||||
) : Resource()
|
||||
|
||||
@Serializable
|
||||
@Stable
|
||||
class VisionPrescription(
|
||||
resourceType: String? = null,
|
||||
id: String = "",
|
||||
override val resourceType: String? = null,
|
||||
override val id: String = "",
|
||||
var status: String? = null,
|
||||
var created: String? = null,
|
||||
var patient: Reference? = Reference(),
|
||||
@@ -95,7 +117,7 @@ class VisionPrescription(
|
||||
var dateWritten: String? = null,
|
||||
var prescriber: Reference? = Reference(),
|
||||
var lensSpecification: List<LensSpecification> = arrayListOf(),
|
||||
) : Resource(resourceType, id) {
|
||||
) : Resource() {
|
||||
fun glasses() = lensSpecification.filter { it.product == "lens" }
|
||||
|
||||
fun contacts() = lensSpecification.filter { it.product == "contacts" }
|
||||
@@ -109,6 +131,7 @@ class VisionPrescription(
|
||||
fun contactsLeftEyes() = lensSpecification.filter { it.product == "contacts" && it.eye == "left" }
|
||||
}
|
||||
|
||||
@Serializable
|
||||
@Stable
|
||||
class LensSpecification(
|
||||
var product: String? = null,
|
||||
@@ -129,12 +152,14 @@ 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,
|
||||
)
|
||||
@@ -156,12 +181,22 @@ fun findReferenceInDb(
|
||||
}
|
||||
}
|
||||
|
||||
fun parseResourceBundleOrNull(json: String): FhirElementDatabase? {
|
||||
val mapper =
|
||||
jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
return try {
|
||||
val resource = mapper.readValue<Resource>(json)
|
||||
fun parseResourceBundleOrNull(json: String): FhirElementDatabase? =
|
||||
try {
|
||||
val resource = FhirJson.decodeFromString(ResourceSerializer, json)
|
||||
|
||||
val db =
|
||||
when (resource) {
|
||||
@@ -182,4 +217,3 @@ fun parseResourceBundleOrNull(json: String): FhirElementDatabase? {
|
||||
Log.e("RenderEyeGlassesPrescription", "Parser error", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
|
||||
|
||||
/**
|
||||
* A candidate group channel when routing a stray group-scoped content event: its [key] and whether it is a
|
||||
* confirmed host (has received relay-signed state). See [redirectStrayRelayGroupContent].
|
||||
*/
|
||||
data class RelayGroupTargetCandidate(
|
||||
val key: GroupId,
|
||||
val hasRelaySignedState: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* Resolves the **serving-relay hazard**. A group-scoped content event (kind-9 chat, poll, kind-11 thread…)
|
||||
* is keyed to its group channel by the relay that served it, because a NIP-29 event doesn't carry its host
|
||||
* relay. That is correct for the group's own host-pinned subscriptions, but a message resolved from a
|
||||
* **non-host** relay — e.g. a quoted kind-9 fetched by id during missing-event resolution — would be filed
|
||||
* under a channel keyed to that stranger relay, one the group's own screens never read, so the message
|
||||
* silently vanishes.
|
||||
*
|
||||
* Called only when there is **no** channel keyed to the serving relay for this group id (the fast, common
|
||||
* path attaches directly and never gets here). It picks the group's single confirmed **host** channel — one
|
||||
* that has received relay-signed state — to attach the stray to instead. Returns that host key, or null when
|
||||
* there is no single confirmed host (a genuinely new group on the serving relay, or an id ambiguous across
|
||||
* several hosts), in which case the caller keeps the serving-relay key as today's best effort.
|
||||
*
|
||||
* A phantom channel (one minted from an earlier stray) never has relay-signed state, so it can never be
|
||||
* chosen here — the redirect only ever lands on a real host, never on another phantom. This makes the fix
|
||||
* strictly safe: it can redirect a stray to a known host, but never divert a message away from one.
|
||||
*/
|
||||
fun redirectStrayRelayGroupContent(candidates: List<RelayGroupTargetCandidate>): GroupId? = candidates.filter { it.hasRelaySignedState }.singleOrNull()?.key
|
||||
@@ -22,6 +22,10 @@ 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
|
||||
@@ -66,9 +70,16 @@ class AccountCacheState(
|
||||
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]
|
||||
@@ -179,6 +190,22 @@ 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),
|
||||
@@ -258,6 +285,8 @@ class AccountCacheState(
|
||||
marmotKeyPackageStore = marmotKeyPackageStore,
|
||||
powQueue = powQueue,
|
||||
relayAuthPermissionStore = relayAuthPermissionStore,
|
||||
signerPermissionStore = signerPermissionStore,
|
||||
nip46ClientStore = nip46ClientStore,
|
||||
).also { newAccount ->
|
||||
accounts.update { existingAccounts ->
|
||||
existingAccounts.plus(Pair(signer.pubKey, newAccount))
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* 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) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
/*
|
||||
* 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.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) + "…"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
/*
|
||||
* 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
|
||||
}
|
||||
}
|
||||
@@ -120,12 +120,26 @@ class BlossomServerListState(
|
||||
hash: HexKey,
|
||||
size: Long,
|
||||
alt: String,
|
||||
): BlossomAuthorizationEvent = BlossomAuthorizationEvent.createUploadAuth(hash, size, alt, signer)
|
||||
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)
|
||||
|
||||
suspend fun createBlossomDeleteAuth(
|
||||
hash: HexKey,
|
||||
alt: String,
|
||||
): BlossomAuthorizationEvent = BlossomAuthorizationEvent.createDeleteAuth(hash, alt, signer)
|
||||
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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -74,7 +74,9 @@ class FeedTopNavFilterState(
|
||||
) {
|
||||
fun loadFlowsFor(listName: TopFilter): IFeedFlowsType =
|
||||
when (listName) {
|
||||
TopFilter.Global, TopFilter.Selected -> {
|
||||
// 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 -> {
|
||||
GlobalFeedFlow(followsRelays, proxyRelays, relayFeeds)
|
||||
}
|
||||
|
||||
|
||||
@@ -41,12 +41,21 @@ private val Context.nappletPermissionsDataStore by preferencesDataStore(name = "
|
||||
*/
|
||||
class DataStoreNappletPermissionStore(
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
private val accountPubKey: () -> String,
|
||||
) : NappletPermissionStore {
|
||||
constructor(context: Context) : this(context.applicationContext.nappletPermissionsDataStore)
|
||||
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"
|
||||
|
||||
override suspend fun load(coordinate: String): Map<NappletCapability, GrantState> {
|
||||
val prefs = dataStore.data.first()
|
||||
val prefix = "$coordinate$SEP"
|
||||
val prefix = "${scoped(coordinate)}$SEP"
|
||||
val result = mutableMapOf<NappletCapability, GrantState>()
|
||||
for ((key, value) in prefs.asMap()) {
|
||||
val name = key.name
|
||||
@@ -68,7 +77,7 @@ class DataStoreNappletPermissionStore(
|
||||
}
|
||||
|
||||
override suspend fun clear(coordinate: String) {
|
||||
val prefix = "$coordinate$SEP"
|
||||
val prefix = "${scoped(coordinate)}$SEP"
|
||||
dataStore.edit { prefs ->
|
||||
val toRemove = prefs.asMap().keys.filter { it.name.startsWith(prefix) }
|
||||
toRemove.forEach { prefs.remove(it) }
|
||||
@@ -78,11 +87,15 @@ 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 "<coordinate> <CAPABILITY>"; the capability is the final space-delimited token.
|
||||
val capName = name.substringAfterLast(SEP, "")
|
||||
val coordinate = name.substringBeforeLast(SEP, "")
|
||||
// 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, "")
|
||||
if (capName.isEmpty() || coordinate.isEmpty()) continue
|
||||
val capability = runCatching { NappletCapability.valueOf(capName) }.getOrNull() ?: continue
|
||||
val grant = runCatching { GrantState.valueOf(value as String) }.getOrNull() ?: continue
|
||||
@@ -103,7 +116,7 @@ class DataStoreNappletPermissionStore(
|
||||
private fun keyOf(
|
||||
coordinate: String,
|
||||
capability: NappletCapability,
|
||||
) = stringPreferencesKey("$coordinate$SEP${capability.name}")
|
||||
) = stringPreferencesKey("${scoped(coordinate)}$SEP${capability.name}")
|
||||
|
||||
companion object {
|
||||
private const val SEP = "\u0000"
|
||||
|
||||
@@ -32,14 +32,20 @@ import kotlinx.coroutines.flow.first
|
||||
private val Context.nappletStorageDataStore by preferencesDataStore(name = "napplet_storage")
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
class DataStoreNappletStorage(
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
private val accountPubKey: () -> String,
|
||||
) : NappletStorage {
|
||||
constructor(context: Context) : this(context.applicationContext.nappletStorageDataStore)
|
||||
constructor(context: Context, accountPubKey: () -> String) :
|
||||
this(context.applicationContext.nappletStorageDataStore, accountPubKey)
|
||||
|
||||
override suspend fun get(
|
||||
coordinate: String,
|
||||
@@ -62,7 +68,9 @@ class DataStoreNappletStorage(
|
||||
}
|
||||
|
||||
override suspend fun keys(coordinate: String): List<String> {
|
||||
val prefix = "$coordinate "
|
||||
// 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)
|
||||
return dataStore.data
|
||||
.first()
|
||||
.asMap()
|
||||
@@ -72,8 +80,11 @@ 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("$coordinate\u0000$key")
|
||||
) = stringPreferencesKey(prefixOf(coordinate) + key)
|
||||
}
|
||||
|
||||
@@ -34,15 +34,14 @@ 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
|
||||
@@ -50,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.amethyst.ui.screen.AccountState
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -78,32 +77,37 @@ import kotlinx.coroutines.launch
|
||||
class NappletBrokerService : Service() {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
// One ledger for the whole service lifetime: persistent grants on disk, session grants in RAM.
|
||||
private val ledger by lazy { NappletPermissionLedger(Amethyst.instance.nappletPermissionStore) }
|
||||
// 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
|
||||
|
||||
// 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 coordinate inside the impl).
|
||||
private val storage by lazy { DataStoreNappletStorage(applicationContext) }
|
||||
// Per-applet sandboxed key-value store (namespaced by account + coordinate inside the impl).
|
||||
private val storage by lazy { DataStoreNappletStorage(applicationContext, Amethyst.instance.nappletAccountScope) }
|
||||
|
||||
private val incoming by lazy { Messenger(Handler(Looper.getMainLooper(), ::handleMessage)) }
|
||||
|
||||
// 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() }
|
||||
// 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 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 pushes (account switch / connect / disconnect) to a watching applet.
|
||||
// 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 "".
|
||||
private val identityWatch =
|
||||
NappletIdentityWatch(scope) {
|
||||
Amethyst.instance.sessionManager.accountContent
|
||||
.map { (it as? AccountState.LoggedIn)?.account?.signer?.pubKey ?: "" }
|
||||
NappletIdentityWatch(scope) { boundPubKey ->
|
||||
Amethyst.instance.accountsCache.accounts
|
||||
.map { loaded -> if (loaded.containsKey(boundPubKey)) boundPubKey else "" }
|
||||
}
|
||||
|
||||
// Binding is restricted to our own UID by exported=false in the manifest, enforced by the OS.
|
||||
@@ -114,6 +118,13 @@ 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() }
|
||||
@@ -241,7 +252,10 @@ 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)
|
||||
val token = NappletLaunchRegistry.register(identity, setOf(NappletCapability.IDENTITY, NappletCapability.RELAY))
|
||||
// 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 response =
|
||||
Message.obtain(null, NappletIpc.MSG_BROWSER_TOKEN).apply {
|
||||
this.data =
|
||||
@@ -278,17 +292,20 @@ 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.
|
||||
val broker = broker()
|
||||
// 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)
|
||||
if (broker == null) {
|
||||
reply(replyTo, requestId, NappletProtocolJson.encodeResponse(requestType, NappletResponse.Failed("No account is signed in.")))
|
||||
reply(replyTo, requestId, NappletProtocolJson.encodeResponse(requestType, NappletResponse.Failed("That account is no longer 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) { push(replyTo, it) }
|
||||
is NappletRequestRouter.Outcome.OpenSubscription ->
|
||||
liveSubscriptions.open(outcome.subId, outcome.filters, accountFor(session.accountPubKey)) { push(replyTo, it) }
|
||||
is NappletRequestRouter.Outcome.CloseSubscription -> liveSubscriptions.close(outcome.subId)
|
||||
is NappletRequestRouter.Outcome.WatchIdentity -> identityWatch.start { push(replyTo, it) }
|
||||
is NappletRequestRouter.Outcome.WatchIdentity -> identityWatch.start(session.accountPubKey) { 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)
|
||||
@@ -327,28 +344,44 @@ 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 *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.
|
||||
* 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.
|
||||
*/
|
||||
@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
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -403,6 +436,38 @@ 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
|
||||
|
||||
@@ -23,15 +23,19 @@ 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
|
||||
@@ -41,11 +45,18 @@ 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
|
||||
@@ -54,6 +65,7 @@ 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
|
||||
|
||||
/**
|
||||
@@ -168,20 +180,74 @@ private fun NappletConsentDialog(
|
||||
)
|
||||
}
|
||||
|
||||
// Operation detail box (may include content preview)
|
||||
if (info.operationSummary.isNotBlank()) {
|
||||
// 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()) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Surface(
|
||||
modifier = Modifier.padding(horizontal = 24.dp).fillMaxWidth(),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
) {
|
||||
SelectionContainer {
|
||||
Text(
|
||||
info.operationSummary,
|
||||
modifier = Modifier.padding(12.dp),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,26 @@ 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?,
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -21,22 +21,35 @@
|
||||
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; holds only a [Context], no account state.
|
||||
* 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.
|
||||
*/
|
||||
class NappletConsentSummary(
|
||||
private val context: Context,
|
||||
private val account: Account,
|
||||
) {
|
||||
fun info(
|
||||
identity: NappletIdentity,
|
||||
@@ -51,16 +64,196 @@ 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 = summaryFor(request),
|
||||
operationSummary = listOfNotNull(summaryFor(request).ifBlank { null }, consequence?.text).joinToString("\n\n"),
|
||||
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)
|
||||
@@ -91,11 +284,14 @@ 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) {
|
||||
if (sats != null && sats > 0) {
|
||||
pluralStringRes(context, R.plurals.napplet_consent_pay_amount, sats.toInt(), sats)
|
||||
} else {
|
||||
context.getString(R.string.napplet_consent_pay)
|
||||
context.getString(R.string.napplet_consent_pay_no_amount)
|
||||
}
|
||||
}
|
||||
is NappletRequest.ResourceBytes -> context.getString(R.string.napplet_consent_resource)
|
||||
|
||||
@@ -39,15 +39,18 @@ import kotlinx.coroutines.launch
|
||||
*/
|
||||
class NappletIdentityWatch(
|
||||
private val scope: CoroutineScope,
|
||||
private val pubKey: () -> Flow<String>,
|
||||
private val pubKey: (boundPubKey: String) -> Flow<String>,
|
||||
) {
|
||||
private var job: Job? = null
|
||||
|
||||
fun start(push: (String) -> Unit) {
|
||||
fun start(
|
||||
boundPubKey: String,
|
||||
push: (String) -> Unit,
|
||||
) {
|
||||
stop()
|
||||
job =
|
||||
scope.launch {
|
||||
pubKey()
|
||||
pubKey(boundPubKey)
|
||||
.distinctUntilChanged()
|
||||
.drop(1)
|
||||
.collect { push(NappletProtocolJson.encodeIdentityChanged(it)) }
|
||||
|
||||
@@ -22,6 +22,7 @@ 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
|
||||
|
||||
@@ -45,6 +46,18 @@ 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
|
||||
@@ -60,9 +73,10 @@ 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)
|
||||
sessions[token] = Session(identity, declared, accountPubKey)
|
||||
return token
|
||||
}
|
||||
|
||||
|
||||
@@ -120,7 +120,16 @@ 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)
|
||||
val launchToken = NappletLaunchRegistry.register(identity, declared)
|
||||
// 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)
|
||||
|
||||
// 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.
|
||||
@@ -156,6 +165,9 @@ 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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,12 +38,13 @@ 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.
|
||||
*
|
||||
* [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).
|
||||
* 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).
|
||||
*/
|
||||
class NappletLiveSubscriptions(
|
||||
private val account: () -> Account?,
|
||||
) {
|
||||
class NappletLiveSubscriptions {
|
||||
private val liveSubs = ConcurrentHashMap<String, LiveSub>()
|
||||
private val liveSeq = AtomicInteger(0)
|
||||
|
||||
@@ -62,9 +63,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))
|
||||
|
||||
@@ -1,325 +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 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,94 +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 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"
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* 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,13 +23,20 @@ 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.commons.napplet.signers.NostrSignerOp
|
||||
import com.vitorpamplona.amethyst.connectedApps.consent.SignerConnectInfo
|
||||
import com.vitorpamplona.amethyst.connectedApps.consent.SignerConsentInfo
|
||||
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]. */
|
||||
@@ -38,15 +45,31 @@ 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))
|
||||
}
|
||||
|
||||
/** Builds the [NappletSignerConsentInfo] needed by the per-op consent dialog. */
|
||||
/**
|
||||
* 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. */
|
||||
fun buildSignerConsentInfo(
|
||||
context: Context,
|
||||
identity: NappletIdentity,
|
||||
op: NostrSignerOp,
|
||||
request: NappletRequest,
|
||||
): NappletSignerConsentInfo {
|
||||
): SignerConsentInfo {
|
||||
val untitled = context.getString(R.string.napplet_fallback_title, identity.authorPubKey.take(8))
|
||||
val (title, iconUrl) =
|
||||
if (identity.authorPubKey == "browser") {
|
||||
@@ -84,7 +107,13 @@ fun buildSignerConsentInfo(
|
||||
}
|
||||
else -> ""
|
||||
}
|
||||
return NappletSignerConsentInfo(
|
||||
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(
|
||||
appletTitle = title,
|
||||
coordinate = identity.coordinate,
|
||||
op = op,
|
||||
@@ -92,14 +121,21 @@ fun buildSignerConsentInfo(
|
||||
contentPreview = preview,
|
||||
rawData = rawData,
|
||||
iconUrl = iconUrl,
|
||||
previewTemplate = previewTemplate,
|
||||
)
|
||||
}
|
||||
|
||||
/** Creates a [NappletConnectInfo] for the first-connect dialog. */
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
fun buildConnectInfo(
|
||||
context: Context,
|
||||
identity: NappletIdentity,
|
||||
): NappletConnectInfo {
|
||||
declared: Set<NappletCapability> = emptySet(),
|
||||
): SignerConnectInfo {
|
||||
val untitled = context.getString(R.string.napplet_fallback_title, identity.authorPubKey.take(8))
|
||||
val (title, iconUrl) =
|
||||
if (identity.authorPubKey == "browser") {
|
||||
@@ -114,5 +150,18 @@ fun buildConnectInfo(
|
||||
} else {
|
||||
identity.identifier.ifBlank { identity.authorPubKey.take(12) + "…" }
|
||||
}
|
||||
return NappletConnectInfo(appletTitle = title, coordinate = identity.coordinate, domain = domain, iconUrl = iconUrl)
|
||||
// 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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -27,6 +27,9 @@ 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
|
||||
@@ -41,15 +44,12 @@ 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.commons.napplet.signers.NostrConnectPrompt
|
||||
import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerConsentPrompt
|
||||
import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerPermissionLedger
|
||||
import com.vitorpamplona.amethyst.connectedApps.consent.SignerConnectCoordinator
|
||||
import com.vitorpamplona.amethyst.connectedApps.consent.SignerConsentCoordinator
|
||||
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,7 +81,9 @@ class AccountNappletGateways(
|
||||
private val httpClient: (useProxy: Boolean) -> OkHttpClient,
|
||||
private val signerLedger: NostrSignerPermissionLedger? = null,
|
||||
) {
|
||||
private val consentSummary = NappletConsentSummary(context)
|
||||
// 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)
|
||||
|
||||
// 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
|
||||
@@ -138,16 +140,16 @@ class AccountNappletGateways(
|
||||
}
|
||||
|
||||
val connectPrompt =
|
||||
NostrConnectPrompt { identity ->
|
||||
NappletConnectCoordinator.requestConnect(
|
||||
NostrConnectPrompt { identity, declared ->
|
||||
SignerConnectCoordinator.requestConnect(
|
||||
context = context,
|
||||
info = buildConnectInfo(context, identity),
|
||||
info = buildConnectInfo(context, identity, declared),
|
||||
)
|
||||
}
|
||||
|
||||
val signerConsent =
|
||||
NostrSignerConsentPrompt { identity, op, request ->
|
||||
NappletSignerConsentCoordinator.requestConsent(
|
||||
SignerConsentCoordinator.requestConsent(
|
||||
context = context,
|
||||
info = buildSignerConsentInfo(context, identity, op, request),
|
||||
)
|
||||
|
||||
@@ -26,6 +26,7 @@ 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
|
||||
@@ -45,14 +46,23 @@ 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 ops = CashuMintOperations(MintHttpClient(token.mint, okHttpClient))
|
||||
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 proofs = token.proofs
|
||||
|
||||
// A Lightning address must commit to an amount before we know the
|
||||
@@ -106,6 +116,14 @@ 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),
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
/*
|
||||
* 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/*
|
||||
* 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -53,8 +53,10 @@ import kotlinx.coroutines.launch
|
||||
* this is the battery-saver "airplane mode". Persisted, so an explicit off survives
|
||||
* restarts and crashes.
|
||||
* - The **per-account participation** flag ([com.vitorpamplona.amethyst.model.AccountSettings.alwaysOnNotificationService],
|
||||
* "Keep this account active in the background"). While the master is on, the service
|
||||
* runs as long as **at least one** writable account participates.
|
||||
* "Keep this account active in the background") **or** its NIP-46 signer toggle
|
||||
* ([com.vitorpamplona.amethyst.model.AccountSettings.nip46SignerEnabled]). While the master is
|
||||
* on, the service runs as long as **at least one** writable account has either flag on — the
|
||||
* background signer relies on the same foreground service to keep answering requests.
|
||||
*
|
||||
* While the master is on, every saved writable account is kept loaded in
|
||||
* [AccountCacheState] so (a) its participation flag is observable and (b) GiftWraps
|
||||
@@ -83,6 +85,11 @@ class AlwaysOnNotificationServiceManager(
|
||||
* loaded writable account. The service layers run while the master is on AND at
|
||||
* least one account participates; the master overrides everything when off.
|
||||
*
|
||||
* An account "participates" when either its always-on setting **or** its NIP-46
|
||||
* signer toggle is on: the foreground service (and its restart layers) keep the
|
||||
* process + shared relay client alive, which is exactly what the background signer
|
||||
* needs to keep answering requests, so the signer toggle keeps the layers up too.
|
||||
*
|
||||
* Idempotent: safe to call again on account switch/login — it restarts the watch.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@@ -105,11 +112,17 @@ class AlwaysOnNotificationServiceManager(
|
||||
|
||||
// Master on: keep every writable account loaded so its participation
|
||||
// flag is observable and its gift wraps can decrypt, then run the
|
||||
// service only while at least one account is participating.
|
||||
// service only while at least one account is participating. An account
|
||||
// participates when its always-on setting OR its NIP-46 signer toggle is
|
||||
// on — the background signer needs the same foreground service alive.
|
||||
startMultiAccountPreload()
|
||||
accountsCache.accounts
|
||||
.flatMapLatest { accounts ->
|
||||
val flags = accounts.values.map { it.settings.alwaysOnNotificationService }
|
||||
val flags =
|
||||
accounts.values.map { account ->
|
||||
account.settings.alwaysOnNotificationService
|
||||
.combine(account.settings.nip46SignerEnabled) { alwaysOn, signer -> alwaysOn || signer }
|
||||
}
|
||||
if (flags.isEmpty()) {
|
||||
flowOf(false)
|
||||
} else {
|
||||
|
||||
@@ -50,11 +50,23 @@ class BootCompletedReceiver : BroadcastReceiver() {
|
||||
"android.intent.action.QUICKBOOT_POWERON",
|
||||
Intent.ACTION_MY_PACKAGE_REPLACED,
|
||||
-> {
|
||||
Log.d(TAG) { "Received ${intent.action}, checking if notification service should start" }
|
||||
if (NotificationRelayService.isEnabled(context)) {
|
||||
Log.d(TAG, "Starting notification relay service")
|
||||
NotificationRelayService.start(context)
|
||||
}
|
||||
// isEnabled() reads plain SharedPreferences (a synchronous disk read, by
|
||||
// design — see LocalPreferences.globalSettingsPrefs) and scans the accounts
|
||||
// cache. onReceive runs on the main thread, so hop off it via goAsync() to
|
||||
// avoid a StrictMode DiskReadViolation while keeping the receiver alive until
|
||||
// the work finishes.
|
||||
val pending = goAsync()
|
||||
Thread {
|
||||
try {
|
||||
Log.d(TAG) { "Received ${intent.action}, checking if notification service should start" }
|
||||
if (NotificationRelayService.isEnabled(context)) {
|
||||
Log.d(TAG, "Starting notification relay service")
|
||||
NotificationRelayService.start(context)
|
||||
}
|
||||
} finally {
|
||||
pending.finish()
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,9 +126,11 @@ class NotificationRelayService : Service() {
|
||||
// the service itself once a participant exists.
|
||||
fun isEnabled(context: Context): Boolean =
|
||||
try {
|
||||
// The service also backs the NIP-46 signer, so an account with the signer on
|
||||
// participates just like one with always-on notifications on.
|
||||
LocalPreferences.isNotificationServiceEnabled() &&
|
||||
Amethyst.instance.accountsCache.accounts.value.values.any {
|
||||
it.settings.alwaysOnNotificationService.value
|
||||
it.settings.alwaysOnNotificationService.value || it.settings.nip46SignerEnabled.value
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
|
||||
@@ -20,34 +20,17 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.pow
|
||||
|
||||
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.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
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.service.pow.PoWEstimator
|
||||
import com.vitorpamplona.amethyst.commons.service.pow.PoWJobState
|
||||
import com.vitorpamplona.amethyst.ui.MainActivity
|
||||
import com.vitorpamplona.amethyst.service.foreground.FlowProgressForegroundService
|
||||
import com.vitorpamplona.amethyst.ui.pluralStringRes
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
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.launch
|
||||
|
||||
/**
|
||||
@@ -56,161 +39,72 @@ import kotlinx.coroutines.launch
|
||||
* finish mining even after the user backgrounds the app.
|
||||
*
|
||||
* Uses the Android 14+ `shortService` type — no special permission, but a
|
||||
* hard ~3 minute budget. On [onTimeout] the service exits cleanly; every
|
||||
* hard ~3 minute budget. On `onTimeout` the service exits cleanly; every
|
||||
* persistable job is already checkpointed by [PowJobStore], so anything still
|
||||
* unmined resumes on the next app launch. Started on every enqueue (the app
|
||||
* is necessarily in the foreground then), stops itself when the queue drains.
|
||||
*
|
||||
* The notification is a live progress card ([NotificationCompat.ProgressStyle]):
|
||||
* one track segment per post, filling as jobs complete, indeterminate while a
|
||||
* single post mines, with a cancel-all action. On Android 16+ it renders as a
|
||||
* Live Updates chip; older versions fall back to a standard progress bar.
|
||||
* The notification card (a live [androidx.core.app.NotificationCompat.ProgressStyle]) and all the
|
||||
* service lifecycle live in [FlowProgressForegroundService]; this subclass only maps mining state
|
||||
* to that card.
|
||||
*/
|
||||
class PowMiningForegroundService : Service() {
|
||||
private val scope = CoroutineScope(Dispatchers.Main.immediate + SupervisorJob())
|
||||
private var watchJob: Job? = null
|
||||
class PowMiningForegroundService : FlowProgressForegroundService<ImmutableList<PoWJobState>>() {
|
||||
override val fgsType: Int = ServiceInfo.FOREGROUND_SERVICE_TYPE_SHORT_SERVICE
|
||||
override val channelId = CHANNEL_ID
|
||||
override val channelNameRes = R.string.pow_notification_channel_name
|
||||
override val channelDescRes = R.string.pow_notification_channel_description
|
||||
override val notificationId = NOTIFICATION_ID
|
||||
override val cancelAction = ACTION_CANCEL_ALL
|
||||
override val cancelLabelRes = R.string.pow_notification_cancel_all
|
||||
|
||||
// Session totals so the progress track can show "done / enqueued since the
|
||||
// service started" — the queue itself only knows what is still pending.
|
||||
// clock-driven refresh for the time-left text and bar; the shortService budget (~3 min)
|
||||
// caps this at a handful of updates.
|
||||
override val refreshMs: Long = PROGRESS_REFRESH_MS
|
||||
|
||||
// Session totals so the progress track can show "done / enqueued since the service
|
||||
// started" — the queue itself only knows what is still pending.
|
||||
private var sessionTotal = 0
|
||||
private var lastQueueSize = 0
|
||||
|
||||
// Benchmarked once per service run (~250 ms, cached by the estimator);
|
||||
// read from the notification builder to compute expected durations.
|
||||
// Benchmarked once per service run (~250 ms, cached by the estimator).
|
||||
@Volatile
|
||||
private var hashRate: Double? = null
|
||||
|
||||
// Built once per service instance: the intents never change, and
|
||||
// buildNotification runs on every queue update.
|
||||
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, PowMiningForegroundService::class.java).setAction(ACTION_CANCEL_ALL),
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
running = true
|
||||
}
|
||||
|
||||
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(currentJobs()) }
|
||||
.onFailure {
|
||||
Log.w(TAG, "startForeground failed; mining continues without the service", it)
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
if (intent?.action == ACTION_CANCEL_ALL) {
|
||||
Amethyst.instance.powPublishQueue.cancelAll()
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
watchQueue()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
/**
|
||||
* The shortService budget (~3 min) is exhausted. Exit before the system
|
||||
* ANRs us: persisted jobs are checkpointed and resume on next launch;
|
||||
* in-memory jobs keep mining opportunistically until the process freezes.
|
||||
*/
|
||||
override fun onTimeout(startId: Int) {
|
||||
Log.d(TAG) { "shortService budget exhausted; ${currentJobs().size} job(s) left to resume later" }
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
running = false
|
||||
scope.cancel()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun currentJobs(): ImmutableList<PoWJobState> = Amethyst.instance.powPublishQueue.jobs.value
|
||||
override fun state() = Amethyst.instance.powPublishQueue.jobs
|
||||
|
||||
private fun watchQueue() {
|
||||
if (watchJob != null) return
|
||||
watchJob =
|
||||
scope.launch {
|
||||
Amethyst.instance.powPublishQueue.jobs.collect { jobs ->
|
||||
if (jobs.size > lastQueueSize) sessionTotal += jobs.size - lastQueueSize
|
||||
lastQueueSize = jobs.size
|
||||
override fun isActive(value: ImmutableList<PoWJobState>) = value.isNotEmpty()
|
||||
|
||||
if (jobs.isEmpty()) {
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
} else {
|
||||
updateNotification(jobs)
|
||||
}
|
||||
}
|
||||
}
|
||||
override fun cancelAll() = Amethyst.instance.powPublishQueue.cancelAll()
|
||||
|
||||
// the estimated-time-left figure and progress fraction only move with
|
||||
// the clock, not with queue events: benchmark the hash rate once,
|
||||
// then refresh the card periodically while something is mining.
|
||||
scope.launch {
|
||||
hashRate = PoWEstimator.hashesPerSecond()
|
||||
while (true) {
|
||||
val jobs = currentJobs()
|
||||
if (jobs.any { it.isMining }) updateNotification(jobs)
|
||||
delay(PROGRESS_REFRESH_MS)
|
||||
}
|
||||
}
|
||||
override fun needsClockRefresh(value: ImmutableList<PoWJobState>) = value.any { it.isMining }
|
||||
|
||||
override fun onStarted() {
|
||||
scope.launch { hashRate = PoWEstimator.hashesPerSecond() }
|
||||
}
|
||||
|
||||
private fun startForegroundCompat(jobs: ImmutableList<PoWJobState>) {
|
||||
ensureChannel(this)
|
||||
val notification = buildNotification(jobs)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_SHORT_SERVICE)
|
||||
} else {
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
}
|
||||
override fun onEmission(value: ImmutableList<PoWJobState>) {
|
||||
if (value.size > lastQueueSize) sessionTotal += value.size - lastQueueSize
|
||||
lastQueueSize = value.size
|
||||
}
|
||||
|
||||
private fun updateNotification(jobs: ImmutableList<PoWJobState>) {
|
||||
val manager = NotificationManagerCompat.from(this)
|
||||
if (!manager.areNotificationsEnabled()) return
|
||||
try {
|
||||
manager.notify(NOTIFICATION_ID, buildNotification(jobs))
|
||||
} catch (_: SecurityException) {
|
||||
// POST_NOTIFICATIONS revoked mid-flight; the FGS keeps running.
|
||||
}
|
||||
}
|
||||
override fun render(value: ImmutableList<PoWJobState>): Content {
|
||||
val done = (sessionTotal - value.size).coerceAtLeast(0)
|
||||
val total = (done + value.size).coerceAtLeast(1)
|
||||
|
||||
private fun buildNotification(jobs: ImmutableList<PoWJobState>): Notification {
|
||||
val done = (sessionTotal - jobs.size).coerceAtLeast(0)
|
||||
val total = (done + jobs.size).coerceAtLeast(1)
|
||||
val current = value.firstOrNull { it.isMining } ?: value.firstOrNull()
|
||||
|
||||
val current = jobs.firstOrNull { it.isMining } ?: jobs.firstOrNull()
|
||||
|
||||
// expected duration for the job being mined right now, so the card can
|
||||
// say "≈ 10 minutes left" and fill its bar toward a predictable end.
|
||||
// expected duration for the job being mined right now, so the card can say
|
||||
// "≈ 10 minutes left" and fill its bar toward a predictable end.
|
||||
val rate = hashRate
|
||||
val startedAt = current?.miningStartedAt
|
||||
val expectedSec = if (current != null && rate != null) PoWEstimator.estimateSeconds(current.difficulty, rate) else null
|
||||
@@ -229,44 +123,23 @@ class PowMiningForegroundService : Service() {
|
||||
|
||||
val fraction = if (expectedSec != null && elapsedSec != null) elapsedSec / expectedSec else null
|
||||
|
||||
val progressStyle: NotificationCompat.ProgressStyle =
|
||||
if (total <= 1) {
|
||||
// single post: fill toward the estimated duration; past the
|
||||
// mean the search is memoryless, so sweep instead of lying.
|
||||
if (fraction != null && fraction < 1.0) {
|
||||
NotificationCompat
|
||||
.ProgressStyle()
|
||||
.setProgressSegments(listOf(NotificationCompat.ProgressStyle.Segment(100)))
|
||||
.setProgress((fraction * 100).toInt())
|
||||
} else {
|
||||
NotificationCompat.ProgressStyle().setProgressIndeterminate(true)
|
||||
}
|
||||
val title =
|
||||
if (value.size > 1) {
|
||||
pluralStringRes(this, R.plurals.pow_mining_progress, value.size, value.size)
|
||||
} else {
|
||||
NotificationCompat
|
||||
.ProgressStyle()
|
||||
.setProgressSegments(List(total) { NotificationCompat.ProgressStyle.Segment(1) })
|
||||
.setProgress(done)
|
||||
stringRes(this, R.string.pow_mining_title)
|
||||
}
|
||||
|
||||
return NotificationCompat
|
||||
.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.amethyst)
|
||||
.setContentTitle(
|
||||
if (jobs.size > 1) {
|
||||
pluralStringRes(this, R.plurals.pow_mining_progress, jobs.size, jobs.size)
|
||||
} else {
|
||||
stringRes(this, R.string.pow_mining_title)
|
||||
},
|
||||
).setContentText(text)
|
||||
.setStyle(progressStyle)
|
||||
.setContentIntent(tapIntent)
|
||||
.addAction(0, stringRes(this, R.string.pow_notification_cancel_all), cancelIntent)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
|
||||
.build()
|
||||
val bar =
|
||||
if (total <= 1) {
|
||||
// single post: fill toward the estimated duration; past the mean the search is
|
||||
// memoryless, so sweep instead of lying.
|
||||
if (fraction != null && fraction < 1.0) Bar.Determinate(fraction) else Bar.Indeterminate
|
||||
} else {
|
||||
Bar.Segmented(total, done)
|
||||
}
|
||||
|
||||
return Content(title, text, bar)
|
||||
}
|
||||
|
||||
companion object {
|
||||
@@ -275,45 +148,19 @@ class PowMiningForegroundService : Service() {
|
||||
private const val NOTIFICATION_ID = 0x504F57 // "POW"
|
||||
private const val ACTION_CANCEL_ALL = "com.vitorpamplona.amethyst.pow.CANCEL_ALL"
|
||||
|
||||
// clock-driven refresh cadence for the time-left text and bar; the
|
||||
// shortService budget (~3 min) caps this at a handful of updates.
|
||||
private const val PROGRESS_REFRESH_MS = 30_000L
|
||||
|
||||
// Best-effort de-dup for start(): the queue calls it on EVERY enqueue,
|
||||
// and each call otherwise round-trips through system_server. A stale
|
||||
// false only costs one redundant startForegroundService (which Android
|
||||
// routes to the existing instance's onStartCommand anyway).
|
||||
// Best-effort de-dup for start(): the queue calls it on EVERY enqueue.
|
||||
@Volatile
|
||||
private var running = false
|
||||
|
||||
/**
|
||||
* Best-effort start: enqueue happens while the user is interacting
|
||||
* with the app, so the foreground-start allowance normally holds. A
|
||||
* restore during a cold background launch may be denied — mining then
|
||||
* proceeds unprotected and the service starts on the next enqueue.
|
||||
* Best-effort start: enqueue happens while the user is interacting with the app,
|
||||
* so the foreground-start allowance normally holds.
|
||||
*/
|
||||
fun start(context: Context) {
|
||||
if (running) return
|
||||
try {
|
||||
context.startForegroundService(Intent(context, PowMiningForegroundService::class.java))
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Could not start mining foreground service (backgrounded?); mining continues unprotected", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureChannel(context: Context) {
|
||||
val manager = context.getSystemService(NOTIFICATION_SERVICE) as NotificationManager
|
||||
if (manager.getNotificationChannel(CHANNEL_ID) != null) return
|
||||
manager.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
stringRes(context, R.string.pow_notification_channel_name),
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
).apply {
|
||||
description = stringRes(context, R.string.pow_notification_channel_description)
|
||||
setShowBadge(false)
|
||||
},
|
||||
)
|
||||
FlowProgressForegroundService.start(context, PowMiningForegroundService::class.java, TAG)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.relayClient
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.tor.TorRelaySettings
|
||||
import com.vitorpamplona.amethyst.model.torState.TorRelayEvaluation
|
||||
import com.vitorpamplona.amethyst.service.connectivity.ConnectivityStatus
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorServiceStatus
|
||||
@@ -63,6 +64,27 @@ class RelayProxyClientConnector(
|
||||
private var lastTorConnection: OkHttpClient? = null
|
||||
private var lastClearConnection: OkHttpClient? = null
|
||||
|
||||
// The network we were last on. The OkHttp clients above are rebuilt off the metered
|
||||
// bit, so they only tell us about wifi<->cellular; they say nothing about wifi A ->
|
||||
// wifi B, a VPN coming up, or a captive portal clearing. Those all mint a new
|
||||
// networkHandle, and after them every existing socket is bound to an interface that
|
||||
// is gone and every accumulated backoff was earned against a network we have left.
|
||||
private var lastNetworkId: Long? = null
|
||||
|
||||
// The user's Tor preferences. TorRelayEvaluation has no equals() and a fresh instance is
|
||||
// emitted on unrelated churn, so we track the settings by value. Without this, flipping a
|
||||
// Tor toggle while Tor is already up leaves both OkHttpClient references identical ->
|
||||
// transportChanged=false -> a relay parked on a 5-minute backoff keeps waiting it out on
|
||||
// a transport it is no longer using.
|
||||
//
|
||||
// Deliberately only [TorRelaySettings], not the trusted/DM/money relay sets that
|
||||
// TorRelayEvaluation also carries: those churn constantly while an account's relay lists
|
||||
// load from the network (observed firing three times during a single cold start). A relay
|
||||
// moving between classifications can flip its transport too, but forgiving the whole
|
||||
// pool's backoff every time any relay list updates is far more damage than making that
|
||||
// one relay serve out its delay.
|
||||
private var lastTorSettings: TorRelaySettings? = null
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
val relayServices =
|
||||
combine(
|
||||
@@ -74,51 +96,8 @@ class RelayProxyClientConnector(
|
||||
) { torSettings, torConnection, clearConnection, connectivity, torStatus ->
|
||||
RelayServiceInfra(torSettings, torConnection, clearConnection, connectivity, torStatus)
|
||||
}.debounce(100)
|
||||
.onEach {
|
||||
when {
|
||||
it.connectivity is ConnectivityStatus.StartingService -> {
|
||||
// ignore
|
||||
}
|
||||
|
||||
it.connectivity is ConnectivityStatus.Off -> {
|
||||
Log.d("ManageRelayServices") { "Connectivity Off: Pausing Relay Services ${it.connectivity}" }
|
||||
if (client.isActive()) {
|
||||
client.disconnect()
|
||||
}
|
||||
if (it.torStatus is TorServiceStatus.Active) {
|
||||
Log.d("ManageRelayServices", "Connectivity off, Tor idle")
|
||||
}
|
||||
}
|
||||
|
||||
it.connectivity is ConnectivityStatus.Active && !client.isActive() -> {
|
||||
Log.d("ManageRelayServices", "Connectivity On: Resuming Relay Services")
|
||||
|
||||
if (it.torStatus is TorServiceStatus.Active) {
|
||||
Log.d("ManageRelayServices", "Connectivity resumed, Tor active")
|
||||
}
|
||||
|
||||
// only calls this if the client is not active. Otherwise goes to the else below
|
||||
client.connect()
|
||||
}
|
||||
|
||||
else -> {
|
||||
// Only skip the per-relay exponential backoff when the actual HTTP
|
||||
// transport changed. Otherwise (e.g. Tor still bootstrapping, the SOCKS
|
||||
// port not yet listening) honor each relay's backoff so we don't
|
||||
// reconnect-fail-reconnect on every unrelated infrastructure event.
|
||||
val transportChanged =
|
||||
it.torConnection !== lastTorConnection || it.clearConnection !== lastClearConnection
|
||||
lastTorConnection = it.torConnection
|
||||
lastClearConnection = it.clearConnection
|
||||
|
||||
Log.d("ManageRelayServices") { "Relay Services have changed, reconnecting relays that need to (transportChanged=$transportChanged)" }
|
||||
client.reconnect(
|
||||
onlyIfChanged = true,
|
||||
ignoreRetryDelays = transportChanged,
|
||||
)
|
||||
}
|
||||
}
|
||||
}.onStart {
|
||||
.onEach { apply(it) }
|
||||
.onStart {
|
||||
Log.d("ManageRelayServices", "Resuming Relay Services")
|
||||
client.connect()
|
||||
}.onCompletion {
|
||||
@@ -130,4 +109,101 @@ class RelayProxyClientConnector(
|
||||
SharingStarted.WhileSubscribed(30000),
|
||||
null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Decides what a change in the relay infrastructure means for the pool. Split out of the
|
||||
* flow so the decision table can be exercised directly, without a debounce and a shared
|
||||
* StateFlow in the way.
|
||||
*/
|
||||
fun apply(infra: RelayServiceInfra) {
|
||||
val networkId = (infra.connectivity as? ConnectivityStatus.Active)?.networkId
|
||||
val torSettings = infra.evaluator.torSettings
|
||||
|
||||
when {
|
||||
infra.connectivity is ConnectivityStatus.StartingService -> {
|
||||
// ignore
|
||||
}
|
||||
|
||||
infra.connectivity is ConnectivityStatus.Off -> {
|
||||
Log.d("ManageRelayServices") { "Connectivity Off: Pausing Relay Services ${infra.connectivity}" }
|
||||
if (client.isActive()) {
|
||||
client.disconnect()
|
||||
}
|
||||
if (infra.torStatus is TorServiceStatus.Active) {
|
||||
Log.d("ManageRelayServices", "Connectivity off, Tor idle")
|
||||
}
|
||||
// disconnect() already cleared every relay's backoff. Forget the network
|
||||
// so the next Active is treated as a fresh start rather than a change.
|
||||
lastNetworkId = null
|
||||
}
|
||||
|
||||
infra.connectivity is ConnectivityStatus.Active && !client.isActive() -> {
|
||||
Log.d("ManageRelayServices", "Connectivity On: Resuming Relay Services")
|
||||
|
||||
if (infra.torStatus is TorServiceStatus.Active) {
|
||||
Log.d("ManageRelayServices", "Connectivity resumed, Tor active")
|
||||
}
|
||||
|
||||
// only calls this if the client is not active. Otherwise goes to the else below
|
||||
client.connect()
|
||||
lastNetworkId = networkId
|
||||
lastTorSettings = torSettings
|
||||
lastTorConnection = infra.torConnection
|
||||
lastClearConnection = infra.clearConnection
|
||||
}
|
||||
|
||||
else -> {
|
||||
// Only skip the per-relay exponential backoff when the actual HTTP
|
||||
// transport changed. Otherwise (e.g. Tor still bootstrapping, the SOCKS
|
||||
// port not yet listening) honor each relay's backoff so we don't
|
||||
// reconnect-fail-reconnect on every unrelated infrastructure event.
|
||||
val transportChanged =
|
||||
infra.torConnection !== lastTorConnection || infra.clearConnection !== lastClearConnection
|
||||
|
||||
// A different network entirely. Every socket is bound to an interface that
|
||||
// no longer carries traffic, and needsToReconnect() cannot see that (it only
|
||||
// compares the proxy and the timeouts), so those sockets would otherwise sit
|
||||
// there until OkHttp's 120s ping finally fails.
|
||||
val networkChanged = networkId != null && lastNetworkId != null && networkId != lastNetworkId
|
||||
|
||||
// Same network and same OkHttp clients, but the user re-classified which
|
||||
// relays go through Tor. The relays whose transport flipped must re-dial now.
|
||||
val torPolicyChanged = lastTorSettings != null && torSettings != lastTorSettings
|
||||
|
||||
val previousNetworkId = lastNetworkId
|
||||
|
||||
lastTorConnection = infra.torConnection
|
||||
lastClearConnection = infra.clearConnection
|
||||
lastNetworkId = networkId ?: lastNetworkId
|
||||
lastTorSettings = torSettings
|
||||
|
||||
if (networkChanged) {
|
||||
Log.d("ManageRelayServices") {
|
||||
"Network identity changed ($previousNetworkId -> $networkId), rebuilding every relay connection"
|
||||
}
|
||||
// Full teardown: disconnect() drops the dead sockets AND clears each
|
||||
// relay's backoff, so the new network starts from a clean slate.
|
||||
client.reconnect(onlyIfChanged = false, ignoreRetryDelays = true)
|
||||
} else {
|
||||
val freshStart = transportChanged || torPolicyChanged
|
||||
if (freshStart) {
|
||||
// The failures behind the current backoffs were measured against a
|
||||
// transport we are no longer using. ignoreRetryDelays alone only
|
||||
// skips the gate once and still doubles the stored delay, so a relay
|
||||
// that fails this one dial would come back worse off than before.
|
||||
client.resetBackoff()
|
||||
}
|
||||
|
||||
Log.d("ManageRelayServices") {
|
||||
"Relay Services have changed, reconnecting relays that need to " +
|
||||
"(transportChanged=$transportChanged torPolicyChanged=$torPolicyChanged)"
|
||||
}
|
||||
client.reconnect(
|
||||
onlyIfChanged = true,
|
||||
ignoreRetryDelays = freshStart,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
/*
|
||||
* 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.relayClient.diagnostics
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.AuthCmd
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import kotlin.concurrent.thread
|
||||
|
||||
/**
|
||||
* Debug-only cold-start census: what every relay in the pool actually did, and why the ones
|
||||
* that failed, failed.
|
||||
*
|
||||
* The existing instrumentation cannot answer this. [com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStats]
|
||||
* has counters but no dump path and no failure taxonomy; `RelaySpeedLogger` counts events per
|
||||
* second but not connection outcomes; `RelayLogger` prints one line per event, which at the
|
||||
* few-hundred-relay cardinality of the outbox model is thousands of lines to grep rather than
|
||||
* a table to read.
|
||||
*
|
||||
* What this adds: connection outcome bucketed **by cause**, REQ/EOSE/CLOSED accounting per
|
||||
* relay, and time-to-first-EOSE — so a boot can be read as "N relays served us, M were
|
||||
* refused for reason R, K were never reachable".
|
||||
*
|
||||
* Attach only in debug builds; it holds one small record per relay for the process lifetime.
|
||||
*/
|
||||
class BootRelayDiagnostics(
|
||||
val client: INostrClient,
|
||||
val dumpAtSeconds: List<Long> = listOf(20, 45, 90),
|
||||
) {
|
||||
companion object {
|
||||
const val TAG = "BootRelayDiag"
|
||||
|
||||
/**
|
||||
* Buckets a connection failure by what actually went wrong. The distinction that
|
||||
* matters most here is *ours vs theirs*: a SOCKS refusal is our Tor proxy declining
|
||||
* to open a stream and says nothing about the relay, but it reaches the relay client
|
||||
* through the same path as a genuine relay failure and is charged to the relay.
|
||||
*/
|
||||
fun classify(error: String): String =
|
||||
when {
|
||||
error.contains("SOCKS", ignoreCase = true) -> "tor-socks"
|
||||
error.contains("127.0.0.1") -> "tor-proxy-down"
|
||||
error.contains("UnknownHostException") -> "dns"
|
||||
error.contains("SSLHandshakeException") || error.contains("SSLPeerUnverified") -> "tls"
|
||||
error.contains("SocketTimeoutException") -> "timeout"
|
||||
error.contains("Server Misconfigured") -> "http-" + (Regex("Response: (\\d+)").find(error)?.groupValues?.get(1) ?: "?")
|
||||
error.contains("ConnectException") -> "refused"
|
||||
error.contains("Connection reset") -> "reset"
|
||||
else -> "other"
|
||||
}
|
||||
}
|
||||
|
||||
class RelayRecord {
|
||||
val tentatives = AtomicInteger()
|
||||
val opens = AtomicInteger()
|
||||
val disconnects = AtomicInteger()
|
||||
val reqsSent = AtomicInteger()
|
||||
val authsSent = AtomicInteger()
|
||||
val events = AtomicInteger()
|
||||
val eoses = AtomicInteger()
|
||||
val notices = AtomicInteger()
|
||||
|
||||
/** failure cause -> count, see [classify]. */
|
||||
val failures = ConcurrentHashMap<String, AtomicInteger>()
|
||||
|
||||
/** CLOSED machine-readable prefix (or "unprefixed") -> count. */
|
||||
val closed = ConcurrentHashMap<String, AtomicInteger>()
|
||||
|
||||
val firstOpenAtMs = AtomicLong(0)
|
||||
val firstEoseAtMs = AtomicLong(0)
|
||||
|
||||
fun bump(
|
||||
map: ConcurrentHashMap<String, AtomicInteger>,
|
||||
key: String,
|
||||
) = map.computeIfAbsent(key) { AtomicInteger() }.incrementAndGet()
|
||||
}
|
||||
|
||||
private val records = ConcurrentHashMap<NormalizedRelayUrl, RelayRecord>()
|
||||
private val startedAtMs = System.currentTimeMillis()
|
||||
|
||||
private fun record(url: NormalizedRelayUrl) = records.computeIfAbsent(url) { RelayRecord() }
|
||||
|
||||
private fun elapsed() = System.currentTimeMillis() - startedAtMs
|
||||
|
||||
private val listener =
|
||||
object : RelayConnectionListener {
|
||||
override fun onConnecting(relay: IRelayClient) {
|
||||
record(relay.url).tentatives.incrementAndGet()
|
||||
}
|
||||
|
||||
override fun onConnected(
|
||||
relay: IRelayClient,
|
||||
pingMillis: Int,
|
||||
compressed: Boolean,
|
||||
) {
|
||||
val r = record(relay.url)
|
||||
r.opens.incrementAndGet()
|
||||
r.firstOpenAtMs.compareAndSet(0, elapsed())
|
||||
}
|
||||
|
||||
override fun onCannotConnect(
|
||||
relay: IRelayClient,
|
||||
errorMessage: String,
|
||||
) {
|
||||
val r = record(relay.url)
|
||||
r.bump(r.failures, classify(errorMessage))
|
||||
}
|
||||
|
||||
override fun onDisconnected(relay: IRelayClient) {
|
||||
record(relay.url).disconnects.incrementAndGet()
|
||||
}
|
||||
|
||||
override fun onSent(
|
||||
relay: IRelayClient,
|
||||
cmdStr: String,
|
||||
cmd: Command,
|
||||
success: Boolean,
|
||||
) {
|
||||
val r = record(relay.url)
|
||||
when (cmd) {
|
||||
is ReqCmd -> r.reqsSent.incrementAndGet()
|
||||
is AuthCmd -> r.authsSent.incrementAndGet()
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
override fun onIncomingMessage(
|
||||
relay: IRelayClient,
|
||||
msgStr: String,
|
||||
msg: Message,
|
||||
) {
|
||||
val r = record(relay.url)
|
||||
when (msg) {
|
||||
is EventMessage -> r.events.incrementAndGet()
|
||||
is EoseMessage -> {
|
||||
r.eoses.incrementAndGet()
|
||||
r.firstEoseAtMs.compareAndSet(0, elapsed())
|
||||
}
|
||||
is NoticeMessage -> r.notices.incrementAndGet()
|
||||
is ClosedMessage -> r.bump(r.closed, prefixOf(msg.message))
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** First token of a NIP-01 machine-readable CLOSED/OK message, or "unprefixed". */
|
||||
private fun prefixOf(message: String?): String {
|
||||
val text = message?.trim().orEmpty()
|
||||
if (text.isEmpty()) return "empty"
|
||||
val head = text.substringBefore(':', "")
|
||||
return if (head.isNotEmpty() && head.length < 20 && !head.contains(' ')) head else "unprefixed"
|
||||
}
|
||||
|
||||
init {
|
||||
client.addConnectionListener(listener)
|
||||
thread(isDaemon = true, name = TAG) {
|
||||
var last = 0L
|
||||
dumpAtSeconds.forEach { at ->
|
||||
Thread.sleep((at - last) * 1000)
|
||||
last = at
|
||||
dump(at)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun detach() = client.removeConnectionListener(listener)
|
||||
|
||||
/**
|
||||
* One line per relay plus a rollup. Kept to a single Log.w per line so the whole census
|
||||
* survives logcat's per-tag rate limiting on a busy boot.
|
||||
*/
|
||||
fun dump(atSeconds: Long) {
|
||||
val snapshot = records.toMap()
|
||||
|
||||
val served = snapshot.filter { it.value.events.get() > 0 }
|
||||
val opened = snapshot.filter { it.value.opens.get() > 0 }
|
||||
val neverOpened = snapshot.filter { it.value.opens.get() == 0 }
|
||||
|
||||
val causeTotals = mutableMapOf<String, Int>()
|
||||
val closedTotals = mutableMapOf<String, Int>()
|
||||
snapshot.values.forEach { r ->
|
||||
r.failures.forEach { (k, v) -> causeTotals[k] = (causeTotals[k] ?: 0) + v.get() }
|
||||
r.closed.forEach { (k, v) -> closedTotals[k] = (closedTotals[k] ?: 0) + v.get() }
|
||||
}
|
||||
|
||||
Log.w(TAG, "===== boot census @${atSeconds}s =====")
|
||||
Log.w(
|
||||
TAG,
|
||||
"pool=${snapshot.size} opened=${opened.size} served_events=${served.size} never_opened=${neverOpened.size} " +
|
||||
"dials=${snapshot.values.sumOf { it.tentatives.get() }} " +
|
||||
"events=${snapshot.values.sumOf { it.events.get() }} " +
|
||||
"reqs=${snapshot.values.sumOf { it.reqsSent.get() }} " +
|
||||
"auths=${snapshot.values.sumOf { it.authsSent.get() }}",
|
||||
)
|
||||
Log.w(TAG, "failures_by_cause=" + causeTotals.entries.sortedByDescending { it.value }.joinToString { "${it.key}:${it.value}" })
|
||||
Log.w(TAG, "closed_by_prefix=" + closedTotals.entries.sortedByDescending { it.value }.joinToString { "${it.key}:${it.value}" })
|
||||
|
||||
// Relays that cost us dials and gave nothing back, worst first: the wasted-effort list.
|
||||
Log.w(TAG, "--- top wasted dials (no events received) ---")
|
||||
snapshot
|
||||
.filter { it.value.events.get() == 0 }
|
||||
.entries
|
||||
.sortedByDescending { it.value.tentatives.get() }
|
||||
.take(25)
|
||||
.forEach { (url, r) ->
|
||||
Log.w(
|
||||
TAG,
|
||||
"WASTE ${url.url} dials=${r.tentatives.get()} opens=${r.opens.get()} " +
|
||||
"fail=[${r.failures.entries.joinToString { "${it.key}:${it.value.get()}" }}] " +
|
||||
"closed=[${r.closed.entries.joinToString { "${it.key}:${it.value.get()}" }}] " +
|
||||
"reqs=${r.reqsSent.get()} eose=${r.eoses.get()}",
|
||||
)
|
||||
}
|
||||
|
||||
// The relays actually carrying the boot, so a suppression change can be checked for
|
||||
// coverage loss rather than just CLOSED reduction.
|
||||
Log.w(TAG, "--- top event providers ---")
|
||||
served.entries
|
||||
.sortedByDescending { it.value.events.get() }
|
||||
.take(20)
|
||||
.forEach { (url, r) ->
|
||||
Log.w(
|
||||
TAG,
|
||||
"SERVE ${url.url} events=${r.events.get()} reqs=${r.reqsSent.get()} eose=${r.eoses.get()} " +
|
||||
"openMs=${r.firstOpenAtMs.get()} eoseMs=${r.firstEoseAtMs.get()} dials=${r.tentatives.get()}",
|
||||
)
|
||||
}
|
||||
Log.w(TAG, "===== end census @${atSeconds}s =====")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* 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.relayClient.eoseManagers
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.chats.ChatFeedType
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Rebuilds a Messages-inbox subscription's filters whenever the user flips [type]'s
|
||||
* load-toggle in Settings › Messages. A disabled type's `updateFilter` returns no filters, so a
|
||||
* flip to off empties the live subscription and a flip back on re-arms it — no restart needed.
|
||||
*
|
||||
* Only the boolean for [type] is watched (via distinct + drop(1)), so unrelated toggle changes
|
||||
* don't churn this assembler.
|
||||
*/
|
||||
fun CoroutineScope.launchChatFeedToggleObserver(
|
||||
account: Account,
|
||||
type: ChatFeedType,
|
||||
onToggle: () -> Unit,
|
||||
): Job =
|
||||
launch(Dispatchers.IO) {
|
||||
account.settings.enabledChatFeeds
|
||||
.map { type in it }
|
||||
.distinctUntilChanged()
|
||||
.drop(1)
|
||||
.collect { onToggle() }
|
||||
}
|
||||
@@ -39,9 +39,13 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource.
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelHistoryFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupMyJoinedGroupsFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupThreadFeedFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupWarmupFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupCardWarmupFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupJoinedChatTailFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupJoinedStateFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupOpenChatHistoryFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupOpenChatTailFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupOpenThreadsFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupOpenThreadsHistoryFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupsDiscoveryFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupsOnRelayFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource.ChatroomListFilterAssembler
|
||||
@@ -126,11 +130,18 @@ class RelaySubscriptionsCoordinator(
|
||||
// assembler above (same as NIP-28 public chats), so only the group-specific surfaces get their
|
||||
// own here.
|
||||
val relayGroupsOnRelay = RelayGroupsOnRelayFilterAssembler(client) // browsing one relay's channel list
|
||||
val relayGroupMyJoinedGroups = RelayGroupMyJoinedGroupsFilterAssembler(client) // metadata+rosters of groups I've joined
|
||||
val relayGroupThreadFeed = RelayGroupThreadFeedFilterAssembler(client) // a group's forum-threads tab
|
||||
val relayGroupWarmup = RelayGroupWarmupFilterAssembler(client) // prefetching a group before it's opened
|
||||
val relayGroupOpenThreads = RelayGroupOpenThreadsFilterAssembler(client) // a group's forum-threads tab (recent tail)
|
||||
val relayGroupOpenThreadsHistory = RelayGroupOpenThreadsHistoryFilterAssembler(client) // the Threads tab's backward history pager
|
||||
val relayGroupCardWarmup = RelayGroupCardWarmupFilterAssembler(client) // prefetching a group before it's opened
|
||||
val relayGroupsDiscovery = RelayGroupsDiscoveryFilterAssembler(client) // the cross-relay Discover feed
|
||||
|
||||
// NIP-29 chat, split state-vs-content like the DM / Concord stacks (see
|
||||
// amethyst/plans/2026-07-18-nip29-group-chat-subscriptions.md).
|
||||
val relayGroupJoinedState = RelayGroupJoinedStateFilterAssembler(client) // always-on: joined groups' metadata/roster/roles/pins
|
||||
val relayGroupJoinedChatTail = RelayGroupJoinedChatTailFilterAssembler(client) // always-on: batched #h recent-tail for Messages previews
|
||||
val relayGroupOpenChatTail = RelayGroupOpenChatTailFilterAssembler(client) // the open group's recent chat (covers non-joined)
|
||||
val relayGroupOpenChatHistory = RelayGroupOpenChatHistoryFilterAssembler(client) // the open group's on-demand backward history pager
|
||||
|
||||
// Concord Channels (encrypted communities). One assembler keeps every joined community's
|
||||
// control + channel planes live (kind-1059 by derived stream address).
|
||||
val concordChannels = ConcordChannelFilterAssembler(client)
|
||||
@@ -201,10 +212,14 @@ class RelaySubscriptionsCoordinator(
|
||||
val all =
|
||||
listOf(
|
||||
relayGroupsOnRelay,
|
||||
relayGroupMyJoinedGroups,
|
||||
relayGroupThreadFeed,
|
||||
relayGroupWarmup,
|
||||
relayGroupOpenThreads,
|
||||
relayGroupOpenThreadsHistory,
|
||||
relayGroupCardWarmup,
|
||||
relayGroupsDiscovery,
|
||||
relayGroupJoinedState,
|
||||
relayGroupJoinedChatTail,
|
||||
relayGroupOpenChatTail,
|
||||
relayGroupOpenChatHistory,
|
||||
concordChannels,
|
||||
concordChannelHistory,
|
||||
account,
|
||||
|
||||
@@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.drafts.
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.marmot.MarmotGroupEventsEoseManager
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metadata.AccountMetadataEoseManager
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications.AccountNotificationsEoseFromInboxRelaysManager
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications.AccountNotificationsHistoryEoseManager
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps.AccountGiftWrapsEoseManager
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps.AccountGiftWrapsHistoryEoseManager
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates
|
||||
@@ -56,13 +57,20 @@ class AccountFilterAssembler(
|
||||
// History: older gift wraps, loaded on demand in bounded one-shot slices.
|
||||
val giftWrapsHistory = AccountGiftWrapsHistoryEoseManager(client, ::allKeys)
|
||||
|
||||
// Live tail: the recent week of notifications from the inbox + group host relays.
|
||||
val notifications = AccountNotificationsEoseFromInboxRelaysManager(client, ::allKeys)
|
||||
|
||||
// History: older notifications, paged backward by until+limit per relay, driven by the feed's markers.
|
||||
val notificationsHistory = AccountNotificationsHistoryEoseManager(client, ::allKeys)
|
||||
|
||||
val group =
|
||||
listOf(
|
||||
AccountMetadataEoseManager(client, ::allKeys),
|
||||
giftWraps,
|
||||
giftWrapsHistory,
|
||||
AccountDraftsEoseManager(client, ::allKeys),
|
||||
AccountNotificationsEoseFromInboxRelaysManager(client, ::allKeys),
|
||||
notifications,
|
||||
notificationsHistory,
|
||||
MarmotGroupEventsEoseManager(client, ::allKeys),
|
||||
)
|
||||
|
||||
|
||||
@@ -20,8 +20,10 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.marmot
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.chats.ChatFeedType
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager
|
||||
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.launchChatFeedToggleObserver
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountQueryState
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
@@ -51,6 +53,7 @@ class MarmotGroupEventsEoseManager(
|
||||
): List<RelayBasedFilter> {
|
||||
val manager = key.account.marmotManager ?: return emptyList()
|
||||
if (!key.account.isWriteable()) return emptyList()
|
||||
if (!key.account.settings.isChatFeedEnabled(ChatFeedType.MARMOT)) return emptyList()
|
||||
|
||||
val result = mutableListOf<RelayBasedFilter>()
|
||||
val fallbackRelays = key.account.homeRelays.flow.value
|
||||
@@ -130,6 +133,7 @@ class MarmotGroupEventsEoseManager(
|
||||
invalidateFilters()
|
||||
}
|
||||
},
|
||||
key.account.scope.launchChatFeedToggleObserver(key.account, ChatFeedType.MARMOT) { invalidateFilters() },
|
||||
)
|
||||
|
||||
return super.newSub(key)
|
||||
|
||||
@@ -28,7 +28,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -51,17 +50,35 @@ class AccountNotificationsEoseFromInboxRelaysManager(
|
||||
key: AccountQueryState,
|
||||
since: SincePerRelayMap?,
|
||||
): List<RelayBasedFilter> {
|
||||
// Backward-paging boundary: once the feed has filled a page, ask for everything older than
|
||||
// its oldest card. It stays null until then — see the note on the missing week floor below,
|
||||
// which is what let it stay null forever on a quiet inbox.
|
||||
val pagingBoundary = key.feedContentStates.notifications.lastNoteCreatedAtIfFilled()
|
||||
|
||||
val inbox =
|
||||
key.account.notificationRelays.flow.value.flatMap {
|
||||
// No `since` floor on the first fetch. These filters are scoped by `#p` to my own
|
||||
// key and carry a relay-side `limit`, so an all-time query costs one index scan and
|
||||
// returns at most `limit` events, newest first — exactly what Home does (it passes
|
||||
// `since ?: boundary`, i.e. null on a cold start).
|
||||
//
|
||||
// This used to fall back to `oneWeekAgo()`, which silently emptied the tab for
|
||||
// anyone whose last mention was older than a week: EOSE `since` is in-memory only,
|
||||
// so EVERY cold start re-pinned the window to 7 days, and the paging boundary above
|
||||
// could never rescue it — it only arms once the feed holds a full page, and the feed
|
||||
// could not fill because the query only ever asked for a week. A fresh install of an
|
||||
// established account hit the same deadlock.
|
||||
val notificationSince = since?.get(it)?.time ?: pagingBoundary
|
||||
|
||||
filterSummaryNotificationsToPubkey(
|
||||
relay = it,
|
||||
pubkey = user(key).pubkeyHex,
|
||||
since = since?.get(it)?.time ?: TimeUtils.oneWeekAgo(),
|
||||
since = notificationSince,
|
||||
) +
|
||||
filterNotificationsToPubkey(
|
||||
relay = it,
|
||||
pubkey = user(key).pubkeyHex,
|
||||
since = since?.get(it)?.time ?: key.feedContentStates.notifications.lastNoteCreatedAtIfFilled() ?: TimeUtils.oneWeekAgo(),
|
||||
since = notificationSince,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -76,7 +93,9 @@ class AccountNotificationsEoseFromInboxRelaysManager(
|
||||
relay = relay,
|
||||
pubkey = user(key).pubkeyHex,
|
||||
groupIds = groupIds.distinct(),
|
||||
since = since?.get(relay)?.time ?: TimeUtils.oneWeekAgo(),
|
||||
// Same reasoning as the inbox filters above: `#p` + `#h` + `limit = 200`
|
||||
// already bound this, so a week floor only hides older group activity.
|
||||
since = since?.get(relay)?.time ?: pagingBoundary,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,6 @@ import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -51,8 +50,11 @@ class AccountNotificationsEoseFromRandomRelaysManager(
|
||||
key: AccountQueryState,
|
||||
since: SincePerRelayMap?,
|
||||
): List<RelayBasedFilter> {
|
||||
// only loads this after the feed is built
|
||||
val defaultSince = key.feedContentStates.notifications.lastNoteCreatedAtIfFilled() ?: TimeUtils.oneWeekAgo()
|
||||
// only loads this after the feed is built, so it stays null on a quiet inbox. No week floor
|
||||
// behind it: this probe is `#p`-scoped to me with `limit = 20`, so relays answer with the 20
|
||||
// newest either way — the floor only ever hid notifications older than a week, and since the
|
||||
// boundary above needs a full page to arm, a quiet inbox could never page past it.
|
||||
val defaultSince = key.feedContentStates.notifications.lastNoteCreatedAtIfFilled()
|
||||
return (key.account.followsPerRelay.value.keys - key.account.notificationRelays.flow.value).flatMap {
|
||||
val since = since?.get(it)?.time ?: defaultSince
|
||||
filterJustTheLatestNotificationsToPubkeyFromRandomRelays(it, user(key).pubkeyHex, since)
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
/*
|
||||
* 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.relayClient.reqCommand.account.nip01Notifications
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.paging.BackwardRelayPager
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.paging.PagingStatus
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountQueryState
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.sample
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Loads the account's notification **history** — everything older than the one-week live tail
|
||||
* ([AccountNotificationsEoseFromInboxRelaysManager]) — by **`until`+`limit` paging, per relay, on
|
||||
* demand**, so the notifications feed can be scrolled back in time instead of being pinned to the
|
||||
* recent week.
|
||||
*
|
||||
* There is no proactive walk: each relay advances exactly one page when the feed's on-screen
|
||||
* window-limit marker for that relay asks ([advance]), then **parks** at its window limit. The markers
|
||||
* are the drivers — a relay pages only while its marker is visible, and keeps paging as long as it
|
||||
* stays visible (see the notifications card feed). So a spam-dense relay never floods: the user has to
|
||||
* scroll through its notifications to pull more, and nothing is fetched while its marker is off screen.
|
||||
*
|
||||
* Relays paged: the same set the live inbox loader covers — the user's inbox relays (all notification
|
||||
* kinds tagging me) plus each joined NIP-29 group's host relay (group-activity kinds scoped by `#h`).
|
||||
* The foreground "random follows" straggler query ([AccountNotificationsEoseFromRandomRelaysManager],
|
||||
* tiny latest-N limits) is deliberately live-tail only and is NOT paged here.
|
||||
*
|
||||
* The per-relay cursors live on the [Account] (so they share the account's lifetime); this class binds
|
||||
* the single-active [BackwardRelayPager] orchestrator to them on [newSub], builds the notification REQ
|
||||
* filters, and forwards relay callbacks into the pager. A relay is *done* once it answers an empty page;
|
||||
* one that won't answer (auth CLOSE, unreachable, or silent) is flagged *stalled* but kept. [exhausted]
|
||||
* flips once every relay is either done or stalled.
|
||||
*/
|
||||
class AccountNotificationsHistoryEoseManager(
|
||||
client: INostrClient,
|
||||
allKeys: () -> Set<AccountQueryState>,
|
||||
) : PerUserEoseManager<AccountQueryState>(client, allKeys) {
|
||||
override fun user(key: AccountQueryState) = key.account.userProfile()
|
||||
|
||||
// A modest page: each marker-triggered advance pulls ~500 older notifications, digestible to render
|
||||
// and enough to fill a scroll, rather than the gift-wrap default (a whole encrypted-blob band at once).
|
||||
private val pager = BackwardRelayPager("notifications.history", pageLimit = 500)
|
||||
|
||||
val loadingMore: StateFlow<Boolean> = pager.loadingMore
|
||||
val status: StateFlow<PagingStatus> = pager.status
|
||||
|
||||
// Each joined group's id, bucketed by the normalized host relay it lives on. Used both to route the
|
||||
// group filter and (its keys) to add group host relays to the paged relay set.
|
||||
private fun groupsByRelay(account: Account): Map<NormalizedRelayUrl, List<String>> =
|
||||
account.relayGroupList.liveRelayGroupList.value
|
||||
.groupBy({ RelayUrlNormalizer.normalizeOrNull(it.relayUrl) }, { it.groupId })
|
||||
.mapNotNull { (relay, ids) -> relay?.let { it to ids.distinct() } }
|
||||
.toMap()
|
||||
|
||||
// The full relay set this account pages notifications back through: inbox relays + group host relays.
|
||||
private fun notificationRelaySet(account: Account): Set<NormalizedRelayUrl> = account.notificationRelays.flow.value + groupsByRelay(account).keys
|
||||
|
||||
override fun updateFilter(
|
||||
key: AccountQueryState,
|
||||
since: SincePerRelayMap?,
|
||||
): List<RelayBasedFilter> {
|
||||
if (!key.account.isWriteable()) return emptyList()
|
||||
|
||||
val pubkey = user(key).pubkeyHex
|
||||
val inbox = key.account.notificationRelays.flow.value
|
||||
val groups = groupsByRelay(key.account)
|
||||
|
||||
// Only relays that have been advanced (armed) and aren't done carry a REQ. A relay that finished a
|
||||
// page keeps the same `until` here, so re-assembly (triggered when ANOTHER relay advances) doesn't
|
||||
// re-REQ it — it stays parked until the marker advances it again.
|
||||
val armed = pager.armedRelays(inbox + groups.keys)
|
||||
if (armed.isEmpty()) return emptyList()
|
||||
|
||||
return armed.flatMap { relay ->
|
||||
val until = pager.requestedUntilFor(relay) ?: return@flatMap emptyList()
|
||||
Log.d(TAG) { "[notifications.history] REQ ${relay.url} until=$until limit=${pager.pageLimit}" }
|
||||
buildList {
|
||||
if (relay in inbox) {
|
||||
addAll(filterNotificationsHistoryToPubkey(relay, pubkey, until, pager.pageLimit))
|
||||
}
|
||||
groups[relay]?.let { groupIds ->
|
||||
addAll(filterGroupNotificationsHistoryToPubkey(relay, pubkey, groupIds, until, pager.pageLimit))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Steps a single [relay] to its next, older page. Driven by that relay's on-screen window-limit marker. */
|
||||
fun advance(relay: NormalizedRelayUrl) {
|
||||
if (pager.advance(relay)) invalidateFilters()
|
||||
}
|
||||
|
||||
/** Steps every not-done, not-in-flight relay one page. For the empty/initial boundary (nothing to scroll). */
|
||||
fun advanceAll() {
|
||||
if (pager.advanceAll()) {
|
||||
Log.d(TAG) { "[notifications.history] advanceAll (empty-feed bootstrap)" }
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
|
||||
private val userJobMap = mutableMapOf<User, List<Job>>()
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
override fun newSub(key: AccountQueryState): Subscription {
|
||||
// Repoint the single-active orchestrator at this account's notification cursors and the relay set
|
||||
// it fans out to, refreshing the display flows from the restored progress.
|
||||
pager.bind(key.account.notificationHistory, key.account.scope) { notificationRelaySet(key.account) }
|
||||
|
||||
val user = user(key)
|
||||
userJobMap[user]?.forEach { it.cancel() }
|
||||
userJobMap[user] =
|
||||
listOf(
|
||||
// A relay joining/leaving the paged set (inbox change, group join/leave) re-issues the REQ
|
||||
// so a newly-added relay can be armed and a removed one drops out.
|
||||
key.account.scope.launch(Dispatchers.IO) {
|
||||
key.account.notificationRelays.flow
|
||||
.sample(1000)
|
||||
.collectLatest { invalidateFilters() }
|
||||
},
|
||||
key.account.scope.launch(Dispatchers.IO) {
|
||||
key.account.relayGroupList.liveRelayGroupList
|
||||
.sample(1000)
|
||||
.collectLatest { invalidateFilters() }
|
||||
},
|
||||
)
|
||||
|
||||
return requestNewSubscription(historyListener(key))
|
||||
}
|
||||
|
||||
private fun historyListener(key: AccountQueryState): SubscriptionListener {
|
||||
// A just-backgrounded account's subscription can still deliver after the orchestrator rebinds to
|
||||
// another account; gate the pager (single-active) on whether it's still bound to THIS account's
|
||||
// cursors so a late callback can't move another account's cursors. newEose runs regardless.
|
||||
val myCursors = key.account.notificationHistory
|
||||
return object : SubscriptionListener {
|
||||
override fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
if (pager.isBoundTo(myCursors)) pager.onEvent(relay, event.createdAt)
|
||||
}
|
||||
|
||||
override fun onEose(
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
if (pager.isBoundTo(myCursors) && pager.onEose(relay)) {
|
||||
Log.d(TAG) { "[notifications.history] ${relay.url} reached the bottom (done)" }
|
||||
}
|
||||
// No auto-advance: the relay parks here until its marker asks for the next page.
|
||||
newEose(key, relay, TimeUtils.now(), forFilters)
|
||||
}
|
||||
|
||||
override fun onClosed(
|
||||
message: String,
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
if (pager.isBoundTo(myCursors)) pager.onClosed(relay, message)
|
||||
}
|
||||
|
||||
override fun onCannotConnect(
|
||||
relay: NormalizedRelayUrl,
|
||||
message: String,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
if (pager.isBoundTo(myCursors)) pager.onCannotConnect(relay, message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun endSub(
|
||||
key: User,
|
||||
subId: String,
|
||||
) {
|
||||
super.endSub(key, subId)
|
||||
userJobMap[key]?.forEach { it.cancel() }
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "NotificationPagination"
|
||||
}
|
||||
}
|
||||
@@ -124,6 +124,70 @@ val NotificationsPerKeyKinds3 =
|
||||
AttestorRecommendationEvent.KIND,
|
||||
)
|
||||
|
||||
/**
|
||||
* Every kind the notifications feed cares about on the user's inbox relays, flattened into one list.
|
||||
* The live-tail query ([filterNotificationsToPubkey] / [filterSummaryNotificationsToPubkey]) splits these
|
||||
* across several filters with different per-kind limits; backward history paging instead asks ONE filter
|
||||
* per relay so the single until+limit cursor stays gap-proof (an empty page truly means "nothing older").
|
||||
*/
|
||||
val AllNotificationKinds =
|
||||
(SummaryKinds + NotificationsPerKeyKinds + NotificationsPerKeyKinds2 + NotificationsPerKeyKinds3).distinct()
|
||||
|
||||
/**
|
||||
* One backward-paging page of notifications on an inbox relay: the N newest events tagging me
|
||||
* ([AllNotificationKinds], `#p` = me) strictly older than [until]. A single filter (not the live query's
|
||||
* split) so the [BackwardRelayPager][com.vitorpamplona.amethyst.commons.relayClient.paging.BackwardRelayPager]
|
||||
* cursor tracking the oldest delivered `created_at` can't skip a band that a per-kind sub-limit capped.
|
||||
*/
|
||||
fun filterNotificationsHistoryToPubkey(
|
||||
relay: NormalizedRelayUrl,
|
||||
pubkey: HexKey?,
|
||||
until: Long,
|
||||
limit: Int,
|
||||
): List<RelayBasedFilter> {
|
||||
if (pubkey.isNullOrEmpty()) return emptyList()
|
||||
|
||||
return listOf(
|
||||
RelayBasedFilter(
|
||||
relay = relay,
|
||||
filter =
|
||||
Filter(
|
||||
kinds = AllNotificationKinds,
|
||||
tags = mapOf("p" to listOf(pubkey)),
|
||||
limit = limit,
|
||||
until = until,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One backward-paging page of NIP-29 group-activity notifications on a group's host relay:
|
||||
* [GroupNotificationKinds] tagging me (`#p`) inside my joined groups (`#h`), strictly older than [until].
|
||||
*/
|
||||
fun filterGroupNotificationsHistoryToPubkey(
|
||||
relay: NormalizedRelayUrl,
|
||||
pubkey: HexKey?,
|
||||
groupIds: List<String>,
|
||||
until: Long,
|
||||
limit: Int,
|
||||
): List<RelayBasedFilter> {
|
||||
if (pubkey.isNullOrEmpty() || groupIds.isEmpty()) return emptyList()
|
||||
|
||||
return listOf(
|
||||
RelayBasedFilter(
|
||||
relay = relay,
|
||||
filter =
|
||||
Filter(
|
||||
kinds = GroupNotificationKinds,
|
||||
tags = mapOf("p" to listOf(pubkey), "h" to groupIds),
|
||||
limit = limit,
|
||||
until = until,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun filterSummaryNotificationsToPubkey(
|
||||
relay: NormalizedRelayUrl,
|
||||
pubkey: HexKey?,
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.chats.ChatFeedType
|
||||
import com.vitorpamplona.amethyst.commons.model.privateChats.DmHistoryTuning
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.filterGiftWrapsToPubkey
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.paging.WindowLoadTracker
|
||||
@@ -27,6 +28,7 @@ import com.vitorpamplona.amethyst.commons.relayClient.paging.trackingListener
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.DmRelayLog
|
||||
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager
|
||||
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.launchChatFeedToggleObserver
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountQueryState
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
@@ -63,7 +65,7 @@ class AccountGiftWrapsEoseManager(
|
||||
key: AccountQueryState,
|
||||
since: SincePerRelayMap?,
|
||||
): List<RelayBasedFilter> {
|
||||
if (!key.account.isWriteable()) {
|
||||
if (!key.account.isWriteable() || !key.account.settings.isChatFeedEnabled(ChatFeedType.NIP17)) {
|
||||
windowLoad.setExpectedRelays(emptySet())
|
||||
return emptyList()
|
||||
}
|
||||
@@ -90,6 +92,7 @@ class AccountGiftWrapsEoseManager(
|
||||
key.account.dmRelays.flow
|
||||
.collectLatest { invalidateFilters() }
|
||||
},
|
||||
key.account.scope.launchChatFeedToggleObserver(key.account, ChatFeedType.NIP17) { invalidateFilters() },
|
||||
)
|
||||
|
||||
return requestNewSubscription(
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.chats.ChatFeedType
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.filterGiftWrapsToPubkey
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.paging.BackwardRelayPager
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.paging.PagingStatus
|
||||
@@ -73,6 +74,7 @@ class AccountGiftWrapsHistoryEoseManager(
|
||||
since: SincePerRelayMap?,
|
||||
): List<RelayBasedFilter> {
|
||||
if (!key.account.isWriteable()) return emptyList()
|
||||
if (!key.account.settings.isChatFeedEnabled(ChatFeedType.NIP17)) return emptyList()
|
||||
// Only relays that have been advanced (armed) and aren't done carry a REQ. A relay that finished a
|
||||
// page keeps the same `until` here, so re-assembly (triggered when ANOTHER relay advances) doesn't
|
||||
// re-REQ it — it stays parked until the UI advances it again.
|
||||
|
||||
@@ -51,19 +51,31 @@ class ForegroundTracker : Application.ActivityLifecycleCallbacks {
|
||||
_isForeground.value = startedActivities > 0
|
||||
}
|
||||
|
||||
// Only started/stopped drive the foreground signal; the remaining lifecycle
|
||||
// callbacks are required by the interface but irrelevant to this tracker.
|
||||
override fun onActivityCreated(
|
||||
activity: Activity,
|
||||
savedInstanceState: Bundle?,
|
||||
) {}
|
||||
) {
|
||||
// No-op: creation does not change foreground state.
|
||||
}
|
||||
|
||||
override fun onActivityResumed(activity: Activity) {}
|
||||
override fun onActivityResumed(activity: Activity) {
|
||||
// No-op: foreground is derived from started/stopped counts, not resume.
|
||||
}
|
||||
|
||||
override fun onActivityPaused(activity: Activity) {}
|
||||
override fun onActivityPaused(activity: Activity) {
|
||||
// No-op: foreground is derived from started/stopped counts, not pause.
|
||||
}
|
||||
|
||||
override fun onActivitySaveInstanceState(
|
||||
activity: Activity,
|
||||
outState: Bundle,
|
||||
) {}
|
||||
) {
|
||||
// No-op: state saving does not change foreground state.
|
||||
}
|
||||
|
||||
override fun onActivityDestroyed(activity: Activity) {}
|
||||
override fun onActivityDestroyed(activity: Activity) {
|
||||
// No-op: onActivityStopped already decremented the counter.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ class ResourceUsageReportAssembler {
|
||||
sb.append("\n\n")
|
||||
|
||||
sb.append("| Prop | Value |\n")
|
||||
sb.append("| --- | --- |\n")
|
||||
sb.append(TABLE_SEPARATOR)
|
||||
sb.append("| Manuf | ${Build.MANUFACTURER} |\n")
|
||||
sb.append("| Model | ${Build.MODEL} |\n")
|
||||
sb.append("| Android | ${Build.VERSION.RELEASE} |\n")
|
||||
@@ -64,7 +64,7 @@ class ResourceUsageReportAssembler {
|
||||
if (memory != null) {
|
||||
sb.append("\n**Memory right now**\n\n")
|
||||
sb.append("| Metric | Value |\n")
|
||||
sb.append("| --- | --- |\n")
|
||||
sb.append(TABLE_SEPARATOR)
|
||||
sb.append("| Device class | ${memory.memoryClassMb} MB |\n")
|
||||
sb.append("| App heap | ${memory.heapUsedMb} / ${memory.heapMaxMb} MB |\n")
|
||||
sb.append("| Native heap | ${memory.nativeHeapUsedMb} MB |\n")
|
||||
@@ -88,7 +88,7 @@ class ResourceUsageReportAssembler {
|
||||
private fun summaryTable(s: UsageSummary): String =
|
||||
buildString {
|
||||
append("| Metric | Value |\n")
|
||||
append("| --- | --- |\n")
|
||||
append(TABLE_SEPARATOR)
|
||||
append("| Cellular data (background) | ${formatBytes(s.mobileBytesBg)} |\n")
|
||||
append("| Cellular data (foreground) | ${formatBytes(s.mobileBytesFg)} |\n")
|
||||
append("| Wi-Fi data | ${formatBytes(s.wifiBytesBg + s.wifiBytesFg)} |\n")
|
||||
@@ -129,6 +129,9 @@ class ResourceUsageReportAssembler {
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Markdown table header/body separator row. */
|
||||
private const val TABLE_SEPARATOR = "| --- | --- |\n"
|
||||
|
||||
fun formatBytes(bytes: Long): String =
|
||||
when {
|
||||
bytes >= 1024L * 1024L * 1024L -> String.format(Locale.US, "%.2f GB", bytes / (1024.0 * 1024.0 * 1024.0))
|
||||
|
||||
@@ -24,6 +24,8 @@ import android.content.Context
|
||||
import android.net.Uri
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.service.upload.BlossomClient
|
||||
import com.vitorpamplona.amethyst.commons.service.upload.BlossomPaymentException
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.uploads.UploadingState.UploadingFinalState
|
||||
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader
|
||||
@@ -34,10 +36,13 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
|
||||
import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent
|
||||
import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent
|
||||
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServerUrl
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import com.vitorpamplona.quartz.utils.ciphers.NostrCipher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.OkHttpClient
|
||||
import java.io.File
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
@@ -200,6 +205,9 @@ class UploadOrchestrator {
|
||||
context: Context,
|
||||
): UploadingFinalState {
|
||||
updateState(0.2, UploadingState.Uploading)
|
||||
// BUD-05: route through /media (optimize) when the user opted in. The forced-signer
|
||||
// path (e.g. NIP-46 draft signing) always uses the bit-exact /upload.
|
||||
val useMedia = forcedSigner == null && account.settings.optimizeMediaOnUpload.value
|
||||
return try {
|
||||
val result =
|
||||
BlossomUploader()
|
||||
@@ -211,27 +219,93 @@ class UploadOrchestrator {
|
||||
sensitiveContent = contentWarningReason,
|
||||
serverBaseUrl = serverBaseUrl,
|
||||
okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads,
|
||||
// Use a t=media token when optimizing via /media, otherwise a plain
|
||||
// t=upload token. Tokens are intentionally NOT server-scoped on the
|
||||
// upload path: some servers reject an upload whose auth carries a
|
||||
// `server` tag, and upload-token replay is not the threat scoping
|
||||
// guards against (delete tokens are — those stay scoped).
|
||||
httpAuth =
|
||||
if (forcedSigner != null) {
|
||||
{ hash, size, alt -> BlossomAuthorizationEvent.createUploadAuth(hash, size, alt, forcedSigner) }
|
||||
} else {
|
||||
account::createBlossomUploadAuth
|
||||
when {
|
||||
forcedSigner != null -> { hash, size, alt -> BlossomAuthorizationEvent.createUploadAuth(hash, size, alt, forcedSigner) }
|
||||
useMedia -> { hash, size, alt -> account.createBlossomMediaAuth(hash, size, alt) }
|
||||
else -> { hash, size, alt -> account.createBlossomUploadAuth(hash, size, alt) }
|
||||
},
|
||||
context = context,
|
||||
useMediaEndpoint = useMedia,
|
||||
)
|
||||
|
||||
verifyHeader(
|
||||
uploadResult = result,
|
||||
localContentType = contentType,
|
||||
okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads,
|
||||
originalHash = originalHash,
|
||||
originalContentType = contentTypeForResult,
|
||||
)
|
||||
val finalState =
|
||||
verifyHeader(
|
||||
uploadResult = result,
|
||||
localContentType = contentType,
|
||||
okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads,
|
||||
originalHash = originalHash,
|
||||
originalContentType = contentTypeForResult,
|
||||
)
|
||||
|
||||
// BUD-04: replicate the blob to the user's other Blossom servers for redundancy.
|
||||
// Fire-and-forget on the account scope AFTER the upload is finished: mirroring is
|
||||
// pure background redundancy, so it must never delay, alter, or fail the upload the
|
||||
// user already completed, and must not touch the on-screen progress state.
|
||||
if (finalState is UploadingState.Finished && forcedSigner == null && account.settings.mirrorUploadsToAllServers.value) {
|
||||
account.scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
mirrorToOtherServers(result, serverBaseUrl, account)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.w("UploadOrchestrator", "Background mirror failed", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
finalState
|
||||
} catch (_: SignerExceptions.ReadOnlyException) {
|
||||
error(R.string.login_with_a_private_key_to_be_able_to_upload)
|
||||
} catch (e: BlossomPaymentException) {
|
||||
// BUD-07: the server wants payment before it will store the blob.
|
||||
error(R.string.blossom_payment_required, e.payment.reason ?: serverBaseUrl)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
error(R.string.failed_to_upload_media, e.message ?: e.javaClass.simpleName)
|
||||
error(R.string.failed_to_upload_media, e.message?.ifBlank { null } ?: e.javaClass.simpleName)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* BUD-04 mirror fan-out: asks every *other* Blossom server in the account's
|
||||
* kind-10063 list to pull the freshly-uploaded blob from [result]'s URL. Runs
|
||||
* in the background after the upload is finished (see caller), so it never
|
||||
* delays the user's post or touches the upload progress UI; per-server failures
|
||||
* are swallowed. Requires the blob's sha256 so server B can verify the download.
|
||||
*/
|
||||
private suspend fun mirrorToOtherServers(
|
||||
result: MediaUploadResult,
|
||||
primaryServerBaseUrl: String,
|
||||
account: Account,
|
||||
) {
|
||||
val sourceUrl = result.url ?: return
|
||||
val hash = result.sha256 ?: sourceUrl.substringAfterLast('/').substringBefore('.')
|
||||
if (hash.length != 64) return
|
||||
|
||||
// Only the user's *explicitly configured* kind-10063 servers (flow), NOT the
|
||||
// DEFAULT_MEDIA_SERVERS fallback that hostNameFlow injects — we must never fan
|
||||
// uploads out to public defaults the user never opted into.
|
||||
val primaryDomain = BlossomServerUrl.domain(primaryServerBaseUrl)
|
||||
val targets =
|
||||
account.blossomServers.flow.value
|
||||
.filter { BlossomServerUrl.domain(it) != primaryDomain }
|
||||
.distinct()
|
||||
|
||||
if (targets.isEmpty()) return
|
||||
|
||||
targets.forEach { target ->
|
||||
try {
|
||||
val auth = account.createBlossomUploadAuth(hash, result.size ?: 0L, "Mirror $hash").toAuthorizationHeader()
|
||||
BlossomClient(Amethyst.instance.roleBasedHttpClientBuilder.okHttpClientForUploads(target))
|
||||
.mirror(sourceUrl, target, auth)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.w("UploadOrchestrator", "Failed to mirror $hash to $target", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
/*
|
||||
* 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.uploads.blossom
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.commons.service.upload.BlossomClient
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
/** Aggregate progress of a running "sync all my blobs to all my servers" sweep. */
|
||||
@Immutable
|
||||
data class BlossomSyncState(
|
||||
val total: Int,
|
||||
val done: Int,
|
||||
val failed: Int,
|
||||
val running: Boolean,
|
||||
) {
|
||||
val succeeded get() = done - failed
|
||||
val fraction get() = if (total == 0) 0f else done.toFloat() / total
|
||||
}
|
||||
|
||||
/** One completed mirror step, streamed so an open manager screen can flip its pill live. */
|
||||
@Immutable
|
||||
data class BlossomMirrorResult(
|
||||
val hash: HexKey,
|
||||
val server: String,
|
||||
val ok: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* App-level BUD-04 mirror queue, modeled on the PoW publish queue: it runs on the
|
||||
* application IO scope (via [Amethyst.instance]) so a sweep keeps going while the
|
||||
* user navigates the app, and exposes [state] for a floating progress banner mounted
|
||||
* at the navigation root. Servers that require payment are skipped (counted as
|
||||
* failed) — those are paid for individually from the manager screen.
|
||||
*/
|
||||
class BlossomMirrorQueue(
|
||||
private val scope: CoroutineScope,
|
||||
/** Invoked (on the foreground thread that called [start]) when a sweep begins, to start the FGS. */
|
||||
private val onActive: () -> Unit = {},
|
||||
) {
|
||||
data class Task(
|
||||
val hash: HexKey,
|
||||
val sourceUrl: String,
|
||||
val size: Long?,
|
||||
val targets: List<String>,
|
||||
)
|
||||
|
||||
private val _state = MutableStateFlow<BlossomSyncState?>(null)
|
||||
val state: StateFlow<BlossomSyncState?> = _state.asStateFlow()
|
||||
|
||||
private val _results = MutableSharedFlow<BlossomMirrorResult>(extraBufferCapacity = 128)
|
||||
val results: SharedFlow<BlossomMirrorResult> = _results.asSharedFlow()
|
||||
|
||||
private var job: Job? = null
|
||||
|
||||
val isRunning get() = _state.value?.running == true
|
||||
|
||||
/** Enqueue a sweep. No-op if one is already running or there's nothing to do. */
|
||||
fun start(
|
||||
account: Account,
|
||||
tasks: List<Task>,
|
||||
) {
|
||||
if (isRunning) return
|
||||
val work = tasks.flatMap { t -> t.targets.map { t to it } }
|
||||
if (work.isEmpty()) return
|
||||
|
||||
// Publish the active state and start the foreground service synchronously (we're on the
|
||||
// foreground thread here, which is what dataSync FGS starts require) before the sweep runs.
|
||||
_state.value = BlossomSyncState(total = work.size, done = 0, failed = 0, running = true)
|
||||
onActive()
|
||||
|
||||
// Parallelize ACROSS servers but stay serial WITHIN a server, so every server gets a
|
||||
// steady one-at-a-time stream (no hammering / rate-limit storms) while all of the user's
|
||||
// servers work at once. Wall-clock ≈ the slowest single server's queue, not the sum.
|
||||
val byServer: Map<String, List<Task>> = work.groupBy({ it.second }, { it.first })
|
||||
|
||||
job =
|
||||
scope.launch {
|
||||
byServer
|
||||
.map { (target, serverTasks) ->
|
||||
async {
|
||||
for (task in serverTasks) {
|
||||
val ok = mirrorOne(account, task, target)
|
||||
_results.tryEmit(BlossomMirrorResult(task.hash, target, ok))
|
||||
_state.update { it?.copy(done = it.done + 1, failed = it.failed + if (ok) 0 else 1) }
|
||||
}
|
||||
}
|
||||
}.awaitAll()
|
||||
_state.update { it?.copy(running = false) }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun mirrorOne(
|
||||
account: Account,
|
||||
task: Task,
|
||||
target: String,
|
||||
): Boolean =
|
||||
try {
|
||||
val auth = account.createBlossomUploadAuth(task.hash, task.size ?: 0L, "Mirror ${task.hash}").toAuthorizationHeader()
|
||||
BlossomClient(Amethyst.instance.roleBasedHttpClientBuilder.okHttpClientForUploads(target))
|
||||
.mirror(task.sourceUrl, target, auth)
|
||||
true
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.w("BlossomMirrorQueue", "mirror ${task.hash} -> $target failed", e)
|
||||
false
|
||||
}
|
||||
|
||||
/** Cancel a running sweep and clear the banner. */
|
||||
fun cancel() {
|
||||
job?.cancel()
|
||||
job = null
|
||||
_state.value = null
|
||||
}
|
||||
|
||||
/** Dismiss the finished-summary banner (no effect while still running). */
|
||||
fun dismiss() {
|
||||
if (!isRunning) _state.value = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
/*
|
||||
* 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.uploads.blossom
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.quartz.lightning.LnInvoiceUtil
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceSuccessResponse
|
||||
import com.vitorpamplona.quartz.nipB7Blossom.BlossomPaymentProof
|
||||
import com.vitorpamplona.quartz.nipB7Blossom.BlossomPaymentRequired
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
|
||||
/**
|
||||
* Settles a BUD-07 [BlossomPaymentRequired] challenge so a blocked upload/mirror can
|
||||
* be retried. Reuses the account's existing NIP-47 (Nostr Wallet Connect) lightning
|
||||
* path — this handler never touches keys or moves funds itself, it only asks the
|
||||
* user's connected wallet to pay the invoice and returns the resulting preimage as
|
||||
* the [BlossomPaymentProof].
|
||||
*
|
||||
* Cashu-only servers (`X-Cashu`, no `X-Lightning`) are not yet supported here; the
|
||||
* caller should surface that a lightning wallet is required.
|
||||
*/
|
||||
object BlossomPaymentHandler {
|
||||
/**
|
||||
* Hard ceiling on a single BUD-07 charge, in sats.
|
||||
*
|
||||
* BUD-07 charges are per-blob storage fees: real paid Blossom servers ask
|
||||
* single-digit to low-hundreds of sats for a media upload. 10,000 sats is
|
||||
* roughly USD 10 at a 100k BTC — one to two orders of magnitude above any
|
||||
* legitimate per-blob fee, so it never gets in a real user's way, while
|
||||
* capping what a hostile or compromised server can drain in one prompt.
|
||||
* Anything above this is refused outright rather than shown to the user,
|
||||
* because the value is server-chosen and a user tapping through a dialog is
|
||||
* not a meaningful defence against a four-digit-sat surprise.
|
||||
*/
|
||||
const val MAX_PAYMENT_SATS = 10_000L
|
||||
|
||||
/** Outcome of [pay]. Everything except [Paid] means no proof and no retry. */
|
||||
sealed interface PayResult {
|
||||
data class Paid(
|
||||
val proof: BlossomPaymentProof,
|
||||
) : PayResult
|
||||
|
||||
/** The amount failed [checkAmount]; [reason] is user-facing. */
|
||||
data class Refused(
|
||||
val reason: String,
|
||||
) : PayResult
|
||||
|
||||
/** No invoice, no wallet, or the wallet request could not be sent. */
|
||||
data object Unavailable : PayResult
|
||||
|
||||
/** The wallet never answered. The invoice stays blocked — see [InFlightInvoices]. */
|
||||
data object TimedOut : PayResult
|
||||
}
|
||||
|
||||
/** Verdict on the invoice amount, before any money moves. */
|
||||
sealed interface AmountCheck {
|
||||
data class Ok(
|
||||
val sats: Long,
|
||||
) : AmountCheck
|
||||
|
||||
/** BOLT-11 with no amount: the payee picks it. Never payable unattended. */
|
||||
data object Amountless : AmountCheck
|
||||
|
||||
data class OverCap(
|
||||
val sats: Long,
|
||||
) : AmountCheck
|
||||
|
||||
/** The invoice asks for something other than what the dialog told the user. */
|
||||
data class Mismatch(
|
||||
val shownSats: Long?,
|
||||
val actualSats: Long,
|
||||
) : AmountCheck
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-derives the amount from the invoice itself and checks it against both the
|
||||
* cap and [shownSats] — the number the confirmation dialog put in front of the
|
||||
* user. The amount shown must be the amount paid, so a server that swapped the
|
||||
* invoice (or leaned on a misleading `X-Reason`) cannot get a different sum
|
||||
* approved than the one the user agreed to.
|
||||
*/
|
||||
fun checkAmount(
|
||||
payment: BlossomPaymentRequired,
|
||||
shownSats: Long?,
|
||||
): AmountCheck {
|
||||
val actual = amountSats(payment) ?: return AmountCheck.Amountless
|
||||
if (actual > MAX_PAYMENT_SATS) return AmountCheck.OverCap(actual)
|
||||
if (shownSats != actual) return AmountCheck.Mismatch(shownSats, actual)
|
||||
return AmountCheck.Ok(actual)
|
||||
}
|
||||
|
||||
/** Human-readable refusal text for a non-[AmountCheck.Ok] verdict. */
|
||||
fun refusalReason(check: AmountCheck): String =
|
||||
when (check) {
|
||||
is AmountCheck.Ok -> ""
|
||||
is AmountCheck.Amountless -> "The server's invoice does not state an amount. Amethyst will not pay it."
|
||||
is AmountCheck.OverCap -> "The server asked for ${check.sats} sats, above the $MAX_PAYMENT_SATS sat limit for a media-server payment. Nothing was paid."
|
||||
is AmountCheck.Mismatch ->
|
||||
"The server's invoice is for ${check.actualSats} sats, not the ${check.shownSats ?: 0} sats shown. Nothing was paid."
|
||||
}
|
||||
|
||||
/** True when this account has a wallet we can pay the lightning invoice with. */
|
||||
fun canPay(
|
||||
account: Account,
|
||||
payment: BlossomPaymentRequired,
|
||||
): Boolean = payment.lightning != null && account.nip47SignerState.hasWalletConnectSetup()
|
||||
|
||||
/**
|
||||
* The invoice amount in sats for display in a confirmation prompt, or null when it is absent or
|
||||
* unreadable. `getAmountInSats` returns ZERO for an amountless BOLT11 rather than null, so a bare
|
||||
* read renders "Pay 0 sats" — telling the user a payment is free when the amount is actually
|
||||
* unspecified and chosen by the payee.
|
||||
*/
|
||||
fun amountSats(payment: BlossomPaymentRequired): Long? =
|
||||
payment.lightning?.let {
|
||||
runCatching { LnInvoiceUtil.getAmountInSats(it).toLong() }.getOrNull()?.takeIf { sats -> sats > 0 }
|
||||
}
|
||||
|
||||
/**
|
||||
* Pays the challenge's BOLT-11 invoice via NWC and returns the proof.
|
||||
*
|
||||
* [shownSats] is what the confirmation dialog displayed; the invoice is
|
||||
* re-read here and must match it and sit under [MAX_PAYMENT_SATS], otherwise
|
||||
* nothing is sent to the wallet at all.
|
||||
*/
|
||||
suspend fun pay(
|
||||
account: Account,
|
||||
payment: BlossomPaymentRequired,
|
||||
shownSats: Long?,
|
||||
): PayResult {
|
||||
val invoice = payment.lightning ?: return PayResult.Unavailable
|
||||
if (!account.nip47SignerState.hasWalletConnectSetup()) return PayResult.Unavailable
|
||||
|
||||
val check = checkAmount(payment, shownSats)
|
||||
if (check !is AmountCheck.Ok) {
|
||||
Log.w("BlossomPayment", "refusing invoice: ${refusalReason(check)}")
|
||||
return PayResult.Refused(refusalReason(check))
|
||||
}
|
||||
|
||||
// Never send the same invoice twice: an earlier attempt may still settle.
|
||||
if (!InFlightInvoices.tryClaim(invoice)) {
|
||||
return PayResult.Refused(
|
||||
"A payment for this invoice was already sent to your wallet and never confirmed. Amethyst will not pay it again.",
|
||||
)
|
||||
}
|
||||
|
||||
val preimageResult = CompletableDeferred<String?>()
|
||||
try {
|
||||
account.sendZapPaymentRequestFor(invoice, null) { response ->
|
||||
// CompletableDeferred.complete is idempotent, so extra callbacks are harmless.
|
||||
preimageResult.complete((response as? PayInvoiceSuccessResponse)?.result?.preimage)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w("BlossomPayment", "Failed to send NWC payment request", e)
|
||||
// The request never left, so the invoice is definitively not in flight.
|
||||
InFlightInvoices.release(invoice)
|
||||
return PayResult.Unavailable
|
||||
}
|
||||
|
||||
// NIP-47 offers no cancel for an outstanding pay_invoice, so a timeout
|
||||
// cannot stop the payment — it can only stop us from sending it again.
|
||||
// Deliberately do NOT release the claim on the timeout path.
|
||||
val answered = withTimeoutOrNull(PAYMENT_TIMEOUT_MS) { preimageResult.await() }
|
||||
if (answered == null && !preimageResult.isCompleted) return PayResult.TimedOut
|
||||
|
||||
InFlightInvoices.release(invoice)
|
||||
val preimage = answered ?: return PayResult.Unavailable
|
||||
return PayResult.Paid(BlossomPaymentProof(lightningPreimage = preimage))
|
||||
}
|
||||
|
||||
/**
|
||||
* How long we wait for the wallet. Matches the previous behaviour; note the
|
||||
* NIP-47 filter itself is dropped after 60s, so a reply past 90s cannot reach
|
||||
* us anyway — which is exactly why the invoice stays blocked afterwards.
|
||||
*/
|
||||
private const val PAYMENT_TIMEOUT_MS = 90_000L
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* 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.uploads.blossom
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.ServiceInfo
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.service.foreground.FlowProgressForegroundService
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
|
||||
/**
|
||||
* Foreground service that keeps the BUD-04 "sync all" sweep ([BlossomMirrorQueue]) running
|
||||
* while the app is backgrounded. Uses the Android 14+ `dataSync` type — the correct type for
|
||||
* an upload/download/sync operation (a multi-file mirror routinely exceeds the `shortService`
|
||||
* ~3-minute budget that PoW mining uses).
|
||||
*
|
||||
* `dataSync` must be started while the app is foreground; "Sync all" is user-initiated from the
|
||||
* manager, so that holds. All of the notification + lifecycle lives in the shared
|
||||
* [FlowProgressForegroundService]; this maps the sweep's [BlossomSyncState] onto the card.
|
||||
*/
|
||||
class BlossomSyncForegroundService : FlowProgressForegroundService<BlossomSyncState?>() {
|
||||
override val fgsType: Int = ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
|
||||
override val channelId = CHANNEL_ID
|
||||
override val channelNameRes = R.string.blossom_sync_channel_name
|
||||
override val channelDescRes = R.string.blossom_sync_channel_description
|
||||
override val notificationId = NOTIFICATION_ID
|
||||
override val cancelAction = ACTION_CANCEL
|
||||
override val cancelLabelRes = R.string.blossom_sync_cancel
|
||||
|
||||
override fun state() = Amethyst.instance.blossomMirrorQueue.state
|
||||
|
||||
override fun isActive(value: BlossomSyncState?) = value?.running == true
|
||||
|
||||
override fun cancelAll() = Amethyst.instance.blossomMirrorQueue.cancel()
|
||||
|
||||
// State emits on every mirror step (including currentHost changes), so no clock refresh.
|
||||
override val refreshMs: Long? = null
|
||||
|
||||
override fun render(value: BlossomSyncState?): Content {
|
||||
if (value == null) return Content(stringRes(this, R.string.blossom_syncing), null, Bar.Indeterminate)
|
||||
val text =
|
||||
buildString {
|
||||
append("${value.done} / ${value.total}")
|
||||
if (value.failed > 0) append(" · ${value.failed} failed")
|
||||
}
|
||||
return Content(stringRes(this, R.string.blossom_syncing), text, Bar.Determinate(value.fraction.toDouble()))
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "BlossomSyncFgs"
|
||||
private const val CHANNEL_ID = "blossom_sync"
|
||||
private const val NOTIFICATION_ID = 0x424C4F // "BLO"
|
||||
private const val ACTION_CANCEL = "com.vitorpamplona.amethyst.blossom.SYNC_CANCEL"
|
||||
|
||||
/**
|
||||
* Started when a sweep begins (from the foreground); stops itself when it finishes.
|
||||
* No `running` de-dup: a sweep starts this exactly once (via [BlossomMirrorQueue.onActive]),
|
||||
* and a redundant start would just route to a cheap onStartCommand — whereas a stale
|
||||
* "already running" flag could leave a fresh sweep with no foreground protection.
|
||||
*/
|
||||
fun start(context: Context) {
|
||||
FlowProgressForegroundService.start(context, BlossomSyncForegroundService::class.java, TAG)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import android.net.Uri
|
||||
import android.provider.OpenableColumns
|
||||
import android.webkit.MimeTypeMap
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.service.upload.BlossomPaymentException
|
||||
import com.vitorpamplona.amethyst.service.HttpStatusMessages
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.service.uploads.MediaUploadResult
|
||||
@@ -36,6 +37,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent
|
||||
import com.vitorpamplona.quartz.nipB7Blossom.BlossomPaymentRequired
|
||||
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServerUrl
|
||||
import com.vitorpamplona.quartz.nipB7Blossom.BlossomUploadResult
|
||||
import com.vitorpamplona.quartz.utils.RandomInstance
|
||||
@@ -79,6 +81,7 @@ class BlossomUploader {
|
||||
okHttpClient: (String) -> OkHttpClient,
|
||||
httpAuth: suspend (hash: HexKey, size: Long, alt: String) -> BlossomAuthorizationEvent?,
|
||||
context: Context,
|
||||
useMediaEndpoint: Boolean = false,
|
||||
onProgress: ((bytesWritten: Long, totalBytes: Long) -> Unit)? = null,
|
||||
): MediaUploadResult {
|
||||
checkNotInMainThread()
|
||||
@@ -115,6 +118,7 @@ class BlossomUploader {
|
||||
okHttpClient,
|
||||
httpAuth,
|
||||
context,
|
||||
useMediaEndpoint,
|
||||
onProgress,
|
||||
)
|
||||
}.mergeLocalMetadata(localMetadata)
|
||||
@@ -132,6 +136,7 @@ class BlossomUploader {
|
||||
okHttpClient: (String) -> OkHttpClient,
|
||||
httpAuth: suspend (hash: HexKey, size: Long, alt: String) -> BlossomAuthorizationEvent?,
|
||||
context: Context,
|
||||
useMediaEndpoint: Boolean = false,
|
||||
onProgress: ((bytesWritten: Long, totalBytes: Long) -> Unit)? = null,
|
||||
): MediaUploadResult {
|
||||
checkNotInMainThread()
|
||||
@@ -142,7 +147,8 @@ class BlossomUploader {
|
||||
MimeTypeMap.getSingleton().getExtensionFromMimeType(it) ?: extensionFromMimeType(it)
|
||||
} ?: ""
|
||||
|
||||
val apiUrl = BlossomServerUrl.upload(serverBaseUrl)
|
||||
// BUD-05: /media asks the server to optimize; /upload stores the exact bytes.
|
||||
val apiUrl = if (useMediaEndpoint) BlossomServerUrl.media(serverBaseUrl) else BlossomServerUrl.upload(serverBaseUrl)
|
||||
|
||||
val client = okHttpClient(apiUrl)
|
||||
val requestBuilder = Request.Builder()
|
||||
@@ -200,6 +206,10 @@ class BlossomUploader {
|
||||
response.body.use { body ->
|
||||
convertToMediaResult(parseResults(body.string()))
|
||||
}
|
||||
} else if (response.code == 402) {
|
||||
// BUD-07: paid server. Surface the payment challenge so the caller can
|
||||
// tell the user (auto-settlement via the NIP-60 wallet is a follow-up).
|
||||
throw BlossomPaymentException(serverBaseUrl, BlossomPaymentRequired.fromHeaders { response.headers[it] })
|
||||
} else {
|
||||
val errorMessage = response.headers.get(BlossomServerUrl.REASON_HEADER)
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* 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.uploads.blossom
|
||||
|
||||
/**
|
||||
* BOLT-11 invoices handed to the NIP-47 wallet whose fate we never learned.
|
||||
*
|
||||
* [BlossomPaymentHandler.pay] waits a bounded time for the wallet to reply, but
|
||||
* NIP-47 has no "cancel this pay_invoice": the request is fire-and-forget, so
|
||||
* giving up on the wait does **not** stop the payment. An invoice that settles a
|
||||
* second after we time out still moved the user's money — and if we then let the
|
||||
* user (or an automatic retry) send the very same invoice again, they pay twice.
|
||||
*
|
||||
* So: claim the invoice before sending it, and release the claim only when the
|
||||
* wallet gives a definitive answer. A timed-out invoice stays claimed for the
|
||||
* life of the process and can never be paid a second time from this app.
|
||||
*
|
||||
* This is the weaker half of the fix — a genuine cancel would be better, but the
|
||||
* NIP-47 client offers none, so we settle for "never silently pay it twice".
|
||||
*/
|
||||
object InFlightInvoices {
|
||||
private val claimed = mutableSetOf<String>()
|
||||
|
||||
/**
|
||||
* Claims [invoice] for one payment attempt. Returns false when it was already
|
||||
* claimed and never resolved — the caller must not send it again.
|
||||
*/
|
||||
fun tryClaim(invoice: String): Boolean = synchronized(claimed) { claimed.add(invoice) }
|
||||
|
||||
/** The wallet gave a definitive answer (paid or explicitly failed): the claim can go. */
|
||||
fun release(invoice: String) {
|
||||
synchronized(claimed) { claimed.remove(invoice) }
|
||||
}
|
||||
|
||||
/** True when [invoice] was sent to the wallet and never resolved. */
|
||||
fun isAwaiting(invoice: String): Boolean = synchronized(claimed) { invoice in claimed }
|
||||
|
||||
/** Test-only reset. */
|
||||
internal fun clear() {
|
||||
synchronized(claimed) { claimed.clear() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* 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.uploads.blossom
|
||||
|
||||
/**
|
||||
* Bounds how often one user action may raise a BUD-07 payment prompt.
|
||||
*
|
||||
* The mirror flow retries a target after paying it, and that retry catches every
|
||||
* exception — including a second `BlossomPaymentException`. Left unbounded, a
|
||||
* server that pockets the preimage and answers 402 again drives an indefinite
|
||||
* pay-prompt cycle: pay → 402 → prompt → pay → 402 → … Each cycle is a real
|
||||
* payment, so "the user can always tap cancel" is not an adequate answer.
|
||||
*
|
||||
* Rule: a given (blob, server) pair may prompt at most once per user-initiated
|
||||
* action. [beginUserAction] resets the ledger; everything downstream of that tap
|
||||
* — including the post-payment retry — goes through [shouldPrompt].
|
||||
*/
|
||||
class PaymentPromptLedger {
|
||||
private val prompted = mutableSetOf<String>()
|
||||
|
||||
/** The user tapped mirror/sync: a fresh budget of one prompt per target. */
|
||||
fun beginUserAction() {
|
||||
synchronized(prompted) { prompted.clear() }
|
||||
}
|
||||
|
||||
/**
|
||||
* True the first time this (blob, server) pair asks for payment in the current
|
||||
* user action; false on every subsequent 402 from the same pair.
|
||||
*/
|
||||
fun shouldPrompt(
|
||||
hash: String,
|
||||
server: String,
|
||||
): Boolean = synchronized(prompted) { prompted.add("$hash|$server") }
|
||||
}
|
||||
@@ -53,6 +53,7 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.GroupInviteLink
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.GroupNAddrInvite
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
|
||||
import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectURI
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
|
||||
@@ -224,6 +225,12 @@ fun uriToRoute(
|
||||
return connectedAppRoute(uri)
|
||||
}
|
||||
|
||||
// A scanned/opened `nostrconnect://` offer is an app asking to connect to our signer: open the
|
||||
// NIP-46 signer screen and let it run the pairing (it enables the signer as part of connecting).
|
||||
if (uri.startsWith(NostrConnectURI.NOSTRCONNECT_SCHEME)) {
|
||||
return Route.Nip46Signer(connectUri = uri)
|
||||
}
|
||||
|
||||
relayGroupInviteRoute(uri)?.let { return it }
|
||||
concordInviteRoute(uri)?.let { return it }
|
||||
|
||||
|
||||
@@ -20,103 +20,155 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.actions.mediaServers
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
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.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
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.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.SettingsCategory
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.SettingsCategoryWithButton
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayDragState
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.draggableRelayItem
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relayDragHandle
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.rememberRelayDragState
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleVertPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.SettingsCategoryFirstModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.SettingsCategorySpacingModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.allGoodColor
|
||||
import com.vitorpamplona.amethyst.ui.theme.grayText
|
||||
import com.vitorpamplona.amethyst.ui.theme.warningColor
|
||||
import com.vitorpamplona.quartz.utils.Rfc3986
|
||||
|
||||
/** Vibrant palette for server monograms; picked deterministically from the host name. */
|
||||
private val MonogramColors =
|
||||
listOf(
|
||||
Color(0xFF8B5CF6),
|
||||
Color(0xFF0EA5A0),
|
||||
Color(0xFFE07B00),
|
||||
Color(0xFF4169E1),
|
||||
Color(0xFFD16D8F),
|
||||
Color(0xFF4F9D4F),
|
||||
Color(0xFFB66605),
|
||||
Color(0xFF7C6FE0),
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun AllMediaBody(blossomServersViewModel: BlossomServersViewModel) {
|
||||
fun AllMediaBody(
|
||||
blossomServersViewModel: BlossomServersViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val blossomServersState by blossomServersViewModel.fileServers.collectAsStateWithLifecycle()
|
||||
val healthState by blossomServersViewModel.health.collectAsStateWithLifecycle()
|
||||
|
||||
val dragState =
|
||||
rememberRelayDragState(
|
||||
onMove = { from, to -> blossomServersViewModel.moveServer(from, to) },
|
||||
itemCount = { blossomServersState.size },
|
||||
)
|
||||
|
||||
// Auto-save the reordering once the drag finishes, rather than on every intermediate swap.
|
||||
LaunchedEffect(dragState.isDragging) {
|
||||
if (!dragState.isDragging) blossomServersViewModel.persistPending()
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
verticalArrangement = Arrangement.SpaceAround,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = modifier,
|
||||
contentPadding = FeedPadding,
|
||||
userScrollEnabled = !dragState.isDragging,
|
||||
) {
|
||||
item {
|
||||
SettingsCategory(
|
||||
R.string.media_servers_blossom_section,
|
||||
R.string.media_servers_blossom_explainer,
|
||||
SettingsCategoryFirstModifier,
|
||||
SectionLabel(
|
||||
title = stringRes(id = R.string.media_servers_priority_section),
|
||||
caption = stringRes(id = R.string.media_servers_reorder_hint),
|
||||
topPadding = 4.dp,
|
||||
)
|
||||
}
|
||||
|
||||
renderMediaServerList(
|
||||
mediaServersState = blossomServersState,
|
||||
keyType = "blossom",
|
||||
editLabel = R.string.add_a_blossom_server,
|
||||
emptyLabel = R.string.no_blossom_server_message,
|
||||
onAddServer = { server ->
|
||||
blossomServersViewModel.addServer(server)
|
||||
},
|
||||
onDeleteServer = {
|
||||
blossomServersViewModel.removeServer(serverUrl = it)
|
||||
},
|
||||
)
|
||||
|
||||
DEFAULT_MEDIA_SERVERS.let {
|
||||
if (blossomServersState.isEmpty()) {
|
||||
item {
|
||||
SettingsCategoryWithButton(
|
||||
title = R.string.recommended_media_servers,
|
||||
description = R.string.built_in_servers_description,
|
||||
modifier = SettingsCategorySpacingModifier,
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
blossomServersViewModel.addServerList(
|
||||
it.mapNotNull { s -> if (s.type == ServerType.Blossom) s.baseUrl else null },
|
||||
)
|
||||
},
|
||||
) {
|
||||
Text(text = stringRes(id = R.string.use_default_servers))
|
||||
}
|
||||
}
|
||||
}
|
||||
itemsIndexed(
|
||||
it,
|
||||
key = { _: Int, server: ServerName ->
|
||||
"Proposed" + server.baseUrl
|
||||
},
|
||||
) { _, server ->
|
||||
MediaServerEntry(
|
||||
serverEntry = server,
|
||||
isAmethystDefault = true,
|
||||
onAddOrDelete = { serverUrl ->
|
||||
if (server.type == ServerType.Blossom) {
|
||||
blossomServersViewModel.addServer(serverUrl)
|
||||
}
|
||||
},
|
||||
Text(
|
||||
text = stringRes(id = R.string.no_blossom_server_message),
|
||||
modifier = DoubleVertPadding,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
itemsIndexed(
|
||||
blossomServersState,
|
||||
key = { _, server -> "blossom" + server.baseUrl },
|
||||
) { index, entry ->
|
||||
MediaServerRow(
|
||||
index = index,
|
||||
serverEntry = entry,
|
||||
health = healthState[entry.baseUrl] ?: ServerHealth.Unknown,
|
||||
dragState = dragState,
|
||||
onDelete = { blossomServersViewModel.removeServer(serverUrl = it) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
AddServerSection(
|
||||
// Server entries carry their host in `name`; match recommended chips by host so
|
||||
// normalization differences in the URL don't hide the "added" state.
|
||||
addedHosts = blossomServersState.mapTo(HashSet()) { it.name },
|
||||
onAddServer = { blossomServersViewModel.addServer(it) },
|
||||
onAddAll = {
|
||||
blossomServersViewModel.addServerList(
|
||||
DEFAULT_MEDIA_SERVERS.mapNotNull { s -> if (s.type == ServerType.Blossom) s.baseUrl else null },
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
SectionLabel(title = stringRes(id = R.string.media_servers_upload_section))
|
||||
UploadBehaviorSection(accountViewModel, nav)
|
||||
}
|
||||
|
||||
item {
|
||||
SectionLabel(title = stringRes(id = R.string.media_servers_cache_section))
|
||||
MediaCacheSection(accountViewModel)
|
||||
}
|
||||
|
||||
item {
|
||||
@@ -125,97 +177,449 @@ fun AllMediaBody(blossomServersViewModel: BlossomServersViewModel) {
|
||||
}
|
||||
}
|
||||
|
||||
fun LazyListScope.renderMediaServerList(
|
||||
mediaServersState: List<ServerName>,
|
||||
keyType: String,
|
||||
editLabel: Int,
|
||||
emptyLabel: Int,
|
||||
onAddServer: (String) -> Unit,
|
||||
onDeleteServer: (String) -> Unit,
|
||||
/**
|
||||
* Upload-side Blossom controls: mirror uploads across the user's servers (BUD-04),
|
||||
* optimize via `/media` (BUD-05), and a shortcut into the blob manager.
|
||||
*/
|
||||
@Composable
|
||||
private fun UploadBehaviorSection(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
if (mediaServersState.isEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
text = stringRes(id = emptyLabel),
|
||||
modifier = DoubleVertPadding,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
itemsIndexed(
|
||||
mediaServersState,
|
||||
key = { _: Int, server: ServerName ->
|
||||
keyType + server.baseUrl
|
||||
},
|
||||
) { _, entry ->
|
||||
MediaServerEntry(
|
||||
serverEntry = entry,
|
||||
onAddOrDelete = {
|
||||
onDeleteServer(it)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
val mirror by accountViewModel.account.settings.mirrorUploadsToAllServers
|
||||
.collectAsStateWithLifecycle()
|
||||
val optimize by accountViewModel.account.settings.optimizeMediaOnUpload
|
||||
.collectAsStateWithLifecycle()
|
||||
|
||||
item {
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
MediaServerEditField(editLabel) {
|
||||
onAddServer(it)
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(20.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer),
|
||||
) {
|
||||
UploadToggleRow(
|
||||
title = stringRes(id = R.string.blossom_mirror_uploads),
|
||||
caption = stringRes(id = R.string.blossom_mirror_uploads_caption),
|
||||
checked = mirror,
|
||||
onCheckedChange = { accountViewModel.account.settings.changeMirrorUploadsToAllServers(it) },
|
||||
)
|
||||
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(horizontal = 14.dp),
|
||||
color = MaterialTheme.colorScheme.outlineVariant,
|
||||
)
|
||||
|
||||
UploadToggleRow(
|
||||
title = stringRes(id = R.string.blossom_optimize_media),
|
||||
caption = stringRes(id = R.string.blossom_optimize_media_caption),
|
||||
checked = optimize,
|
||||
onCheckedChange = { accountViewModel.account.settings.changeOptimizeMediaOnUpload(it) },
|
||||
)
|
||||
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(horizontal = 14.dp),
|
||||
color = MaterialTheme.colorScheme.outlineVariant,
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { nav.nav(Route.ManageBlossomBlobs) }
|
||||
.padding(14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = stringRes(id = R.string.my_blossom_data),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Storage,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MediaServerEntry(
|
||||
modifier: Modifier = Modifier,
|
||||
serverEntry: ServerName,
|
||||
isAmethystDefault: Boolean = false,
|
||||
onAddOrDelete: (serverUrl: String) -> Unit,
|
||||
private fun UploadToggleRow(
|
||||
title: String,
|
||||
caption: String,
|
||||
checked: Boolean,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 10.dp),
|
||||
modifier = Modifier.fillMaxWidth().padding(14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceAround,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
|
||||
Text(text = title, style = MaterialTheme.typography.bodyLarge)
|
||||
Text(
|
||||
text = caption,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.grayText,
|
||||
)
|
||||
}
|
||||
Switch(checked = checked, onCheckedChange = onCheckedChange)
|
||||
}
|
||||
}
|
||||
|
||||
/** Compact section header: an accent label with an optional gray caption below. */
|
||||
@Composable
|
||||
private fun SectionLabel(
|
||||
title: String,
|
||||
caption: String? = null,
|
||||
topPadding: Dp = 20.dp,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(modifier = modifier.fillMaxWidth().padding(top = topPadding, bottom = 8.dp)) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
if (caption != null) {
|
||||
Text(
|
||||
text = caption,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.grayText,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A draggable, ranked server card. Position in the list is the upload/fallback priority
|
||||
* (row #1 is tried first), so each card carries its rank on the monogram and a drag handle
|
||||
* wired into the shared [RelayDragState], plus a live reachability dot. The primary target
|
||||
* (#1) is called out with an accent border, tint, and badge.
|
||||
*/
|
||||
@Composable
|
||||
fun MediaServerRow(
|
||||
index: Int,
|
||||
serverEntry: ServerName,
|
||||
health: ServerHealth,
|
||||
dragState: RelayDragState,
|
||||
onDelete: (serverUrl: String) -> Unit,
|
||||
) {
|
||||
val isPrimary = index == 0
|
||||
val shape = RoundedCornerShape(16.dp)
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 5.dp)
|
||||
.draggableRelayItem(index, dragState)
|
||||
.clip(shape)
|
||||
.background(
|
||||
if (isPrimary) {
|
||||
MaterialTheme.colorScheme.primary.copy(alpha = 0.06f)
|
||||
} else {
|
||||
Color.Transparent
|
||||
},
|
||||
).border(
|
||||
width = if (isPrimary) 1.5.dp else 1.dp,
|
||||
color = if (isPrimary) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant,
|
||||
shape = shape,
|
||||
).padding(start = 6.dp, top = 8.dp, bottom = 8.dp, end = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.DragIndicator,
|
||||
contentDescription = stringRes(id = R.string.media_server_reorder),
|
||||
modifier = Modifier.size(22.dp).relayDragHandle(index, dragState),
|
||||
tint = MaterialTheme.colorScheme.grayText,
|
||||
)
|
||||
|
||||
Spacer(Modifier.size(8.dp))
|
||||
|
||||
ServerAvatar(name = serverEntry.name, rank = index + 1, isPrimary = isPrimary)
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.weight(1f),
|
||||
modifier = Modifier.weight(1f).padding(start = 12.dp),
|
||||
) {
|
||||
serverEntry.let {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = it.name.replaceFirstChar(Char::titlecase),
|
||||
text = serverEntry.name.replaceFirstChar(Char::titlecase),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
Text(
|
||||
text = it.baseUrl,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.grayText,
|
||||
)
|
||||
if (isPrimary) {
|
||||
Spacer(Modifier.size(6.dp))
|
||||
PrimaryBadge()
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = serverEntry.baseUrl,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.grayText,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
onAddOrDelete(serverEntry.baseUrl)
|
||||
HealthIndicator(health)
|
||||
|
||||
IconButton(onClick = { onDelete(serverEntry.baseUrl) }) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Delete,
|
||||
contentDescription = stringRes(id = R.string.delete_media_server),
|
||||
tint = MaterialTheme.colorScheme.grayText,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline "add a server" area: a URL field followed by the recommended servers as a
|
||||
* horizontal strip of add-chips (already-added ones read as done).
|
||||
*/
|
||||
@Composable
|
||||
private fun AddServerSection(
|
||||
addedHosts: Set<String>,
|
||||
onAddServer: (String) -> Unit,
|
||||
onAddAll: () -> Unit,
|
||||
) {
|
||||
SectionLabel(title = stringRes(id = R.string.media_servers_add_section))
|
||||
|
||||
// Recommended servers first — one tap adds a known-good host.
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = stringRes(id = R.string.media_servers_recommended_label),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.grayText,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
TextButton(onClick = onAddAll) {
|
||||
Text(text = stringRes(id = R.string.use_default_servers))
|
||||
}
|
||||
}
|
||||
|
||||
LazyRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
contentPadding = PaddingValues(vertical = 4.dp),
|
||||
) {
|
||||
items(
|
||||
DEFAULT_MEDIA_SERVERS,
|
||||
key = { it.baseUrl },
|
||||
) { server ->
|
||||
val host = runCatching { Rfc3986.host(server.baseUrl) }.getOrNull()
|
||||
RecommendedChip(
|
||||
serverEntry = server,
|
||||
added = host != null && host in addedHosts,
|
||||
onAdd = { onAddServer(server.baseUrl) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ...or paste any server address.
|
||||
Text(
|
||||
text = stringRes(id = R.string.media_servers_add_url_label),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.grayText,
|
||||
modifier = Modifier.padding(top = 16.dp, bottom = 8.dp),
|
||||
)
|
||||
MediaServerEditField(R.string.add_a_blossom_server) { onAddServer(it) }
|
||||
}
|
||||
|
||||
/** A recommended server as a tappable pill. Once added it reads as done and stops responding. */
|
||||
@Composable
|
||||
private fun RecommendedChip(
|
||||
serverEntry: ServerName,
|
||||
added: Boolean,
|
||||
onAdd: () -> Unit,
|
||||
) {
|
||||
val shape = RoundedCornerShape(50)
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.clip(shape)
|
||||
.then(
|
||||
if (added) {
|
||||
Modifier.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
} else {
|
||||
Modifier.border(1.dp, MaterialTheme.colorScheme.outlineVariant, shape)
|
||||
},
|
||||
).clickable(enabled = !added, onClick = onAdd)
|
||||
.padding(start = 6.dp, end = 12.dp, top = 6.dp, bottom = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
ServerMonogram(name = serverEntry.name, size = 24.dp)
|
||||
Text(
|
||||
text = serverEntry.name.replaceFirstChar(Char::titlecase),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
)
|
||||
Icon(
|
||||
symbol = if (added) MaterialSymbols.CheckCircle else MaterialSymbols.Add,
|
||||
contentDescription =
|
||||
if (added) {
|
||||
stringRes(id = R.string.media_server_added)
|
||||
} else {
|
||||
stringRes(id = R.string.add_media_server)
|
||||
},
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = if (added) MaterialTheme.colorScheme.grayText else MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** A colored letter tile identifying a server, derived from its host name. */
|
||||
@Composable
|
||||
private fun ServerMonogram(
|
||||
name: String,
|
||||
size: Dp,
|
||||
) {
|
||||
val letter = name.firstOrNull { it.isLetterOrDigit() }?.uppercaseChar()?.toString() ?: "?"
|
||||
val color = MonogramColors[((name.hashCode() % MonogramColors.size) + MonogramColors.size) % MonogramColors.size]
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(size)
|
||||
.clip(RoundedCornerShape(size / 3))
|
||||
.background(color),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = letter,
|
||||
style = if (size >= 30.dp) MaterialTheme.typography.labelLarge else MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The server's monogram with its priority rank as a small corner badge — one visual unit
|
||||
* for identity + position. The rank chip is accent-filled for the primary target (#1).
|
||||
*/
|
||||
@Composable
|
||||
private fun ServerAvatar(
|
||||
name: String,
|
||||
rank: Int,
|
||||
isPrimary: Boolean,
|
||||
) {
|
||||
Box(modifier = Modifier.size(40.dp)) {
|
||||
ServerMonogram(name = name, size = 36.dp)
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.size(18.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.padding(1.5.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
if (isPrimary) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceVariant
|
||||
},
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
symbol = if (isAmethystDefault) MaterialSymbols.Add else MaterialSymbols.Delete,
|
||||
contentDescription =
|
||||
if (isAmethystDefault) {
|
||||
stringRes(id = R.string.add_media_server)
|
||||
Text(
|
||||
text = rank.toString(),
|
||||
fontSize = 9.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color =
|
||||
if (isPrimary) {
|
||||
MaterialTheme.colorScheme.onPrimary
|
||||
} else {
|
||||
stringRes(id = R.string.delete_media_server)
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Small "Primary" pill shown on the #1 server. */
|
||||
@Composable
|
||||
private fun PrimaryBadge() {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(5.dp))
|
||||
.background(MaterialTheme.colorScheme.primaryContainer)
|
||||
.padding(horizontal = 6.dp, vertical = 1.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringRes(id = R.string.media_server_primary_badge),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Colored reachability dot + label, or a spinner while a probe is in flight. */
|
||||
@Composable
|
||||
private fun HealthIndicator(health: ServerHealth) {
|
||||
if (health == ServerHealth.Unknown) return
|
||||
|
||||
if (health == ServerHealth.Checking) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(14.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.grayText,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val color: Color
|
||||
val label: Int
|
||||
when (health) {
|
||||
ServerHealth.Online -> {
|
||||
color = MaterialTheme.colorScheme.allGoodColor
|
||||
label = R.string.media_server_status_online
|
||||
}
|
||||
ServerHealth.Slow -> {
|
||||
color = MaterialTheme.colorScheme.warningColor
|
||||
label = R.string.media_server_status_slow
|
||||
}
|
||||
else -> {
|
||||
color = MaterialTheme.colorScheme.error
|
||||
label = R.string.media_server_status_offline
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(5.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(9.dp)
|
||||
.clip(CircleShape)
|
||||
.background(color),
|
||||
)
|
||||
Text(
|
||||
text = stringRes(id = label),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = color,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,9 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.actions.mediaServers
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
@@ -28,6 +30,9 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -39,15 +44,18 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.allGoodColor
|
||||
import com.vitorpamplona.amethyst.ui.theme.grayText
|
||||
|
||||
@Composable
|
||||
@@ -63,9 +71,7 @@ fun AllMediaServersScreen(
|
||||
blossomServersViewModel.load()
|
||||
}
|
||||
|
||||
MediaServersScaffold(blossomServersViewModel, accountViewModel) {
|
||||
nav.popBack()
|
||||
}
|
||||
MediaServersScaffold(blossomServersViewModel, accountViewModel, nav)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@@ -73,24 +79,20 @@ fun AllMediaServersScreen(
|
||||
fun MediaServersScaffold(
|
||||
blossomServersViewModel: BlossomServersViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
onClose: () -> Unit,
|
||||
nav: INav,
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
SavingTopBar(
|
||||
titleRes = R.string.media_servers,
|
||||
onCancel = {
|
||||
blossomServersViewModel.refresh()
|
||||
onClose()
|
||||
},
|
||||
onPost = {
|
||||
blossomServersViewModel.saveFileServers()
|
||||
onClose()
|
||||
},
|
||||
TopBarWithBackButton(
|
||||
caption = stringRes(id = R.string.media_servers),
|
||||
nav = nav,
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
AllMediaBody(
|
||||
blossomServersViewModel = blossomServersViewModel,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
@@ -101,43 +103,49 @@ fun MediaServersScaffold(
|
||||
bottom = padding.calculateBottomPadding(),
|
||||
).consumeWindowInsets(padding)
|
||||
.imePadding(),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp, alignment = Alignment.Top),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = stringRes(id = R.string.set_preferred_media_servers),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 10.dp),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.grayText,
|
||||
)
|
||||
|
||||
LocalBlossomCacheToggle(accountViewModel)
|
||||
HorizontalDivider()
|
||||
|
||||
AllMediaBody(blossomServersViewModel)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The on-device Blossom cache, rendered as a self-contained card so it reads as its
|
||||
* own feature rather than a stray toggle. Binds to the same two account settings as
|
||||
* before.
|
||||
*/
|
||||
@Composable
|
||||
private fun LocalBlossomCacheToggle(accountViewModel: AccountViewModel) {
|
||||
fun MediaCacheSection(accountViewModel: AccountViewModel) {
|
||||
val enabled by accountViewModel.account.settings.useLocalBlossomCache
|
||||
.collectAsStateWithLifecycle()
|
||||
val profilePicturesOnly by accountViewModel.account.settings.localBlossomCacheProfilePicturesOnly
|
||||
.collectAsStateWithLifecycle()
|
||||
val probeAvailable by accountViewModel.useLocalBlossomBridgeForProfilePics
|
||||
.collectAsStateWithLifecycle()
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(20.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier = Modifier.fillMaxWidth().padding(14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(36.dp)
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(MaterialTheme.colorScheme.secondaryContainer),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Storage,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
)
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f).padding(start = 14.dp, end = 12.dp)) {
|
||||
Text(
|
||||
text = stringRes(id = R.string.use_local_blossom_cache),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
@@ -147,18 +155,6 @@ private fun LocalBlossomCacheToggle(accountViewModel: AccountViewModel) {
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.grayText,
|
||||
)
|
||||
Text(
|
||||
text =
|
||||
if (enabled && probeAvailable) {
|
||||
stringRes(id = R.string.local_blossom_cache_detected)
|
||||
} else if (enabled) {
|
||||
stringRes(id = R.string.local_blossom_cache_not_detected)
|
||||
} else {
|
||||
""
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.grayText,
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = enabled,
|
||||
@@ -167,11 +163,21 @@ private fun LocalBlossomCacheToggle(accountViewModel: AccountViewModel) {
|
||||
}
|
||||
|
||||
if (enabled) {
|
||||
CacheDetectionChip(accountViewModel)
|
||||
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(start = 64.dp),
|
||||
color = MaterialTheme.colorScheme.outlineVariant,
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(start = 16.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 64.dp, end = 14.dp, top = 12.dp, bottom = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
|
||||
Text(
|
||||
text = stringRes(id = R.string.local_blossom_cache_profile_pics_only),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
@@ -192,3 +198,39 @@ private fun LocalBlossomCacheToggle(accountViewModel: AccountViewModel) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loopback-detection status for the local cache, shown only while the cache is enabled
|
||||
* (kept in its own composable so the loopback probe is subscribed only then).
|
||||
*/
|
||||
@Composable
|
||||
private fun CacheDetectionChip(accountViewModel: AccountViewModel) {
|
||||
val probeAvailable by accountViewModel.useLocalBlossomBridgeForProfilePics
|
||||
.collectAsStateWithLifecycle()
|
||||
|
||||
val color = if (probeAvailable) MaterialTheme.colorScheme.allGoodColor else MaterialTheme.colorScheme.grayText
|
||||
|
||||
Row(
|
||||
modifier = Modifier.padding(start = 64.dp, end = 14.dp, bottom = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(8.dp)
|
||||
.clip(CircleShape)
|
||||
.background(color),
|
||||
)
|
||||
Text(
|
||||
text =
|
||||
if (probeAvailable) {
|
||||
stringRes(id = R.string.local_blossom_cache_detected)
|
||||
} else {
|
||||
stringRes(id = R.string.local_blossom_cache_not_detected)
|
||||
},
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = color,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,510 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.actions.mediaServers
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
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.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import coil3.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.R
|
||||
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.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.allGoodColor
|
||||
import com.vitorpamplona.amethyst.ui.theme.grayText
|
||||
import com.vitorpamplona.quartz.nip56Reports.ReportType
|
||||
|
||||
@Composable
|
||||
fun BlossomBlobManagerScreen(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val vm: BlossomBlobManagerViewModel = viewModel()
|
||||
vm.init(accountViewModel)
|
||||
|
||||
LaunchedEffect(accountViewModel) { vm.refresh() }
|
||||
|
||||
val blobs by vm.blobs.collectAsStateWithLifecycle()
|
||||
val loading by vm.isLoading.collectAsStateWithLifecycle()
|
||||
val error by vm.error.collectAsStateWithLifecycle()
|
||||
val pendingPayment by vm.pendingPayment.collectAsStateWithLifecycle()
|
||||
|
||||
pendingPayment?.let { pending ->
|
||||
BlossomPaymentDialog(
|
||||
host = pending.targetHost,
|
||||
amountSats = pending.amountSats,
|
||||
reason = pending.payment.sanitizedReason(),
|
||||
onConfirm = { vm.confirmPendingPayment() },
|
||||
onDismiss = { vm.cancelPendingPayment() },
|
||||
)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopBarExtensibleWithBackButton(
|
||||
title = { Text(stringRes(R.string.my_blossom_data)) },
|
||||
showBackButton = nav.canPop(),
|
||||
popBack = { nav.popBack() },
|
||||
actions = {
|
||||
IconButton(onClick = { vm.refresh() }, enabled = !loading) {
|
||||
if (loading) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(22.dp), strokeWidth = 2.dp)
|
||||
} else {
|
||||
Icon(symbol = MaterialSymbols.Sync, contentDescription = stringRes(R.string.blossom_refresh))
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(
|
||||
top = padding.calculateTopPadding(),
|
||||
bottom = padding.calculateBottomPadding(),
|
||||
),
|
||||
) {
|
||||
when {
|
||||
loading && blobs.isEmpty() -> CenteredState { CircularProgressIndicator() }
|
||||
|
||||
error != null && blobs.isEmpty() ->
|
||||
CenteredState {
|
||||
StatusGlyph(MaterialSymbols.Warning, MaterialTheme.colorScheme.error)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(error ?: "", color = MaterialTheme.colorScheme.grayText)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
OutlinedButton(onClick = { vm.refresh() }) { Text(stringRes(R.string.retry)) }
|
||||
}
|
||||
|
||||
blobs.isEmpty() ->
|
||||
CenteredState {
|
||||
StatusGlyph(MaterialSymbols.Storage, MaterialTheme.colorScheme.grayText)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
stringRes(R.string.manage_stored_files_empty),
|
||||
color = MaterialTheme.colorScheme.grayText,
|
||||
)
|
||||
}
|
||||
|
||||
else ->
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
if (blobs.any { it.hasMissing }) {
|
||||
item { SyncAllBanner(onSyncAll = { vm.syncAll() }) }
|
||||
}
|
||||
items(blobs, key = { it.hash }) { row ->
|
||||
BlobCard(row, vm)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CenteredState(content: @Composable () -> Unit) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(32.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) { content() }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatusGlyph(
|
||||
symbol: MaterialSymbol,
|
||||
tint: Color,
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(72.dp)
|
||||
.clip(RoundedCornerShape(20.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHighest),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(symbol = symbol, contentDescription = null, modifier = Modifier.size(34.dp), tint = tint)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SyncAllBanner(onSyncAll: () -> Unit) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(20.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHighest)
|
||||
.padding(14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
|
||||
Text(stringRes(R.string.blossom_sync_gaps), style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
FilledTonalButton(onClick = onSyncAll) {
|
||||
Icon(symbol = MaterialSymbols.CloudUpload, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Text(stringRes(R.string.blossom_sync_all))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun BlobCard(
|
||||
row: BlobRow,
|
||||
vm: BlossomBlobManagerViewModel,
|
||||
) {
|
||||
var menuOpen by remember { mutableStateOf(false) }
|
||||
var reportOpen by remember { mutableStateOf(false) }
|
||||
val clipboard = LocalClipboardManager.current
|
||||
val context = LocalContext.current
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(20.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
.padding(14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
// Header: thumbnail / file glyph + hash + overflow menu.
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
BlobThumbnail(row)
|
||||
Column(modifier = Modifier.weight(1f).padding(horizontal = 12.dp)) {
|
||||
Text(
|
||||
text = row.hash.take(12) + "…" + row.hash.takeLast(6),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 1,
|
||||
)
|
||||
Text(
|
||||
text = listOfNotNull(row.type, row.size?.let { humanBytes(it) }).joinToString(" · "),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.grayText,
|
||||
)
|
||||
}
|
||||
|
||||
Box {
|
||||
IconButton(onClick = { menuOpen = true }) {
|
||||
Icon(symbol = MaterialSymbols.MoreVert, contentDescription = null)
|
||||
}
|
||||
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
|
||||
if (row.url != null) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(R.string.copy)) },
|
||||
leadingIcon = { MenuIcon(MaterialSymbols.ContentCopy) },
|
||||
onClick = {
|
||||
menuOpen = false
|
||||
clipboard.setText(AnnotatedString(row.url))
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(R.string.blossom_open)) },
|
||||
leadingIcon = { MenuIcon(MaterialSymbols.AutoMirrored.OpenInNew) },
|
||||
onClick = {
|
||||
menuOpen = false
|
||||
runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, row.url.toUri())) }
|
||||
},
|
||||
)
|
||||
}
|
||||
if (row.hasPresent) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(R.string.blossom_report)) },
|
||||
leadingIcon = { MenuIcon(MaterialSymbols.Report) },
|
||||
onClick = {
|
||||
menuOpen = false
|
||||
reportOpen = true
|
||||
},
|
||||
)
|
||||
HorizontalDivider()
|
||||
row.presentServers.forEach { server ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(R.string.blossom_delete_from_host, vm.hostOf(server))) },
|
||||
leadingIcon = { MenuIcon(MaterialSymbols.Delete, MaterialTheme.colorScheme.error) },
|
||||
onClick = {
|
||||
menuOpen = false
|
||||
vm.delete(row.hash, server)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Per-server presence pills (green = has it, grey = missing, spinner = working).
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
row.servers.forEach { ServerPill(it) }
|
||||
}
|
||||
|
||||
// Primary CTA: fill the gaps.
|
||||
if (row.hasMissing && row.url != null) {
|
||||
FilledTonalButton(
|
||||
onClick = { vm.mirrorToMissing(row) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Icon(symbol = MaterialSymbols.CloudUpload, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Text(stringRes(R.string.blossom_mirror_to_missing))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (reportOpen) {
|
||||
BlossomReportDialog(row = row, vm = vm, onDismiss = { reportOpen = false })
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BlobThumbnail(row: BlobRow) {
|
||||
val shape = RoundedCornerShape(12.dp)
|
||||
if (row.url != null && row.type?.startsWith("image/") == true) {
|
||||
AsyncImage(
|
||||
model = row.url,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.size(48.dp).clip(shape),
|
||||
)
|
||||
} else {
|
||||
Box(
|
||||
modifier = Modifier.size(48.dp).clip(shape).background(MaterialTheme.colorScheme.secondaryContainer),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
val glyph = if (row.type?.startsWith("video/") == true) MaterialSymbols.Download else MaterialSymbols.Storage
|
||||
Icon(
|
||||
symbol = glyph,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(22.dp),
|
||||
tint = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ServerPill(presence: ServerPresence) {
|
||||
val present = presence.state == PresenceState.PRESENT
|
||||
val pending = presence.state == PresenceState.PENDING
|
||||
val accent = MaterialTheme.colorScheme.allGoodColor
|
||||
val bg =
|
||||
if (present) accent.copy(alpha = 0.12f) else MaterialTheme.colorScheme.surfaceContainerHighest
|
||||
Row(
|
||||
modifier = Modifier.clip(CircleShape).background(bg).padding(horizontal = 10.dp, vertical = 5.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
if (pending) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(9.dp),
|
||||
strokeWidth = 1.5.dp,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
} else {
|
||||
val dot = if (present) accent else MaterialTheme.colorScheme.grayText
|
||||
Box(modifier = Modifier.size(7.dp).clip(CircleShape).background(dot))
|
||||
}
|
||||
Text(
|
||||
text = presence.host,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = if (present) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.grayText,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MenuIcon(
|
||||
symbol: MaterialSymbol,
|
||||
tint: Color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
) {
|
||||
Icon(symbol = symbol, contentDescription = null, modifier = Modifier.size(20.dp), tint = tint)
|
||||
}
|
||||
|
||||
private fun humanBytes(bytes: Long): String =
|
||||
when {
|
||||
bytes >= 1_000_000 -> "${bytes / 1_000_000} MB"
|
||||
bytes >= 1_000 -> "${bytes / 1_000} KB"
|
||||
else -> "$bytes B"
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BlossomPaymentDialog(
|
||||
host: String,
|
||||
amountSats: Long?,
|
||||
reason: String?,
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
icon = { Icon(symbol = MaterialSymbols.Bolt, contentDescription = null, tint = MaterialTheme.colorScheme.allGoodColor) },
|
||||
title = { Text(stringRes(R.string.blossom_payment_title)) },
|
||||
text = {
|
||||
Column {
|
||||
Text(text = stringRes(R.string.blossom_payment_message, host))
|
||||
// X-Reason is server-controlled: it is sanitized upstream and rendered
|
||||
// here attributed to the server, in a dimmer italic, so it can never be
|
||||
// mistaken for Amethyst's own wording (e.g. a fake "Pay 1 sat").
|
||||
reason?.let {
|
||||
Spacer(Modifier.size(12.dp))
|
||||
Text(
|
||||
text = stringRes(R.string.blossom_payment_server_says, host, it),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontStyle = FontStyle.Italic,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
FilledTonalButton(onClick = onConfirm) {
|
||||
Icon(symbol = MaterialSymbols.Bolt, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Text(
|
||||
if (amountSats != null) {
|
||||
pluralStringResource(R.plurals.blossom_pay_sats, amountSats.toInt(), amountSats.toInt())
|
||||
} else {
|
||||
stringRes(R.string.blossom_pay)
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringRes(R.string.cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BlossomReportDialog(
|
||||
row: BlobRow,
|
||||
vm: BlossomBlobManagerViewModel,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
var comment by remember { mutableStateOf("") }
|
||||
var typeMenuOpen by remember { mutableStateOf(false) }
|
||||
var type by remember { mutableStateOf(ReportType.OTHER) }
|
||||
// Report to the first server that actually holds the blob.
|
||||
val server = row.presentServers.firstOrNull() ?: return
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
icon = { Icon(symbol = MaterialSymbols.Report, contentDescription = null) },
|
||||
title = { Text(stringRes(R.string.blossom_report_title)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Box {
|
||||
OutlinedButton(onClick = { typeMenuOpen = true }, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(type.code)
|
||||
}
|
||||
DropdownMenu(expanded = typeMenuOpen, onDismissRequest = { typeMenuOpen = false }) {
|
||||
ReportType.entries.forEach { rt ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(rt.code) },
|
||||
onClick = {
|
||||
type = rt
|
||||
typeMenuOpen = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = comment,
|
||||
onValueChange = { comment = it },
|
||||
label = { Text(stringRes(R.string.blossom_report_comment_hint)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
FilledTonalButton(onClick = {
|
||||
vm.report(row.hash, server, type, comment)
|
||||
onDismiss()
|
||||
}) { Text(stringRes(R.string.blossom_send)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringRes(R.string.cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,472 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.actions.mediaServers
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.commons.service.upload.BlossomClient
|
||||
import com.vitorpamplona.amethyst.commons.service.upload.BlossomPaymentException
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomMirrorQueue
|
||||
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomPaymentHandler
|
||||
import com.vitorpamplona.amethyst.service.uploads.blossom.PaymentPromptLedger
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip56Reports.ReportType
|
||||
import com.vitorpamplona.quartz.nipB7Blossom.BlossomPaymentProof
|
||||
import com.vitorpamplona.quartz.nipB7Blossom.BlossomPaymentRequired
|
||||
import com.vitorpamplona.quartz.nipB7Blossom.BlossomReport
|
||||
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServerUrl
|
||||
import com.vitorpamplona.quartz.nipB7Blossom.BlossomUploadResult
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
/** Whether one of the user's servers holds a blob, or an operation on it is in flight. */
|
||||
enum class PresenceState {
|
||||
/** Confirmed present (green). */
|
||||
PRESENT,
|
||||
|
||||
/** Confirmed absent (grey). */
|
||||
MISSING,
|
||||
|
||||
/** An operation is in flight — first-load HEAD probe, a mirror, or a delete (spinner). */
|
||||
PENDING,
|
||||
}
|
||||
|
||||
@Immutable
|
||||
data class ServerPresence(
|
||||
val server: String,
|
||||
val host: String,
|
||||
val state: PresenceState,
|
||||
)
|
||||
|
||||
/**
|
||||
* One stored blob, plus the state of each of the user's Blossom servers for it —
|
||||
* the "seen files per server" view (BUD-01 HEAD / BUD-02 list).
|
||||
*/
|
||||
@Immutable
|
||||
data class BlobRow(
|
||||
val hash: HexKey,
|
||||
val url: String?,
|
||||
val size: Long?,
|
||||
val type: String?,
|
||||
val servers: List<ServerPresence>,
|
||||
) {
|
||||
val presentServers get() = servers.filter { it.state == PresenceState.PRESENT }.map { it.server }
|
||||
val missingServers get() = servers.filter { it.state == PresenceState.MISSING }.map { it.server }
|
||||
val hasPresent get() = servers.any { it.state == PresenceState.PRESENT }
|
||||
val hasMissing get() = servers.any { it.state == PresenceState.MISSING }
|
||||
val presentCount get() = servers.count { it.state == PresenceState.PRESENT }
|
||||
}
|
||||
|
||||
/** A BUD-07 payment prompt raised while mirroring [hash] to [target]. */
|
||||
@Immutable
|
||||
data class PendingMirrorPayment(
|
||||
val hash: HexKey,
|
||||
val sourceUrl: String,
|
||||
val target: String,
|
||||
val targetHost: String,
|
||||
val payment: BlossomPaymentRequired,
|
||||
val amountSats: Long?,
|
||||
)
|
||||
|
||||
/**
|
||||
* Backs the Blossom blob-manager screen. For the active account it fans a
|
||||
* `GET /list/<pubkey>` (BUD-02) across every server in the user's kind-10063 list
|
||||
* in parallel, shows the result immediately, then backfills only the servers that
|
||||
* do NOT implement `/list` with parallel `HEAD /<sha256>` probes (BUD-01) — a server
|
||||
* that returned a list already reports its full holdings, so no probe is needed for
|
||||
* it. Delete (BUD-02), mirror-to-missing (BUD-04) and report (BUD-09) update the
|
||||
* affected pill in place (spinner → green/grey) instead of reloading the screen.
|
||||
*/
|
||||
@Stable
|
||||
class BlossomBlobManagerViewModel : ViewModel() {
|
||||
private lateinit var account: Account
|
||||
|
||||
private val _blobs = MutableStateFlow<List<BlobRow>>(emptyList())
|
||||
val blobs = _blobs.asStateFlow()
|
||||
|
||||
private val _isLoading = MutableStateFlow(false)
|
||||
val isLoading = _isLoading.asStateFlow()
|
||||
|
||||
private val _error = MutableStateFlow<String?>(null)
|
||||
val error = _error.asStateFlow()
|
||||
|
||||
private val _pendingPayment = MutableStateFlow<PendingMirrorPayment?>(null)
|
||||
val pendingPayment = _pendingPayment.asStateFlow()
|
||||
|
||||
private var resultCollectorStarted = false
|
||||
|
||||
/**
|
||||
* Caps BUD-07 payment prompts at one per (blob, server) per user-initiated
|
||||
* mirror, so a server that keeps replying 402 after being paid cannot drive an
|
||||
* unbounded pay-prompt cycle. See [PaymentPromptLedger].
|
||||
*/
|
||||
private val promptLedger = PaymentPromptLedger()
|
||||
|
||||
fun init(accountViewModel: AccountViewModel) {
|
||||
this.account = accountViewModel.account
|
||||
// Reflect the app-level sync sweep's per-server results onto the pills, so an
|
||||
// open manager turns dots green live even though the work runs in the background.
|
||||
if (!resultCollectorStarted) {
|
||||
resultCollectorStarted = true
|
||||
viewModelScope.launch {
|
||||
Amethyst.instance.blossomMirrorQueue.results.collect { r ->
|
||||
setServerState(r.hash, r.server, if (r.ok) PresenceState.PRESENT else PresenceState.MISSING)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun clientFor(server: String) = BlossomClient(Amethyst.instance.roleBasedHttpClientBuilder.okHttpClientForUploads(server))
|
||||
|
||||
// The user's explicitly configured kind-10063 servers (empty if they never set
|
||||
// a list) — not the DEFAULT_MEDIA_SERVERS fallback, so the matrix reflects the
|
||||
// servers the user actually chose.
|
||||
private fun servers(): List<String> =
|
||||
account.blossomServers.flow.value
|
||||
.distinct()
|
||||
|
||||
private var refreshJob: Job? = null
|
||||
|
||||
fun refresh() {
|
||||
// Cancel any in-flight load so two quick refreshes can't interleave their writes
|
||||
// or fight over _isLoading.
|
||||
refreshJob?.cancel()
|
||||
refreshJob =
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
_isLoading.value = true
|
||||
_error.value = null
|
||||
try {
|
||||
loadMatrix()
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.w("BlossomBlobManager", "Failed to load blob list", e)
|
||||
_error.value = e.message?.ifBlank { null } ?: e.javaClass.simpleName
|
||||
} finally {
|
||||
_isLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadMatrix() {
|
||||
val pubkey = account.signer.pubKey
|
||||
val servers = servers()
|
||||
if (servers.isEmpty()) {
|
||||
_blobs.value = emptyList()
|
||||
return
|
||||
}
|
||||
|
||||
// Phase 1 — /list every server in parallel. A null result means the server
|
||||
// doesn't implement /list (or errored) and needs HEAD backfill in phase 2.
|
||||
val listed: List<Pair<String, List<BlossomUploadResult>?>> =
|
||||
coroutineScope {
|
||||
servers
|
||||
.map { server ->
|
||||
async {
|
||||
server to
|
||||
try {
|
||||
val auth = account.createBlossomListAuth("List blobs").toAuthorizationHeader()
|
||||
clientFor(server).list(server, pubkey, auth)
|
||||
} catch (e: Exception) {
|
||||
Log.w("BlossomBlobManager", "list failed on $server", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
}.awaitAll()
|
||||
}
|
||||
|
||||
val meta = HashMap<HexKey, BlobMeta>()
|
||||
val hashesByServer = HashMap<String, Set<HexKey>>()
|
||||
val listCapable = HashSet<String>()
|
||||
listed.forEach { (server, blobs) ->
|
||||
if (blobs != null) {
|
||||
listCapable.add(server)
|
||||
hashesByServer[server] = blobs.mapNotNull { it.sha256 }.toSet()
|
||||
blobs.forEach { d -> d.sha256?.let { meta.putIfAbsent(it, BlobMeta(d.url, d.size, d.type)) } }
|
||||
}
|
||||
}
|
||||
val allHashes = meta.keys.toList()
|
||||
|
||||
fun rows(head: Map<Pair<String, HexKey>, Boolean> = emptyMap()): List<BlobRow> =
|
||||
allHashes
|
||||
.map { hash ->
|
||||
val presences =
|
||||
servers.map { server ->
|
||||
val state =
|
||||
when {
|
||||
server in listCapable ->
|
||||
if (hash in hashesByServer[server].orEmpty()) PresenceState.PRESENT else PresenceState.MISSING
|
||||
else ->
|
||||
head[server to hash]?.let { if (it) PresenceState.PRESENT else PresenceState.MISSING }
|
||||
?: PresenceState.PENDING
|
||||
}
|
||||
ServerPresence(server, hostOf(server), state)
|
||||
}
|
||||
val m = meta[hash]
|
||||
BlobRow(hash, m?.url, m?.size, m?.type, presences)
|
||||
}.sortedByDescending { it.presentCount }
|
||||
|
||||
// Show the /list result right away; the non-list servers appear as spinning
|
||||
// pills until their HEAD probe lands.
|
||||
_blobs.value = rows()
|
||||
_isLoading.value = false
|
||||
|
||||
// Phase 2 — HEAD-probe ONLY the non-list servers, for the known hashes. Bounded so a
|
||||
// user with hundreds of blobs on a non-/list server doesn't spawn hundreds of probes
|
||||
// at once; OkHttp still caps per-host, this caps the coroutine/allocation breadth.
|
||||
val nonListServers = servers.filter { it !in listCapable }
|
||||
if (nonListServers.isNotEmpty() && allHashes.isNotEmpty()) {
|
||||
val limiter = Semaphore(MAX_HEAD_PROBES)
|
||||
val head =
|
||||
coroutineScope {
|
||||
nonListServers
|
||||
.flatMap { server ->
|
||||
allHashes.map { hash ->
|
||||
async { limiter.withPermit { (server to hash) to clientFor(server).has(hash, server) } }
|
||||
}
|
||||
}.awaitAll()
|
||||
}.toMap()
|
||||
_blobs.value = rows(head)
|
||||
}
|
||||
}
|
||||
|
||||
private fun currentRow(hash: HexKey) = _blobs.value.firstOrNull { it.hash == hash }
|
||||
|
||||
private fun setServerState(
|
||||
hash: HexKey,
|
||||
server: String,
|
||||
state: PresenceState,
|
||||
) {
|
||||
// Atomic read-modify-write: the app-level sync results collector (Main) and the
|
||||
// per-row delete/mirror actions (IO) both mutate _blobs concurrently, so a plain
|
||||
// `_blobs.value = _blobs.value.map{}` would lose updates.
|
||||
_blobs.update { list ->
|
||||
list.map { row ->
|
||||
if (row.hash != hash) {
|
||||
row
|
||||
} else {
|
||||
row.copy(servers = row.servers.map { if (it.server == server) it.copy(state = state) else it })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** BUD-02 delete: remove [hash] from a single [server]; the pill spins then goes grey. */
|
||||
fun delete(
|
||||
hash: HexKey,
|
||||
server: String,
|
||||
) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
setServerState(hash, server, PresenceState.PENDING)
|
||||
val ok =
|
||||
try {
|
||||
val auth = account.createBlossomDeleteAuth(hash, "Delete blob").toAuthorizationHeader()
|
||||
clientFor(server).delete(hash, server, auth)
|
||||
} catch (e: Exception) {
|
||||
Log.w("BlossomBlobManager", "delete failed on $server", e)
|
||||
false
|
||||
}
|
||||
setServerState(hash, server, if (ok) PresenceState.MISSING else PresenceState.PRESENT)
|
||||
// Drop the row entirely once it's gone from every server.
|
||||
_blobs.update { list -> if (list.firstOrNull { it.hash == hash }?.hasPresent == false) list.filter { it.hash != hash } else list }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* BUD-04: mirror a blob to every server that doesn't have it; each pill spins
|
||||
* then turns green. This is the user-initiated entry point, so it resets the
|
||||
* "already asked for payment" ledger — see [promptedForPayment].
|
||||
*/
|
||||
fun mirrorToMissing(row: BlobRow) {
|
||||
promptLedger.beginUserAction()
|
||||
mirrorMissingTargets(row)
|
||||
}
|
||||
|
||||
private fun mirrorMissingTargets(row: BlobRow) {
|
||||
val source = row.url ?: return
|
||||
val targets = currentRow(row.hash)?.missingServers ?: row.missingServers
|
||||
if (targets.isEmpty()) return
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
for (target in targets) {
|
||||
setServerState(row.hash, target, PresenceState.PENDING)
|
||||
try {
|
||||
mirrorOne(source, row.hash, row.size, target, null)
|
||||
setServerState(row.hash, target, PresenceState.PRESENT)
|
||||
} catch (e: BlossomPaymentException) {
|
||||
setServerState(row.hash, target, PresenceState.MISSING)
|
||||
if (BlossomPaymentHandler.canPay(account, e.payment)) {
|
||||
// Bounded: a server that pockets the preimage and replies 402
|
||||
// again must not be able to spin up an endless pay-prompt
|
||||
// cycle. One prompt per target per user-initiated mirror.
|
||||
if (!promptLedger.shouldPrompt(row.hash, target)) {
|
||||
Log.w("BlossomBlobManager", "mirror to $target asked for payment again after being paid; not re-prompting")
|
||||
_error.value = "${hostOf(target)} asked for payment again after being paid. Amethyst stopped to avoid paying twice."
|
||||
continue
|
||||
}
|
||||
_pendingPayment.value =
|
||||
PendingMirrorPayment(row.hash, source, target, hostOf(target), e.payment, BlossomPaymentHandler.amountSats(e.payment))
|
||||
return@launch
|
||||
}
|
||||
Log.w("BlossomBlobManager", "mirror to $target needs unsupported payment", e)
|
||||
} catch (e: Exception) {
|
||||
setServerState(row.hash, target, PresenceState.MISSING)
|
||||
Log.w("BlossomBlobManager", "mirror to $target failed", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* BUD-04 sweep: hand the whole "fill every gap" job to the app-level
|
||||
* [BlossomMirrorQueue] so it keeps running (with a floating progress banner) as
|
||||
* the user navigates away. The pills we're about to fill go straight to a spinner;
|
||||
* the queue's [results] stream (collected in [init]) flips each to green/grey as it
|
||||
* lands, so an open manager stays in sync with the background sweep.
|
||||
*/
|
||||
fun syncAll() {
|
||||
val tasks =
|
||||
_blobs.value
|
||||
.filter { it.hasMissing && it.url != null }
|
||||
.map { BlossomMirrorQueue.Task(it.hash, it.url!!, it.size, it.missingServers) }
|
||||
if (tasks.isEmpty()) return
|
||||
|
||||
_blobs.update { list ->
|
||||
list.map { row ->
|
||||
if (!row.hasMissing) {
|
||||
row
|
||||
} else {
|
||||
row.copy(servers = row.servers.map { if (it.state == PresenceState.MISSING) it.copy(state = PresenceState.PENDING) else it })
|
||||
}
|
||||
}
|
||||
}
|
||||
Amethyst.instance.blossomMirrorQueue.start(account, tasks)
|
||||
}
|
||||
|
||||
private suspend fun mirrorOne(
|
||||
source: String,
|
||||
hash: HexKey,
|
||||
size: Long?,
|
||||
target: String,
|
||||
proof: BlossomPaymentProof?,
|
||||
) {
|
||||
val auth = account.createBlossomUploadAuth(hash, size ?: 0L, "Mirror $hash").toAuthorizationHeader()
|
||||
clientFor(target).mirror(source, target, auth, proof)
|
||||
}
|
||||
|
||||
/** User confirmed the BUD-07 prompt: pay via the wallet, retry that server, then continue. */
|
||||
fun confirmPendingPayment() {
|
||||
val pending = _pendingPayment.value ?: return
|
||||
_pendingPayment.value = null
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
setServerState(pending.hash, pending.target, PresenceState.PENDING)
|
||||
// pending.amountSats is exactly what the dialog showed; pay() re-derives
|
||||
// the amount from the invoice and refuses if the two disagree or the
|
||||
// amount is above the cap.
|
||||
val result = BlossomPaymentHandler.pay(account, pending.payment, pending.amountSats)
|
||||
val proof =
|
||||
when (result) {
|
||||
is BlossomPaymentHandler.PayResult.Paid -> result.proof
|
||||
is BlossomPaymentHandler.PayResult.Refused -> {
|
||||
setServerState(pending.hash, pending.target, PresenceState.MISSING)
|
||||
_error.value = result.reason
|
||||
return@launch
|
||||
}
|
||||
BlossomPaymentHandler.PayResult.TimedOut -> {
|
||||
setServerState(pending.hash, pending.target, PresenceState.MISSING)
|
||||
_error.value =
|
||||
"Your wallet did not confirm in time. If the payment does go through, retry the mirror — " +
|
||||
"Amethyst will not send this invoice again."
|
||||
return@launch
|
||||
}
|
||||
BlossomPaymentHandler.PayResult.Unavailable -> {
|
||||
setServerState(pending.hash, pending.target, PresenceState.MISSING)
|
||||
_error.value = "Payment failed or was not confirmed by the wallet."
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
try {
|
||||
mirrorOne(pending.sourceUrl, pending.hash, currentRow(pending.hash)?.size, pending.target, proof)
|
||||
setServerState(pending.hash, pending.target, PresenceState.PRESENT)
|
||||
} catch (e: Exception) {
|
||||
setServerState(pending.hash, pending.target, PresenceState.MISSING)
|
||||
Log.w("BlossomBlobManager", "paid mirror to ${pending.target} failed", e)
|
||||
}
|
||||
// Continue with any remaining missing servers. Targets already prompted
|
||||
// in this action (including this one) will NOT prompt again.
|
||||
currentRow(pending.hash)?.let { mirrorMissingTargets(it) }
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelPendingPayment() {
|
||||
_pendingPayment.value = null
|
||||
}
|
||||
|
||||
/** BUD-09: report a blob to a server as problematic content. */
|
||||
fun report(
|
||||
hash: HexKey,
|
||||
server: String,
|
||||
type: ReportType,
|
||||
comment: String,
|
||||
onDone: (Boolean) -> Unit = {},
|
||||
) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val ok =
|
||||
try {
|
||||
val event = account.signer.sign(BlossomReport.build(hash, type, account.signer.pubKey, comment))
|
||||
clientFor(server).report(server, event.toJson())
|
||||
} catch (e: Exception) {
|
||||
Log.w("BlossomBlobManager", "report failed on $server", e)
|
||||
false
|
||||
}
|
||||
withContext(Dispatchers.Main) { onDone(ok) }
|
||||
}
|
||||
}
|
||||
|
||||
fun hostOf(serverBaseUrl: String): String = BlossomServerUrl.domain(serverBaseUrl)
|
||||
|
||||
private data class BlobMeta(
|
||||
val url: String?,
|
||||
val size: Long?,
|
||||
val type: String?,
|
||||
)
|
||||
|
||||
companion object {
|
||||
/** Cap on concurrent HEAD probes during the /list backfill. */
|
||||
private const val MAX_HEAD_PROBES = 8
|
||||
}
|
||||
}
|
||||
@@ -24,9 +24,11 @@ import androidx.compose.runtime.Stable
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.privacyOptions.IRoleBasedHttpClientBuilder
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import com.vitorpamplona.quartz.utils.Rfc3986
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
@@ -36,18 +38,26 @@ import kotlinx.coroutines.launch
|
||||
class BlossomServersViewModel : ViewModel() {
|
||||
private lateinit var accountViewModel: AccountViewModel
|
||||
private lateinit var account: Account
|
||||
private var httpClientBuilder: IRoleBasedHttpClientBuilder? = null
|
||||
|
||||
private val _fileServers = MutableStateFlow<List<ServerName>>(emptyList())
|
||||
val fileServers = _fileServers.asStateFlow()
|
||||
|
||||
/** Reachability status per server, keyed by [ServerName.baseUrl]. */
|
||||
private val _health = MutableStateFlow<Map<String, ServerHealth>>(emptyMap())
|
||||
val health = _health.asStateFlow()
|
||||
|
||||
private var isModified = false
|
||||
|
||||
fun init(accountViewModel: AccountViewModel) {
|
||||
this.accountViewModel = accountViewModel
|
||||
this.account = accountViewModel.account
|
||||
this.httpClientBuilder = accountViewModel.httpClientBuilder
|
||||
}
|
||||
|
||||
fun load() {
|
||||
refresh()
|
||||
checkAllHealth()
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
@@ -67,15 +77,63 @@ class BlossomServersViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
}
|
||||
pruneHealth()
|
||||
}
|
||||
|
||||
fun addServerList(serverList: List<String>) {
|
||||
serverList.forEach { serverUrl ->
|
||||
addServer(serverUrl)
|
||||
/** Moves a server to a new position; list order is the upload/fallback priority. */
|
||||
fun moveServer(
|
||||
from: Int,
|
||||
to: Int,
|
||||
) {
|
||||
_fileServers.update { list ->
|
||||
if (from !in list.indices || to !in list.indices) return@update list
|
||||
list.toMutableList().apply { add(to, removeAt(from)) }
|
||||
}
|
||||
isModified = true
|
||||
}
|
||||
|
||||
/** Re-probes every server currently in the list. Fresh cached results are reused. */
|
||||
fun checkAllHealth() {
|
||||
_fileServers.value.forEach { probeServer(it.baseUrl) }
|
||||
}
|
||||
|
||||
private fun probeServer(serverUrl: String) {
|
||||
val builder = httpClientBuilder ?: return
|
||||
|
||||
// A probe is already in flight for this URL — don't launch a duplicate.
|
||||
if (_health.value[serverUrl] == ServerHealth.Checking) return
|
||||
|
||||
// Reuse a still-fresh cached status instead of hitting the network again.
|
||||
MediaServerHealthProbe.cached(serverUrl)?.let { cachedStatus ->
|
||||
_health.update { it + (serverUrl to cachedStatus) }
|
||||
return
|
||||
}
|
||||
|
||||
_health.update { it + (serverUrl to ServerHealth.Checking) }
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val result = MediaServerHealthProbe.probe(serverUrl, builder::okHttpClientForPreview)
|
||||
_health.update { it + (serverUrl to result) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Drops health entries for servers no longer in the list so the map can't grow unbounded. */
|
||||
private fun pruneHealth() {
|
||||
val liveUrls = _fileServers.value.mapTo(HashSet()) { it.baseUrl }
|
||||
_health.update { statuses -> statuses.filterKeys { it in liveUrls } }
|
||||
}
|
||||
|
||||
fun addServerList(serverList: List<String>) {
|
||||
var added = false
|
||||
serverList.forEach { if (addServerInternal(it)) added = true }
|
||||
if (added) persist()
|
||||
}
|
||||
|
||||
fun addServer(serverUrl: String) {
|
||||
if (addServerInternal(serverUrl)) persist()
|
||||
}
|
||||
|
||||
/** Adds a server to the in-memory list (no persist). Returns true if it was new. */
|
||||
private fun addServerInternal(serverUrl: String): Boolean {
|
||||
val normalizedUrl =
|
||||
try {
|
||||
Rfc3986.normalize(serverUrl.trim())
|
||||
@@ -94,14 +152,12 @@ class BlossomServersViewModel : ViewModel() {
|
||||
normalizedUrl,
|
||||
ServerType.Blossom,
|
||||
)
|
||||
if (_fileServers.value.contains(serverRef)) {
|
||||
return
|
||||
} else {
|
||||
_fileServers.update {
|
||||
it.plus(serverRef)
|
||||
}
|
||||
}
|
||||
if (_fileServers.value.contains(serverRef)) return false
|
||||
|
||||
_fileServers.update { it.plus(serverRef) }
|
||||
probeServer(serverRef.baseUrl)
|
||||
isModified = true
|
||||
return true
|
||||
}
|
||||
|
||||
fun removeServer(
|
||||
@@ -120,22 +176,24 @@ class BlossomServersViewModel : ViewModel() {
|
||||
ServerName(serverName, serverUrl, ServerType.Blossom),
|
||||
)
|
||||
}
|
||||
pruneHealth()
|
||||
isModified = true
|
||||
persist()
|
||||
}
|
||||
}
|
||||
|
||||
fun removeAllServers() {
|
||||
_fileServers.update { emptyList() }
|
||||
isModified = true
|
||||
}
|
||||
/**
|
||||
* Publishes any pending change. Called after each discrete edit (add/remove) and,
|
||||
* for a reorder, once the drag gesture completes — so a drag doesn't publish a
|
||||
* kind-10063 event on every intermediate swap.
|
||||
*/
|
||||
fun persistPending() = persist()
|
||||
|
||||
fun saveFileServers() {
|
||||
if (isModified) {
|
||||
accountViewModel.launchSigner {
|
||||
val serverList = _fileServers.value.map { it.baseUrl }
|
||||
account.sendBlossomServersList(serverList)
|
||||
refresh()
|
||||
}
|
||||
private fun persist() {
|
||||
if (!isModified) return
|
||||
isModified = false
|
||||
accountViewModel.launchSigner {
|
||||
account.sendBlossomServersList(_fileServers.value.map { it.baseUrl })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.actions.mediaServers
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
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.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomSyncState
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.allGoodColor
|
||||
import com.vitorpamplona.amethyst.ui.theme.grayText
|
||||
|
||||
/**
|
||||
* App-wide floating banner for the BUD-04 "sync all" sweep, mounted at the navigation
|
||||
* root (a sibling of the mining/broadcast banners) so it floats over every screen and
|
||||
* survives navigation. Observes the app-level [Amethyst.instance.blossomMirrorQueue].
|
||||
*/
|
||||
@Composable
|
||||
fun DisplayBlossomSyncProgress() {
|
||||
val queue = Amethyst.instance.blossomMirrorQueue
|
||||
val state by queue.state.collectAsStateWithLifecycle()
|
||||
|
||||
// Retain the last non-null value so the slide-out exit still has content to draw when
|
||||
// state clears to null on cancel/dismiss.
|
||||
var lastShown by remember { mutableStateOf<BlossomSyncState?>(null) }
|
||||
LaunchedEffect(state) { state?.let { lastShown = it } }
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.BottomCenter) {
|
||||
AnimatedVisibility(
|
||||
visible = state != null,
|
||||
enter = slideInVertically { it } + fadeIn(),
|
||||
exit = slideOutVertically { it } + fadeOut(),
|
||||
modifier =
|
||||
Modifier
|
||||
.navigationBarsPadding()
|
||||
.padding(start = 12.dp, end = 12.dp, bottom = 116.dp)
|
||||
.widthIn(max = 560.dp),
|
||||
) {
|
||||
val s = lastShown ?: return@AnimatedVisibility
|
||||
Surface(
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||
tonalElevation = 3.dp,
|
||||
shadowElevation = 6.dp,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(14.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
symbol = if (s.running) MaterialSymbols.CloudUpload else MaterialSymbols.Check,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = if (s.running) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.allGoodColor,
|
||||
)
|
||||
Text(
|
||||
text = if (s.running) stringRes(R.string.blossom_syncing) else stringRes(R.string.blossom_sync_done),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f).padding(horizontal = 10.dp),
|
||||
)
|
||||
IconButton(
|
||||
onClick = { if (s.running) queue.cancel() else queue.dismiss() },
|
||||
modifier = Modifier.size(28.dp),
|
||||
) {
|
||||
Icon(symbol = MaterialSymbols.Close, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
}
|
||||
|
||||
LinearProgressIndicator(
|
||||
progress = { s.fraction },
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 10.dp),
|
||||
)
|
||||
|
||||
Text(
|
||||
text =
|
||||
buildString {
|
||||
append("${s.done} / ${s.total}")
|
||||
if (s.failed > 0) append(" · ${s.failed} failed")
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.grayText,
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.actions.mediaServers
|
||||
|
||||
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServerUrl
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.coroutines.executeAsync
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Reachability status of a media server, shown as a colored dot next to each
|
||||
* entry in the Media Servers list.
|
||||
*/
|
||||
enum class ServerHealth {
|
||||
/** Not probed yet. */
|
||||
Unknown,
|
||||
|
||||
/** A probe is in flight. */
|
||||
Checking,
|
||||
|
||||
/** Responded quickly. */
|
||||
Online,
|
||||
|
||||
/** Responded, but slower than [MediaServerHealthProbe.SLOW_THRESHOLD_MS]. */
|
||||
Slow,
|
||||
|
||||
/** Could not be reached (DNS, refused, timeout, TLS). */
|
||||
Offline,
|
||||
}
|
||||
|
||||
/**
|
||||
* A one-shot, lightweight reachability check for a Blossom server. Issues a `HEAD` to
|
||||
* the server's `/upload` endpoint (BUD-01/BUD-02) and classifies the outcome by
|
||||
* round-trip time. Any HTTP response — even 401/404/405 — counts as reachable; only
|
||||
* connection-level failures map to [ServerHealth.Offline].
|
||||
*
|
||||
* The `/upload` path is probed rather than the bare root because CDN-fronted hosts
|
||||
* (e.g. cdn.satellite.earth) don't answer `/` at all and would time out, wrongly
|
||||
* reading as offline even though uploads work. `/upload` is the endpoint that
|
||||
* actually matters for a media upload target.
|
||||
*
|
||||
* Mirrors the timeout/short-circuit shape of
|
||||
* [com.vitorpamplona.amethyst.service.uploads.blossom.bud10.LocalBlossomCacheProbe],
|
||||
* but runs per-server and returns latency-classified status rather than a boolean.
|
||||
*/
|
||||
object MediaServerHealthProbe {
|
||||
/** Round-trip time above which a reachable server is reported as [ServerHealth.Slow]. */
|
||||
const val SLOW_THRESHOLD_MS: Long = 1_000L
|
||||
private const val PROBE_TIMEOUT_MS: Long = 5_000L
|
||||
|
||||
/**
|
||||
* How long a probe result is reused before the server is re-checked. The cache is
|
||||
* process-wide (this is a singleton) so results survive the screen's ViewModel being
|
||||
* recreated on each open, mirroring [com.vitorpamplona.amethyst.service.uploads.blossom.bud10.LocalBlossomCacheProbe].
|
||||
*/
|
||||
private const val CACHE_TTL_MS: Long = 60_000L
|
||||
|
||||
private class CachedResult(
|
||||
val status: ServerHealth,
|
||||
val atMs: Long,
|
||||
)
|
||||
|
||||
private val cache = ConcurrentHashMap<String, CachedResult>()
|
||||
|
||||
/** The cached status for [baseUrl] if still within [CACHE_TTL_MS], else null. */
|
||||
fun cached(baseUrl: String): ServerHealth? {
|
||||
val entry = cache[baseUrl] ?: return null
|
||||
return if (TimeUtils.nowMillis() - entry.atMs < CACHE_TTL_MS) entry.status else null
|
||||
}
|
||||
|
||||
suspend fun probe(
|
||||
baseUrl: String,
|
||||
clientForUrl: (String) -> OkHttpClient,
|
||||
): ServerHealth {
|
||||
cached(baseUrl)?.let { return it }
|
||||
val result = runProbe(baseUrl, clientForUrl)
|
||||
cache[baseUrl] = CachedResult(result, TimeUtils.nowMillis())
|
||||
return result
|
||||
}
|
||||
|
||||
private suspend fun runProbe(
|
||||
baseUrl: String,
|
||||
clientForUrl: (String) -> OkHttpClient,
|
||||
): ServerHealth =
|
||||
try {
|
||||
val client =
|
||||
clientForUrl(baseUrl)
|
||||
.newBuilder()
|
||||
.connectTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||
.readTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||
.callTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||
.build()
|
||||
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.url(BlossomServerUrl.upload(baseUrl))
|
||||
.head()
|
||||
.build()
|
||||
|
||||
val startedAt = TimeUtils.nowMillis()
|
||||
client.newCall(request).executeAsync().use {
|
||||
// The status code doesn't matter — /upload commonly answers 401/404/405
|
||||
// without auth. Getting any response back proves the host is reachable.
|
||||
val elapsed = TimeUtils.nowMillis() - startedAt
|
||||
if (elapsed > SLOW_THRESHOLD_MS) ServerHealth.Slow else ServerHealth.Online
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
ServerHealth.Offline
|
||||
}
|
||||
}
|
||||
@@ -88,40 +88,13 @@ fun ConcordInviteCard(
|
||||
onClick = { nav.nav(Route.ConcordInvite(linkText)) },
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
ConcordInvitePreviewRow(
|
||||
robotSeed = robotSeed,
|
||||
title = title,
|
||||
subtitle = stringRes(R.string.concord_invite_card_subtitle),
|
||||
accountViewModel = accountViewModel,
|
||||
autoPlayGif = autoPlayGif,
|
||||
) {
|
||||
RobohashFallbackAsyncImage(
|
||||
robot = robotSeed,
|
||||
model = null,
|
||||
contentDescription = title,
|
||||
modifier =
|
||||
Modifier
|
||||
.size(52.dp)
|
||||
.clip(CircleShape)
|
||||
.border(1.5.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.35f), CircleShape),
|
||||
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
|
||||
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
|
||||
autoPlayGif = autoPlayGif,
|
||||
)
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = stringRes(R.string.concord_invite_card_subtitle),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
SymbolIcon(
|
||||
symbol = MaterialSymbols.ChevronRight,
|
||||
contentDescription = stringRes(R.string.concord_invite_card_join),
|
||||
@@ -131,3 +104,57 @@ fun ConcordInviteCard(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The avatar + title/subtitle row shared by [ConcordInviteCard] (in note content) and
|
||||
* the deep-link consent screen, so both render an invite identically. Purely
|
||||
* presentational — it performs no I/O, which is what lets the deep-link screen show a
|
||||
* preview without contacting the link's (attacker-supplied) relays before the user
|
||||
* consents.
|
||||
*/
|
||||
@Composable
|
||||
fun ConcordInvitePreviewRow(
|
||||
robotSeed: String,
|
||||
title: String,
|
||||
subtitle: String,
|
||||
accountViewModel: AccountViewModel,
|
||||
autoPlayGif: Boolean,
|
||||
trailing: @Composable () -> Unit = {},
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
RobohashFallbackAsyncImage(
|
||||
robot = robotSeed,
|
||||
model = null,
|
||||
contentDescription = title,
|
||||
modifier =
|
||||
Modifier
|
||||
.size(52.dp)
|
||||
.clip(CircleShape)
|
||||
.border(1.5.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.35f), CircleShape),
|
||||
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
|
||||
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
|
||||
autoPlayGif = autoPlayGif,
|
||||
)
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
trailing()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.LoadRelayGroupChannel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupWarmupSubscription
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupCardWarmupSubscription
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.GroupInviteLink
|
||||
@@ -93,7 +93,7 @@ private fun RelayGroupCardContent(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
RelayGroupWarmupSubscription(baseChannel, accountViewModel.dataSources().relayGroupWarmup, accountViewModel)
|
||||
RelayGroupCardWarmupSubscription(baseChannel, accountViewModel.dataSources().relayGroupCardWarmup, accountViewModel)
|
||||
|
||||
// Recompose in place when the relay-signed metadata / roster changes.
|
||||
val channelState by baseChannel
|
||||
|
||||
@@ -54,8 +54,6 @@ private data class NoteLayoutPx(
|
||||
val authorWidth: Int,
|
||||
/** Horizontal gap between author column and content column: 10dp */
|
||||
val authorContentGap: Int,
|
||||
/** Vertical gap between author picture and relay badges: 5dp */
|
||||
val authorBadgeGap: Int,
|
||||
/** Vertical spacer between header rows and note content: 4dp */
|
||||
val contentSpacer: Int,
|
||||
/** Left padding for the content area: 12dp */
|
||||
@@ -87,9 +85,6 @@ private fun NoteComposeLayoutPreviewCard() {
|
||||
.background(Color(0xFF9575CD)),
|
||||
)
|
||||
},
|
||||
relayBadges = {
|
||||
Text("R1 R2 R3", fontSize = 10.sp, color = Color.Gray)
|
||||
},
|
||||
firstRow = {
|
||||
Text(
|
||||
"Alice @alice 2h ...",
|
||||
@@ -127,7 +122,6 @@ private fun NoteComposeLayoutBoostedPreview() {
|
||||
addPadding = false,
|
||||
showAuthorColumn = false,
|
||||
authorPicture = {},
|
||||
relayBadges = {},
|
||||
firstRow = {
|
||||
Text("Bob boosted 2h ...")
|
||||
},
|
||||
@@ -157,10 +151,8 @@ private fun NoteComposeLayoutBoostedPreview() {
|
||||
* │ │author│ gap │ firstRow │ │
|
||||
* │ │ 55dp │ │ secondRow (optional) │ │
|
||||
* │ │ │ │ 4dp spacer (optional) │ │
|
||||
* │ ├──────┤ 5dp │ noteContent │ │
|
||||
* │ │relay │ gap │ │ │
|
||||
* │ │badges│ │ │ │
|
||||
* │ └──────┘ └────────────────────────────┘ │
|
||||
* │ └──────┘ │ noteContent │ │
|
||||
* │ └────────────────────────────┘ │
|
||||
* ├───────────────────────────────────────────────┤
|
||||
* │ reactionsRow (full width, no side padding) │
|
||||
* └───────────────────────────────────────────────┘
|
||||
@@ -200,11 +192,10 @@ private fun NoteComposeLayoutBoostedPreview() {
|
||||
*
|
||||
* The `contents` list and `allMeasurables` indices are:
|
||||
* - 0: [authorPicture] - 0 or 1 measurables
|
||||
* - 1: [relayBadges] - 0 or 1 measurables
|
||||
* - 2: [firstRow] - exactly 1 measurable
|
||||
* - 3: [secondRow] - 0 or 1 measurables
|
||||
* - 4: [noteContent] - 1+ measurables (stacked vertically)
|
||||
* - 5: [reactionsRow] - 0+ measurables (stacked vertically)
|
||||
* - 1: [firstRow] - exactly 1 measurable
|
||||
* - 2: [secondRow] - 0 or 1 measurables
|
||||
* - 3: [noteContent] - 1+ measurables (stacked vertically)
|
||||
* - 4: [reactionsRow] - 0+ measurables (stacked vertically)
|
||||
*
|
||||
* ## Performance
|
||||
*
|
||||
@@ -212,7 +203,7 @@ private fun NoteComposeLayoutBoostedPreview() {
|
||||
* - Eliminates 3 layout node levels (Row, author Column, content Column)
|
||||
* - Eliminates Row's two-pass measurement (measure author first, then content)
|
||||
* - Pre-computes all pixel dimensions once via [remember], cached across recompositions
|
||||
* - Remaining per-frame allocations: ~48 bytes for `listOf(6 lambdas)` +
|
||||
* - Remaining per-frame allocations: ~40 bytes for `listOf(5 lambdas)` +
|
||||
* ~24 bytes per `arrayOfNulls` for multi-child slots
|
||||
*
|
||||
* The caller should use `drawBehind { drawRect(color) }` instead of
|
||||
@@ -231,8 +222,6 @@ private fun NoteComposeLayoutBoostedPreview() {
|
||||
* note content. False for repost events.
|
||||
* @param authorPicture Slot for the author's profile picture (55x55dp area).
|
||||
* Should emit nothing when [showAuthorColumn] is false to skip composition.
|
||||
* @param relayBadges Slot for relay indicator icons below the author picture.
|
||||
* Should emit nothing when [showAuthorColumn] is false.
|
||||
* @param firstRow Slot for the primary header: author name, time, more options.
|
||||
* Always present.
|
||||
* @param secondRow Slot for the secondary header: NIP-05, location, PoW, OTS.
|
||||
@@ -250,7 +239,6 @@ fun NoteComposeLayout(
|
||||
showSecondRow: Boolean = false,
|
||||
showContentSpacer: Boolean = true,
|
||||
authorPicture: @Composable () -> Unit,
|
||||
relayBadges: @Composable () -> Unit,
|
||||
firstRow: @Composable () -> Unit,
|
||||
secondRow: @Composable () -> Unit,
|
||||
noteContent: @Composable () -> Unit,
|
||||
@@ -263,7 +251,6 @@ fun NoteComposeLayout(
|
||||
NoteLayoutPx(
|
||||
authorWidth = Size55dp.roundToPx(),
|
||||
authorContentGap = 10.dp.roundToPx(),
|
||||
authorBadgeGap = 5.dp.roundToPx(),
|
||||
contentSpacer = 4.dp.roundToPx(),
|
||||
padStart = 12.dp.roundToPx(),
|
||||
padEnd = 12.dp.roundToPx(),
|
||||
@@ -277,11 +264,10 @@ fun NoteComposeLayout(
|
||||
contents =
|
||||
listOf(
|
||||
authorPicture, // 0
|
||||
relayBadges, // 1
|
||||
firstRow, // 2
|
||||
secondRow, // 3
|
||||
noteContent, // 4
|
||||
reactionsRow, // 5
|
||||
firstRow, // 1
|
||||
secondRow, // 2
|
||||
noteContent, // 3
|
||||
reactionsRow, // 4
|
||||
),
|
||||
modifier = modifier,
|
||||
) { allMeasurables, constraints ->
|
||||
@@ -308,14 +294,13 @@ fun NoteComposeLayout(
|
||||
// Single-child slots: firstOrNull() returns null for empty slots (hidden),
|
||||
// skipping measurement entirely.
|
||||
val authorPlaceable = allMeasurables[0].firstOrNull()?.measure(authorConstraints)
|
||||
val relayPlaceable = allMeasurables[1].firstOrNull()?.measure(authorConstraints)
|
||||
|
||||
val firstRowPlaceable = allMeasurables[2].firstOrNull()?.measure(contentConstraints)
|
||||
val firstRowPlaceable = allMeasurables[1].firstOrNull()?.measure(contentConstraints)
|
||||
val secondRowPlaceable =
|
||||
if (showSecondRow) allMeasurables[3].firstOrNull()?.measure(contentConstraints) else null
|
||||
if (showSecondRow) allMeasurables[2].firstOrNull()?.measure(contentConstraints) else null
|
||||
|
||||
// Multi-child slots: measured into pre-sized arrays to avoid List allocation.
|
||||
val contentMeasurables = allMeasurables[4]
|
||||
val contentMeasurables = allMeasurables[3]
|
||||
val contentPlaceables = arrayOfNulls<Placeable>(contentMeasurables.size)
|
||||
var contentStackHeight = 0
|
||||
for (i in contentMeasurables.indices) {
|
||||
@@ -324,7 +309,7 @@ fun NoteComposeLayout(
|
||||
contentStackHeight += placeable.height
|
||||
}
|
||||
|
||||
val reactionsMeasurables = allMeasurables[5]
|
||||
val reactionsMeasurables = allMeasurables[4]
|
||||
val reactionsPlaceables = arrayOfNulls<Placeable>(reactionsMeasurables.size)
|
||||
var reactionsHeight = 0
|
||||
for (i in reactionsMeasurables.indices) {
|
||||
@@ -344,7 +329,7 @@ fun NoteComposeLayout(
|
||||
|
||||
val authorColumnHeight =
|
||||
if (showAuthorColumn && authorPlaceable != null) {
|
||||
authorPlaceable.height + px.authorBadgeGap + (relayPlaceable?.height ?: 0)
|
||||
authorPlaceable.height
|
||||
} else {
|
||||
0
|
||||
}
|
||||
@@ -357,10 +342,6 @@ fun NoteComposeLayout(
|
||||
// Author column (inside padding area)
|
||||
if (showAuthorColumn) {
|
||||
authorPlaceable?.placeRelative(padStart, padTop)
|
||||
relayPlaceable?.placeRelative(
|
||||
padStart,
|
||||
padTop + (authorPlaceable?.height ?: 0) + px.authorBadgeGap,
|
||||
)
|
||||
}
|
||||
|
||||
// Content column (to the right of author, inside padding area)
|
||||
|
||||
@@ -56,6 +56,8 @@ import com.vitorpamplona.amethyst.service.resourceusage.DisplayResourceUsageAler
|
||||
import com.vitorpamplona.amethyst.service.resourceusage.ScreenTimeIntegrator
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataScreen
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.AllMediaServersScreen
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.BlossomBlobManagerScreen
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DisplayBlossomSyncProgress
|
||||
import com.vitorpamplona.amethyst.ui.actions.paymentTargets.PaymentTargetsScreen
|
||||
import com.vitorpamplona.amethyst.ui.broadcast.DisplayBroadcastProgress
|
||||
import com.vitorpamplona.amethyst.ui.call.CallActivity
|
||||
@@ -77,6 +79,7 @@ import com.vitorpamplona.amethyst.ui.note.UpdateReactionTypeScreen
|
||||
import com.vitorpamplona.amethyst.ui.note.nip22Comments.ReplyCommentPostScreen
|
||||
import com.vitorpamplona.amethyst.ui.note.share.ShareNoteAsImageFileScreen
|
||||
import com.vitorpamplona.amethyst.ui.note.share.ShareNoteAsImageScreen
|
||||
import com.vitorpamplona.amethyst.ui.note.share.ShareNoteAsQrScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountSwitcherAndLeftDrawerLayout
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
@@ -151,6 +154,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.N
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.drafts.DraftListScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.DvmContentDiscoveryScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.favorites.FavoriteAlgoFeedsListScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabAccountWatcher
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabLayer
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabPreloader
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabThemeWatcher
|
||||
@@ -258,6 +262,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SpammingUsersScree
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.UpdateZapAmountScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.UserSettingsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.VideoPlayerSettingsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.nip46.Nip46ConnectedAppsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.nip46.Nip46SignerScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts.ShortsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.softwareapps.SoftwareAppDetailScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.softwareapps.SoftwareAppsScreen
|
||||
@@ -330,6 +336,10 @@ fun AppNavigation(
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
val bottomBarItems by accountViewModel.settings.uiSettingsFlow.bottomBarItems
|
||||
.collectAsStateWithLifecycle()
|
||||
// Move every embedded app to the new account on a switch. Mounted before the layer and
|
||||
// the preloader so the previous account's sessions are dropped ahead of the first sweep
|
||||
// (an embed WebView's storage profile is fixed at construction, so it must be rebuilt).
|
||||
EmbeddedTabAccountWatcher()
|
||||
EmbeddedTabLayer(bottomBarItems.favoriteIds())
|
||||
// Warm every pinned tab at startup so the first tap is instant (content already local).
|
||||
EmbeddedTabPreloader(accountViewModel)
|
||||
@@ -348,6 +358,7 @@ fun AppNavigation(
|
||||
DisplayCrashMessages(accountViewModel, nav)
|
||||
DisplayResourceUsageAlert(accountViewModel, nav)
|
||||
DisplayBroadcastProgress(accountViewModel)
|
||||
DisplayBlossomSyncProgress()
|
||||
|
||||
ObserveIncomingCalls(accountViewModel)
|
||||
}
|
||||
@@ -423,6 +434,8 @@ fun BuildNavigation(
|
||||
composableFromEndArgs<Route.NostrApp>(capWidth = false) { NostrAppScreen(it.coordinate, accountViewModel, nav) }
|
||||
composableFromEnd<Route.ConnectedApps> { ConnectedAppsScreen(accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.ConnectedAppDetail> { ConnectedAppDetailScreen(it.coordinate, accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.Nip46Signer> { Nip46SignerScreen(accountViewModel, nav, it.connectUri) }
|
||||
composableFromEnd<Route.Nip46ConnectedApps> { Nip46ConnectedAppsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.RelayAuthSettings> { RelayAuthSettingsScreen(accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.SoftwareAppDetail> { SoftwareAppDetailScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) }
|
||||
composableFromEnd<Route.Calendars> { CalendarsScreen(accountViewModel, nav) }
|
||||
@@ -557,6 +570,7 @@ fun BuildNavigation(
|
||||
composableFromEnd<Route.RequestToVanish> { RequestToVanishScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.VanishEvents> { VanishEventsScreen(accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.EditMediaServers> { AllMediaServersScreen(accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.ManageBlossomBlobs> { BlossomBlobManagerScreen(accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.EditNestsServers> {
|
||||
com.vitorpamplona.amethyst.ui.actions.nestsServers
|
||||
.NestsServersScreen(accountViewModel, nav)
|
||||
@@ -570,6 +584,7 @@ fun BuildNavigation(
|
||||
composableFromEndArgs<Route.Note> { ThreadScreen(it.id, accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.ShareNoteAsImage> { ShareNoteAsImageScreen(it.id, accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.ShareNoteAsImageFile> { ShareNoteAsImageFileScreen(it.id, accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.ShareNoteAsQr> { ShareNoteAsQrScreen(it.id, accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.ContactListUsers> { ContactListUsersScreen(it.noteId, accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.Hashtag> { HashtagScreen(it, accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.Geohash> { GeoHashScreen(it, accountViewModel, nav) }
|
||||
|
||||
@@ -46,8 +46,10 @@ enum class NavBarItem {
|
||||
DRAFTS,
|
||||
SCHEDULED_POSTS,
|
||||
INTEREST_SETS,
|
||||
BLOSSOM_DATA,
|
||||
EMOJI_PACKS,
|
||||
WALLET,
|
||||
NOSTR_SIGNER,
|
||||
COMMUNITIES,
|
||||
ARTICLES,
|
||||
PICTURES,
|
||||
@@ -181,6 +183,13 @@ val NavBarCatalog: Map<NavBarItem, NavBarItemDef> =
|
||||
icon = MaterialSymbols.AutoAwesome,
|
||||
resolveRoute = { Route.EditFavoriteAlgoFeeds },
|
||||
),
|
||||
NavBarItem.BLOSSOM_DATA to
|
||||
NavBarItemDef(
|
||||
id = NavBarItem.BLOSSOM_DATA,
|
||||
labelRes = R.string.my_blossom_data,
|
||||
icon = MaterialSymbols.Storage,
|
||||
resolveRoute = { Route.ManageBlossomBlobs },
|
||||
),
|
||||
NavBarItem.EMOJI_PACKS to
|
||||
NavBarItemDef(
|
||||
id = NavBarItem.EMOJI_PACKS,
|
||||
@@ -195,6 +204,13 @@ val NavBarCatalog: Map<NavBarItem, NavBarItemDef> =
|
||||
icon = MaterialSymbols.AccountBalanceWallet,
|
||||
resolveRoute = { Route.Wallet },
|
||||
),
|
||||
NavBarItem.NOSTR_SIGNER to
|
||||
NavBarItemDef(
|
||||
id = NavBarItem.NOSTR_SIGNER,
|
||||
labelRes = R.string.nip46_signer_title,
|
||||
icon = MaterialSymbols.Key,
|
||||
resolveRoute = { Route.Nip46Signer() },
|
||||
),
|
||||
NavBarItem.COMMUNITIES to
|
||||
NavBarItemDef(
|
||||
id = NavBarItem.COMMUNITIES,
|
||||
@@ -441,8 +457,10 @@ val DrawerYouItems: List<NavBarItem> =
|
||||
NavBarItem.DRAFTS,
|
||||
NavBarItem.SCHEDULED_POSTS,
|
||||
NavBarItem.INTEREST_SETS,
|
||||
NavBarItem.BLOSSOM_DATA,
|
||||
NavBarItem.EMOJI_PACKS,
|
||||
NavBarItem.WALLET,
|
||||
NavBarItem.NOSTR_SIGNER,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -493,8 +511,10 @@ val BottomBarCategories: List<NavBarCategory> =
|
||||
NavBarItem.SCHEDULED_POSTS,
|
||||
NavBarItem.INTEREST_SETS,
|
||||
NavBarItem.FAVORITE_ALGO_FEEDS,
|
||||
NavBarItem.BLOSSOM_DATA,
|
||||
NavBarItem.EMOJI_PACKS,
|
||||
NavBarItem.WALLET,
|
||||
NavBarItem.NOSTR_SIGNER,
|
||||
),
|
||||
),
|
||||
NavBarCategory(
|
||||
|
||||
@@ -107,6 +107,13 @@ sealed class Route {
|
||||
|
||||
@Serializable object ConnectedApps : Route()
|
||||
|
||||
@Serializable data class Nip46Signer(
|
||||
/** When set (from a scanned/opened `nostrconnect://` offer), the screen connects that app on open. */
|
||||
val connectUri: String? = null,
|
||||
) : Route()
|
||||
|
||||
@Serializable object Nip46ConnectedApps : Route()
|
||||
|
||||
@Serializable object RelayAuthSettings : Route()
|
||||
|
||||
@Serializable data class ConnectedAppDetail(
|
||||
@@ -458,6 +465,8 @@ sealed class Route {
|
||||
|
||||
@Serializable object EditMediaServers : Route()
|
||||
|
||||
@Serializable object ManageBlossomBlobs : Route()
|
||||
|
||||
@Serializable object EditNestsServers : Route()
|
||||
|
||||
@Serializable object EditFavoriteAlgoFeeds : Route()
|
||||
@@ -498,6 +507,10 @@ sealed class Route {
|
||||
val id: String,
|
||||
) : Route()
|
||||
|
||||
@Serializable data class ShareNoteAsQr(
|
||||
val id: String,
|
||||
) : Route()
|
||||
|
||||
@Serializable data class ContactListUsers(
|
||||
val noteId: String,
|
||||
) : Route()
|
||||
|
||||
@@ -37,6 +37,7 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
@@ -72,6 +73,8 @@ import com.vitorpamplona.amethyst.commons.ui.components.LoadingAnimation
|
||||
import com.vitorpamplona.amethyst.model.TopFilter
|
||||
import com.vitorpamplona.amethyst.service.location.LocationState
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserIsFollowingGeohash
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.location.GeohashLocationPickerDialog
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName
|
||||
import com.vitorpamplona.amethyst.ui.screen.CommunityName
|
||||
import com.vitorpamplona.amethyst.ui.screen.FavoriteAlgoFeedName
|
||||
@@ -129,85 +132,108 @@ fun FeedFilterSpinner(
|
||||
|
||||
val openDropdownLabel = stringRes(R.string.open_dropdown_menu)
|
||||
|
||||
Box(
|
||||
modifier = modifier,
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Spacer(modifier = Size20Modifier)
|
||||
val filter = selected?.code ?: placeholderCode
|
||||
|
||||
// Bound the Column so long filter names (e.g. DVM titles) get truncated
|
||||
// instead of wrapping to multiple lines and shoving the expand icon out.
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
) {
|
||||
val filter = selected?.code
|
||||
if (filter is TopFilter.Geohash) {
|
||||
LoadCityName(
|
||||
geohashStr = filter.tag,
|
||||
onLoading = {
|
||||
Row {
|
||||
Text(
|
||||
text = filter.tag,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
LoadingAnimation(indicatorSize = 12.dp, circleWidth = 2.dp)
|
||||
}
|
||||
},
|
||||
) { cityName ->
|
||||
Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Spacer(modifier = Size20Modifier)
|
||||
|
||||
// Bound the Column so long filter names (e.g. DVM titles) get truncated
|
||||
// instead of wrapping to multiple lines and shoving the expand icon out.
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
) {
|
||||
if (filter is TopFilter.Geohash) {
|
||||
LoadCityName(
|
||||
geohashStr = filter.tag,
|
||||
onLoading = {
|
||||
Row {
|
||||
Text(
|
||||
text = filter.tag,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
LoadingAnimation(indicatorSize = 12.dp, circleWidth = 2.dp)
|
||||
}
|
||||
},
|
||||
) { cityName ->
|
||||
Text(
|
||||
text = cityName,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
text = cityName,
|
||||
text = currentText,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
text = currentText,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
if (filter is TopFilter.AroundMe) {
|
||||
val locationPermissionState = rememberPermissionState(Manifest.permission.ACCESS_COARSE_LOCATION)
|
||||
if (!locationPermissionState.status.isGranted) {
|
||||
LaunchedEffect(locationPermissionState) { locationPermissionState.launchPermissionRequest() }
|
||||
if (filter is TopFilter.AroundMe) {
|
||||
val locationPermissionState = rememberPermissionState(Manifest.permission.ACCESS_COARSE_LOCATION)
|
||||
if (!locationPermissionState.status.isGranted) {
|
||||
LaunchedEffect(locationPermissionState) { locationPermissionState.launchPermissionRequest() }
|
||||
|
||||
Text(
|
||||
text = stringRes(R.string.lack_location_permissions),
|
||||
fontSize = Font12SP,
|
||||
lineHeight = 12.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
} else {
|
||||
val location by Amethyst.instance.locationManager.geohashStateFlow
|
||||
.collectAsStateWithLifecycle()
|
||||
Text(
|
||||
text = stringRes(R.string.lack_location_permissions),
|
||||
fontSize = Font12SP,
|
||||
lineHeight = 12.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
} else {
|
||||
val location by Amethyst.instance.locationManager.geohashStateFlow
|
||||
.collectAsStateWithLifecycle()
|
||||
|
||||
when (val myLocation = location) {
|
||||
is LocationState.LocationResult.Success -> {
|
||||
LoadCityName(
|
||||
geohashStr = myLocation.geoHash.toString(),
|
||||
onLoading = {
|
||||
Row {
|
||||
Text(
|
||||
text = "(${myLocation.geoHash})",
|
||||
fontSize = Font12SP,
|
||||
lineHeight = 12.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
LoadingAnimation(indicatorSize = 12.dp, circleWidth = 2.dp)
|
||||
}
|
||||
},
|
||||
) { cityName ->
|
||||
when (val myLocation = location) {
|
||||
is LocationState.LocationResult.Success -> {
|
||||
LoadCityName(
|
||||
geohashStr = myLocation.geoHash.toString(),
|
||||
onLoading = {
|
||||
Row {
|
||||
Text(
|
||||
text = "(${myLocation.geoHash})",
|
||||
fontSize = Font12SP,
|
||||
lineHeight = 12.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
LoadingAnimation(indicatorSize = 12.dp, circleWidth = 2.dp)
|
||||
}
|
||||
},
|
||||
) { cityName ->
|
||||
Text(
|
||||
text = "($cityName)",
|
||||
fontSize = Font12SP,
|
||||
lineHeight = 12.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
LocationState.LocationResult.LackPermission -> {
|
||||
Text(
|
||||
text = "($cityName)",
|
||||
text = stringRes(R.string.lack_location_permissions),
|
||||
fontSize = Font12SP,
|
||||
lineHeight = 12.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
LocationState.LocationResult.Loading -> {
|
||||
Text(
|
||||
text = stringRes(R.string.loading_location),
|
||||
fontSize = Font12SP,
|
||||
lineHeight = 12.sp,
|
||||
maxLines = 1,
|
||||
@@ -215,58 +241,46 @@ fun FeedFilterSpinner(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
LocationState.LocationResult.LackPermission -> {
|
||||
Text(
|
||||
text = stringRes(R.string.lack_location_permissions),
|
||||
fontSize = Font12SP,
|
||||
lineHeight = 12.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
LocationState.LocationResult.Loading -> {
|
||||
Text(
|
||||
text = stringRes(R.string.loading_location),
|
||||
fontSize = Font12SP,
|
||||
lineHeight = 12.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Icon(
|
||||
symbol = MaterialSymbols.ExpandMore,
|
||||
contentDescription = explainer,
|
||||
modifier = Size20Modifier,
|
||||
tint = MaterialTheme.colorScheme.placeholderText,
|
||||
Icon(
|
||||
symbol = MaterialSymbols.ExpandMore,
|
||||
contentDescription = explainer,
|
||||
modifier = Size20Modifier,
|
||||
tint = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.matchParentSize()
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
) {
|
||||
optionsShowing = true
|
||||
}.semantics {
|
||||
role = Role.DropdownList
|
||||
stateDescription = accessibilityDescription
|
||||
onClick(label = openDropdownLabel) {
|
||||
optionsShowing = true
|
||||
return@onClick true
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.matchParentSize()
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
) {
|
||||
optionsShowing = true
|
||||
}.semantics {
|
||||
role = Role.DropdownList
|
||||
stateDescription = accessibilityDescription
|
||||
onClick(label = openDropdownLabel) {
|
||||
optionsShowing = true
|
||||
return@onClick true
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// Save/unsave the active place to the followed-locations list (kind 10081), so a
|
||||
// teleported spot can be kept as a permanent chip — the "add to my interests" step.
|
||||
if (filter is TopFilter.Geohash) {
|
||||
FollowLocationToggle(filter.tag, accountViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
var teleporting by remember { mutableStateOf(false) }
|
||||
|
||||
if (optionsShowing && options.isNotEmpty()) {
|
||||
GroupedFeedFilterDialog(
|
||||
title = explainer,
|
||||
@@ -274,12 +288,60 @@ fun FeedFilterSpinner(
|
||||
onDismiss = { optionsShowing = false },
|
||||
onSelect = { definition ->
|
||||
optionsShowing = false
|
||||
onSelect(definition)
|
||||
// "Teleport" isn't a real filter — open the map picker and apply the chosen
|
||||
// place as this screen's Geohash filter via the normal onSelect path.
|
||||
if (definition.code is TopFilter.TeleportPicker) {
|
||||
teleporting = true
|
||||
} else {
|
||||
onSelect(definition)
|
||||
}
|
||||
},
|
||||
) {
|
||||
RenderOption(it.name, accountViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
if (teleporting) {
|
||||
GeohashLocationPickerDialog(
|
||||
initialGeohash = (selected?.code as? TopFilter.Geohash)?.tag,
|
||||
onDismiss = { teleporting = false },
|
||||
onConfirm = { cell ->
|
||||
teleporting = false
|
||||
onSelect(FeedDefinition(code = TopFilter.Geohash(cell), name = GeoHashName(cell)))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** A compact toggle in the filter header to follow/unfollow the active geohash location. */
|
||||
@Composable
|
||||
private fun FollowLocationToggle(
|
||||
tag: String,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val isFollowing by observeUserIsFollowingGeohash(tag, accountViewModel)
|
||||
IconButton(onClick = {
|
||||
if (!accountViewModel.isWriteable()) {
|
||||
accountViewModel.toastManager.toast(
|
||||
R.string.read_only_user,
|
||||
if (isFollowing) R.string.login_with_a_private_key_to_be_able_to_unfollow else R.string.login_with_a_private_key_to_be_able_to_follow,
|
||||
)
|
||||
} else if (isFollowing) {
|
||||
accountViewModel.unfollowGeohash(tag)
|
||||
} else {
|
||||
accountViewModel.followGeohash(tag)
|
||||
}
|
||||
}) {
|
||||
Icon(
|
||||
symbol = if (isFollowing) MaterialSymbols.Bookmark else MaterialSymbols.BookmarkAdd,
|
||||
contentDescription =
|
||||
stringRes(
|
||||
if (isFollowing) R.string.unfollow_geohash else R.string.follow_geohash,
|
||||
),
|
||||
modifier = Size20Modifier,
|
||||
tint = if (isFollowing) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -366,6 +428,7 @@ private fun FeedDefinition.group(): FeedGroup =
|
||||
is ResourceName -> {
|
||||
when (code) {
|
||||
is TopFilter.AroundMe -> FeedGroup.LOCATIONS
|
||||
is TopFilter.TeleportPicker -> FeedGroup.LOCATIONS
|
||||
is TopFilter.Global -> FeedGroup.RELAYS
|
||||
is TopFilter.Selected -> FeedGroup.RELAYS
|
||||
is TopFilter.AllFavoriteAlgoFeeds -> FeedGroup.DVMS
|
||||
@@ -516,6 +579,10 @@ private fun FeedIcon(
|
||||
MaterialSymbols.LocationOn
|
||||
}
|
||||
|
||||
is TopFilter.TeleportPicker -> {
|
||||
MaterialSymbols.TravelExplore
|
||||
}
|
||||
|
||||
is TopFilter.AllFollows -> {
|
||||
MaterialSymbols.Groups
|
||||
}
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.navigation.topbars
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
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.graphics.SolidColor
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
|
||||
/**
|
||||
* Top bar for the All Settings screen that hosts the settings filter directly in the app bar,
|
||||
* replacing the static "Settings" title. The back arrow is still shown only when there is
|
||||
* something to pop (i.e. the user arrived from a deep/profile link rather than the bottom nav,
|
||||
* which clears the stack). This keeps the shared [TopBarWithBackButton] untouched for every other
|
||||
* screen while giving Settings a search-as-app-bar layout.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SettingsSearchTopBar(
|
||||
query: String,
|
||||
onQueryChange: (String) -> Unit,
|
||||
onClear: () -> Unit,
|
||||
nav: INav,
|
||||
) {
|
||||
ShorterTopAppBar(
|
||||
expandedHeight = SettingsSearchTopBarHeight,
|
||||
title = {
|
||||
CompactSettingsSearchField(
|
||||
query = query,
|
||||
onQueryChange = onQueryChange,
|
||||
onClear = onClear,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(end = 12.dp),
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
// Suppress the back arrow when this is the bottom of the back stack (bottom-nav entry
|
||||
// clears it with popUpTo(route) { inclusive = true }); show it for deep-link entries.
|
||||
if (nav.canPop()) {
|
||||
IconButton(nav::popBack) {
|
||||
ArrowBackIcon()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A compact, pill-shaped search field sized to sit inside the app bar. It filters the settings
|
||||
* list in place rather than opening a results overlay, so it is a plain [BasicTextField] with a
|
||||
* custom decoration box (a full [androidx.compose.material3.TextField] enforces a ~56dp min height
|
||||
* that does not fit an app bar).
|
||||
*/
|
||||
@Composable
|
||||
private fun CompactSettingsSearchField(
|
||||
query: String,
|
||||
onQueryChange: (String) -> Unit,
|
||||
onClear: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
BasicTextField(
|
||||
value = query,
|
||||
onValueChange = onQueryChange,
|
||||
modifier = modifier,
|
||||
singleLine = true,
|
||||
textStyle = MaterialTheme.typography.bodyLarge.copy(color = colorScheme.onSurface),
|
||||
cursorBrush = SolidColor(colorScheme.primary),
|
||||
decorationBox = { innerTextField ->
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(SettingsSearchFieldHeight)
|
||||
.clip(CircleShape)
|
||||
.background(colorScheme.surfaceContainerHigh)
|
||||
.padding(start = 12.dp, end = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Search,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = 8.dp),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
if (query.isEmpty()) {
|
||||
Text(
|
||||
text = stringRes(R.string.settings_search_placeholder),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
innerTextField()
|
||||
}
|
||||
if (query.isNotEmpty()) {
|
||||
IconButton(onClick = onClear) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Close,
|
||||
contentDescription = stringRes(R.string.clear),
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = LocalContentColor.current,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private val SettingsSearchTopBarHeight = 56.dp
|
||||
private val SettingsSearchFieldHeight = 42.dp
|
||||
@@ -0,0 +1,152 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.note
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.MediumRelayIconModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.NotificationIconModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size35dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdStartPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.sample
|
||||
|
||||
/**
|
||||
* Observes the relays the note was seen on, throttled and de-duplicated for the
|
||||
* "accepted by relays" gallery line. Exposed as State so the caller can both gate
|
||||
* the gallery's visibility on it and feed it to [RenderAcceptedByRelaysGallery]
|
||||
* from a single subscription.
|
||||
*/
|
||||
@OptIn(FlowPreview::class)
|
||||
@Composable
|
||||
internal fun observeNoteRelays(baseNote: Note): State<ImmutableList<NormalizedRelayUrl>> {
|
||||
// Cold wrapper: `flow()` must be re-resolved on every collection start. A memory
|
||||
// trim destroys the NoteFlowSet while the lifecycle is stopped; a stateFlow
|
||||
// captured in remember would then be orphaned and never see another relay update.
|
||||
// Sampled at 500ms because relay arrivals churn while a note is actively fanning out.
|
||||
val flow =
|
||||
remember(baseNote) {
|
||||
flow { emitAll(baseNote.flow().relays.stateFlow) }
|
||||
.sample(500)
|
||||
.map { it.note.relays.toImmutableList() }
|
||||
.distinctUntilChanged()
|
||||
}
|
||||
|
||||
val initial = remember(baseNote) { baseNote.relays.toImmutableList() }
|
||||
return flow.collectAsStateWithLifecycle(initial)
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight "does this note have any relays" observer for the reaction row's
|
||||
* expand-button gate, which runs for every note in the feed. Unlike
|
||||
* [observeNoteRelays] it maps to a Boolean (no per-emission list allocation) and
|
||||
* needs no throttling, since the emptiness flips at most a couple of times.
|
||||
*/
|
||||
@Composable
|
||||
internal fun observeNoteHasRelays(baseNote: Note): State<Boolean> {
|
||||
val flow =
|
||||
remember(baseNote) {
|
||||
flow { emitAll(baseNote.flow().relays.stateFlow) }
|
||||
.map { it.note.relays.isNotEmpty() }
|
||||
.distinctUntilChanged()
|
||||
}
|
||||
|
||||
return flow.collectAsStateWithLifecycle(baseNote.relays.isNotEmpty())
|
||||
}
|
||||
|
||||
/**
|
||||
* The "accepted by relays" line of the reaction gallery: the favicon of every relay
|
||||
* the note was seen on. This is the same relay-pill set that Complete UI Mode used to
|
||||
* paint below the author avatar; it now lives in the expanded reaction gallery so it
|
||||
* is available to everyone, not just Complete mode.
|
||||
*/
|
||||
@Composable
|
||||
internal fun RenderAcceptedByRelaysGallery(
|
||||
relays: ImmutableList<NormalizedRelayUrl>,
|
||||
nav: INav,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
// NotificationIconModifier (55dp wide, 5dp end padding) matches the category-icon
|
||||
// column of the zap/like/nutzap gallery rows so the Dns icon lines up with them.
|
||||
Box(modifier = NotificationIconModifier) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Dns,
|
||||
contentDescription = stringRes(id = R.string.accepted_by_relays),
|
||||
modifier = Modifier.size(Size20dp).align(Alignment.TopEnd),
|
||||
tint = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
}
|
||||
|
||||
RelayGallery(relays, nav, accountViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun RelayGallery(
|
||||
relays: ImmutableList<NormalizedRelayUrl>,
|
||||
nav: INav,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
Column(modifier = StdStartPadding) {
|
||||
FlowRow {
|
||||
relays.forEach { relay ->
|
||||
// Match the 35dp author pictures of the sibling zap/boost/reaction lines.
|
||||
RenderRelay(
|
||||
relay = relay,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
boxSize = Size35dp,
|
||||
iconModifier = MediumRelayIconModifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,9 @@ import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChann
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.GenericLoadable
|
||||
import com.vitorpamplona.amethyst.commons.ui.note.HeaderPill
|
||||
import com.vitorpamplona.amethyst.commons.ui.note.QuietMark
|
||||
import com.vitorpamplona.amethyst.commons.ui.note.RenderCashuMint
|
||||
import com.vitorpamplona.amethyst.commons.ui.note.RenderFedimint
|
||||
import com.vitorpamplona.amethyst.commons.ui.note.RenderMintRecommendation
|
||||
import com.vitorpamplona.amethyst.commons.ui.state.produceCachedStateAsync
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
@@ -127,7 +130,6 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarCollectionEvent
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarDateSlotEvent
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarRSVPEvent
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarTimeSlotEvent
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderCashuMint
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderChannelMessage
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderChat
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderChatMessage
|
||||
@@ -137,7 +139,6 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderClassifieds
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderCodeSnippetEvent
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderCommunity
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderEmojiPack
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderFedimint
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderFhirResource
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderFundraiser
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderGitIssueEvent
|
||||
@@ -156,7 +157,6 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderLnZap
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderLongFormContent
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderMeetingRoomEvent
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderMeetingSpaceEvent
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderMintRecommendation
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderMusicPlaylist
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderMusicTrack
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderNIP90ContentDiscoveryResponse
|
||||
@@ -745,11 +745,6 @@ fun InnerNoteWithReactions(
|
||||
}
|
||||
}
|
||||
},
|
||||
relayBadges = {
|
||||
if (notBoostedNorQuote) {
|
||||
BadgeBox(baseNote, accountViewModel, nav)
|
||||
}
|
||||
},
|
||||
firstRow = {
|
||||
FirstUserInfoRow(
|
||||
baseNote = baseNote,
|
||||
@@ -2075,21 +2070,6 @@ fun observeEdits(
|
||||
return editState
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BadgeBox(
|
||||
baseNote: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
if (accountViewModel.settings.isCompleteUIMode()) {
|
||||
if (baseNote.event is RepostEvent || baseNote.event is GenericRepostEvent) {
|
||||
baseNote.replyTo?.lastOrNull()?.let { RelayBadges(it, accountViewModel, nav) }
|
||||
} else {
|
||||
RelayBadges(baseNote, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RenderAuthorImages(
|
||||
baseNote: Note,
|
||||
|
||||
@@ -523,10 +523,13 @@ private fun WatchReactionsZapsBoostsAndDisplayIfExists(
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val hasReactions by observeNoteReferences(baseNote, accountViewModel)
|
||||
val hasRelays by observeNoteHasRelays(baseNote)
|
||||
|
||||
val hasZapraiser = (baseNote.event?.zapraiserAmount() ?: 0) > 0
|
||||
|
||||
if (hasReactions || hasZapraiser) {
|
||||
// The gallery always carries an "accepted by relays" line when the note has been
|
||||
// seen on any relay, so the expand button must be reachable in that case too.
|
||||
if (hasReactions || hasZapraiser || hasRelays) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
@@ -564,8 +567,13 @@ private fun ReactionDetailGallery(
|
||||
val backgroundColor = remember { mutableStateOf(defaultBackgroundColor) }
|
||||
|
||||
val hasReactions by observeNoteReferences(baseNote, accountViewModel)
|
||||
val relays by observeNoteRelays(baseNote)
|
||||
|
||||
if (hasReactions) {
|
||||
// The gallery shows whenever there is anything to display: the "accepted by relays"
|
||||
// line (relays are almost always present once a note is seen) or any zap/boost/
|
||||
// reaction line. Guarding here keeps the padded Row from rendering an empty strip
|
||||
// for a note that has neither.
|
||||
if (hasReactions || relays.isNotEmpty()) {
|
||||
Row(
|
||||
verticalAlignment = CenterVertically,
|
||||
modifier = Modifier.padding(start = 10.dp, top = 5.dp),
|
||||
@@ -576,6 +584,9 @@ private fun ReactionDetailGallery(
|
||||
WatchOnchainZapsAndRenderGallery(baseNote, nav, accountViewModel)
|
||||
WatchBoostsAndRenderGallery(baseNote, nav, accountViewModel)
|
||||
WatchReactionsAndRenderGallery(baseNote, nav, accountViewModel)
|
||||
if (relays.isNotEmpty()) {
|
||||
RenderAcceptedByRelaysGallery(relays, nav, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,6 @@ import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -57,7 +56,6 @@ import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.ShowMoreRelaysButtonBoxModifer
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size17Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.noteComposeRelayBox
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
@@ -67,26 +65,6 @@ import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.sample
|
||||
|
||||
@Composable
|
||||
fun RelayBadges(
|
||||
baseNote: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val expanded = remember { mutableStateOf(false) }
|
||||
|
||||
CrossfadeIfEnabled(expanded.value, modifier = noteComposeRelayBox, label = "RelayBadges", accountViewModel = accountViewModel) {
|
||||
if (it) {
|
||||
RenderAllRelayList(baseNote, Modifier.fillMaxWidth(), accountViewModel = accountViewModel, nav = nav)
|
||||
} else {
|
||||
Column {
|
||||
RenderClosedRelayList(baseNote, Modifier.fillMaxWidth(), accountViewModel = accountViewModel, nav = nav)
|
||||
ShouldShowExpandButton(baseNote, accountViewModel) { ShowMoreRelaysButton { expanded.value = true } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class, FlowPreview::class)
|
||||
@Composable
|
||||
fun RenderAllRelayList(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user