mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-09-14 00:55:08 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e2859f719 | ||
|
|
fa3287f737 | ||
|
|
d759741f96 | ||
|
|
5e662fef0b | ||
|
|
65ea34342e | ||
|
|
6c07dcb839 | ||
|
|
94db88943c | ||
|
|
cd344ac07d | ||
|
|
6f52d1de90 | ||
|
|
54c6521f3f | ||
|
|
7ed368118c | ||
|
|
d691317489 | ||
|
|
725bbc557c | ||
|
|
e9437aa371 | ||
|
|
cdff305242 | ||
|
|
1590dc8570 | ||
|
|
42c96bf8fc | ||
|
|
467a1f0c06 | ||
|
|
421b25bb0e | ||
|
|
9639f97c5e | ||
|
|
2c4cf9d160 | ||
|
|
7a4dc1b378 | ||
|
|
b335e2991a | ||
|
|
d6d5a72e49 | ||
|
|
826fc826db | ||
|
|
6cf09f0d75 | ||
|
|
9ea7c36cf9 | ||
|
|
69532badee | ||
|
|
a1f9cc96f1 | ||
|
|
a0a55af174 | ||
|
|
defbdfbb28 | ||
|
|
af49bcc396 | ||
|
|
cbd0a4a174 | ||
|
|
34c60fada1 | ||
|
|
0030432e20 | ||
|
|
36819b1011 | ||
|
|
985b4c2ea3 | ||
|
|
c25517c2d6 | ||
|
|
790458f9a0 | ||
|
|
6f71eb0c4c | ||
|
|
04d506e81e | ||
|
|
b10be95a6e | ||
|
|
514f4b26f0 | ||
|
|
91d4d66461 | ||
|
|
8a43d707e6 | ||
|
|
de3ce56bcc | ||
|
|
2aa4a43337 | ||
|
|
2415565ebf | ||
|
|
a370b1d8c5 | ||
|
|
e38981fd1d | ||
|
|
7ff8e3b5ac | ||
|
|
e4d288a9c0 | ||
|
|
f4c452a761 | ||
|
|
12a1b571ba | ||
|
|
6f97faf167 | ||
|
|
7665acb878 | ||
|
|
f150696a26 | ||
|
|
3eff5f2d87 | ||
|
|
bfcff43868 | ||
|
|
ef22469410 | ||
|
|
be7f099b7f | ||
|
|
5ea4d6770e | ||
|
|
8d457de93e | ||
|
|
72324011a4 | ||
|
|
4d578006db | ||
|
|
42652e6b36 | ||
|
|
c6916d49d1 | ||
|
|
f4bf36030b | ||
|
|
3b4ac12eb1 | ||
|
|
0d9b2d8d29 | ||
|
|
2f220656f8 | ||
|
|
b71f26917a | ||
|
|
16b4bc9197 | ||
|
|
c857bfe064 | ||
|
|
f40b80eb9c | ||
|
|
029c40ebb4 | ||
|
|
cd5441be44 | ||
|
|
16ef96279f | ||
|
|
81e39fe29b | ||
|
|
340fbee132 | ||
|
|
1da2c32df3 | ||
|
|
cdfc7ddb17 | ||
|
|
42c297104c | ||
|
|
dfddf35e40 | ||
|
|
2ffc5ff49d | ||
|
|
9fae1e526e | ||
|
|
e5e6076039 | ||
|
|
163b272ae8 | ||
|
|
b06093e9a2 | ||
|
|
1d420dc406 | ||
|
|
ccdcf433e1 | ||
|
|
ec27db70cc | ||
|
|
8293cbfb7f | ||
|
|
2735d6612b | ||
|
|
9bd85d0cbc | ||
|
|
4f3e9fd1cd | ||
|
|
b29519e4e6 | ||
|
|
319348de9a | ||
|
|
97919fd460 | ||
|
|
d44e1ff1d3 | ||
|
|
866897022f | ||
|
|
c8bc0f6378 | ||
|
|
dd98d9da9d | ||
|
|
519b76c708 | ||
|
|
f1b36ec4a5 | ||
|
|
d651ad3b1e | ||
|
|
8059dd7898 | ||
|
|
09cddc59f1 | ||
|
|
965d55779c | ||
|
|
1e26fb8d5a | ||
|
|
7ffb5a9556 | ||
|
|
551ded1cf9 | ||
|
|
3a057f47b7 | ||
|
|
040c4c9f01 | ||
|
|
18b0dafec6 | ||
|
|
e7bcb88d30 | ||
|
|
b266f1c403 | ||
|
|
4261124402 | ||
|
|
e0e2e427c4 | ||
|
|
ef4e7075be | ||
|
|
51d3485b53 | ||
|
|
1b569eec7f | ||
|
|
3e459ec5ad | ||
|
|
60d84b8d75 | ||
|
|
deee5e5cde | ||
|
|
d06b83bd53 | ||
|
|
8114f054d4 | ||
|
|
c291026d5a | ||
|
|
e7b7211625 | ||
|
|
a3623ceffd | ||
|
|
00ccef7277 | ||
|
|
b935f4c99c | ||
|
|
a1edb597bc | ||
|
|
8d79145d2a | ||
|
|
9aa4a9536c | ||
|
|
b9ff65290b | ||
|
|
e1df0f0c45 | ||
|
|
22c170c510 | ||
|
|
24a8540ad9 | ||
|
|
fb0d8bd857 | ||
|
|
baae40e5fc | ||
|
|
f11bdb8e49 | ||
|
|
f1c461dcfa | ||
|
|
bdb2990846 | ||
|
|
2f1e1d546c | ||
|
|
cc13664816 | ||
|
|
de9a41ddb9 | ||
|
|
cb5ddd7a77 | ||
|
|
33a64ce73a | ||
|
|
17c9357b2f | ||
|
|
ed2b3ef8d7 | ||
|
|
d28afe0677 | ||
|
|
6becc6efbe | ||
|
|
ff1ecde496 | ||
|
|
2587294c55 | ||
|
|
9eed37c5f4 | ||
|
|
3a53e2b965 | ||
|
|
98f09f29c0 | ||
|
|
f5f8f605ec | ||
|
|
111f3392b4 | ||
|
|
58f144f0d1 | ||
|
|
f4c130d7fe | ||
|
|
2ff7b7f199 | ||
|
|
1c25bcb8d3 | ||
|
|
541b6116d7 | ||
|
|
3e383e77d7 | ||
|
|
c16e3f3c69 | ||
|
|
28e8af2564 | ||
|
|
4ef3d12adf | ||
|
|
e184db69c7 | ||
|
|
7f0e1f2f90 | ||
|
|
6ca19eab90 | ||
|
|
36a74ae10c | ||
|
|
78acc61318 | ||
|
|
1190b55dbf | ||
|
|
81d5add807 | ||
|
|
e4adae05de | ||
|
|
c3f7bf52e9 | ||
|
|
daa6022adc | ||
|
|
18d28cf8ed | ||
|
|
9c8c11a0d7 | ||
|
|
16aae86791 | ||
|
|
6614b0bd60 | ||
|
|
9dc209cdfe | ||
|
|
a82035c601 | ||
|
|
a5e2aae960 | ||
|
|
41fff6b9b3 | ||
|
|
416cd32eb3 | ||
|
|
94976dbe74 | ||
|
|
7594d826a8 |
Executable
+61
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Decide whether a PreToolUse payload on stdin is a push/PR boundary.
|
||||
|
||||
Shared by every pre-push hook in this directory (pre-push-spotless.sh,
|
||||
pre-push-orphan-strings.sh) so the gate condition is defined once. Each hook is
|
||||
a separate process with its own stdin, so this is exec'd per hook rather than
|
||||
run once and shared.
|
||||
|
||||
Exit 0 = this call publishes code (gate it). Exit 1 = let it through.
|
||||
"""
|
||||
|
||||
import json
|
||||
import shlex
|
||||
import sys
|
||||
|
||||
# Reaching the push subcommand means stepping over git's global options first.
|
||||
GLOBAL_WITH_ARG = {"-c", "-C", "--namespace", "--git-dir", "--work-tree", "--exec-path"}
|
||||
|
||||
|
||||
def is_boundary(data):
|
||||
tool = data.get("tool_name", "")
|
||||
if tool.endswith("create_pull_request"):
|
||||
return True
|
||||
if tool != "Bash":
|
||||
return False
|
||||
|
||||
cmd = (data.get("tool_input") or {}).get("command", "")
|
||||
# Tokenize like a shell so `push` inside a quoted commit message or heredoc
|
||||
# stays one token and is NOT mistaken for the push subcommand.
|
||||
try:
|
||||
tokens = shlex.split(cmd, comments=True)
|
||||
except ValueError:
|
||||
tokens = cmd.split()
|
||||
|
||||
for i, token in enumerate(tokens):
|
||||
if token != "git" and not token.endswith("/git"):
|
||||
continue
|
||||
j = i + 1
|
||||
while j < len(tokens):
|
||||
tok = tokens[j]
|
||||
if tok in GLOBAL_WITH_ARG:
|
||||
j += 2
|
||||
elif tok.startswith("-"):
|
||||
j += 1
|
||||
else:
|
||||
break
|
||||
if j < len(tokens) and tokens[j] == "push":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
data = json.load(sys.stdin)
|
||||
except Exception:
|
||||
return 1
|
||||
return 0 if is_boundary(data) else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+87
@@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fail if any locale declares a string resource its default values/ no longer has.
|
||||
|
||||
A key removed or renamed in a default `values/strings.xml` orphans every
|
||||
`values-<locale>/strings.xml` entry that still declares it. In an Android res
|
||||
tree that is an `[ExtraTranslation]` lint ERROR, which aborts
|
||||
`:amethyst:lint<Variant>` and with it the whole `test-and-build-android` CI job.
|
||||
|
||||
Run directly, or via the pre-push-orphan-strings.sh hook that wraps it.
|
||||
Exits 0 when clean, 2 with a report when not.
|
||||
"""
|
||||
|
||||
import glob
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
|
||||
# values-night, values-v29, values-sw600dp, ... are configuration qualifiers,
|
||||
# not locales; only locale-qualified dirs can hold a translation.
|
||||
LOCALE = re.compile(r"^values-(?:b\+[A-Za-z0-9+]+|[a-z]{2,3}(?:-r[A-Z]{2,3})?)$")
|
||||
NAMED = re.compile(r'<(?:string|plurals|string-array)\s+[^>]*name="([^"]+)"')
|
||||
|
||||
# Both Crowdin-managed resource systems (see the find-missing-translations
|
||||
# skill, "Resource trees — scan BOTH"), each with what an orphan costs there.
|
||||
# Android res is the tree lint policies; the Compose-Multiplatform catalog is
|
||||
# not lint-checked, but an orphan there is the same authoring mistake and
|
||||
# leaves a dead translation behind.
|
||||
ROOTS = (
|
||||
("*/src/*/res/values", "Android lint [ExtraTranslation] error — aborts the build"),
|
||||
("*/src/*/composeResources/values", "dead translation — key no longer exists in the default catalog"),
|
||||
)
|
||||
|
||||
|
||||
def names(paths):
|
||||
found = set()
|
||||
for path in paths:
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
found |= set(NAMED.findall(handle.read()))
|
||||
return found
|
||||
|
||||
|
||||
def find_orphans():
|
||||
orphans = defaultdict(list) # (res_root, key, consequence) -> [locale, ...]
|
||||
for pattern, consequence in ROOTS:
|
||||
for default_dir in sorted(glob.glob(pattern)):
|
||||
res_root = os.path.dirname(default_dir)
|
||||
base = names(glob.glob(os.path.join(default_dir, "*.xml")))
|
||||
for locale_dir in sorted(glob.glob(os.path.join(res_root, "values-*"))):
|
||||
locale = os.path.basename(locale_dir)
|
||||
if not LOCALE.match(locale):
|
||||
continue
|
||||
extra = names(glob.glob(os.path.join(locale_dir, "*.xml"))) - base
|
||||
for key in extra:
|
||||
orphans[(res_root, key, consequence)].append(locale[len("values-"):])
|
||||
return orphans
|
||||
|
||||
|
||||
def main():
|
||||
orphans = find_orphans()
|
||||
if not orphans:
|
||||
return 0
|
||||
|
||||
total = sum(len(v) for v in orphans.values())
|
||||
out = sys.stderr
|
||||
print(
|
||||
f"BLOCKED: {total} orphaned translation(s) across {len(orphans)} key(s) — "
|
||||
"translated in a locale, absent from that tree's default values/.",
|
||||
file=out,
|
||||
)
|
||||
print(file=out)
|
||||
for (res_root, key, consequence), locales in sorted(orphans.items()):
|
||||
print(f" {res_root}: {key!r} in {len(locales)} locale(s) — {consequence}", file=out)
|
||||
print(f" {' '.join(sorted(locales))}", file=out)
|
||||
print(file=out)
|
||||
print(
|
||||
"A key removed or renamed in a default values/strings.xml must be deleted\n"
|
||||
"from every values-*/strings.xml in the SAME commit. Crowdin's next sync is\n"
|
||||
"not a cleanup step CI waits for — lint runs on the tree you push.\n"
|
||||
"See amethyst/src/main/res/CLAUDE.md, 'Renaming or removing a string key'.",
|
||||
file=out,
|
||||
)
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+37
@@ -0,0 +1,37 @@
|
||||
#!/bin/bash
|
||||
# PreToolUse gate: no locale string may outlive its default-locale key.
|
||||
#
|
||||
# Fires on `git push` (Bash tool) and on the create_pull_request MCP tool.
|
||||
# Delegates to orphan_strings_check.py, which compares every
|
||||
# `values-<locale>/*.xml` resource name against the union of names declared in
|
||||
# that tree's default `values/*.xml`. Anything present in a locale but absent
|
||||
# from the default is an orphan: in an Android res tree Android lint reports it
|
||||
# as an [ExtraTranslation] ERROR, which aborts `:amethyst:lint<Variant>` and
|
||||
# therefore the whole `test-and-build-android` CI job.
|
||||
#
|
||||
# Why a dedicated hook instead of "just run lint": `:amethyst:lintFdroidBenchmark`
|
||||
# takes ~19 minutes on a warm daemon, so nobody runs it per-commit. This check is
|
||||
# a directory scan and finishes in well under a second.
|
||||
#
|
||||
# Run the scan by hand any time with: .claude/hooks/orphan_strings_check.py
|
||||
set -uo pipefail
|
||||
|
||||
hook_dir="$(cd "$(dirname "$0")" && pwd)"
|
||||
cd "${CLAUDE_PROJECT_DIR:-.}" || exit 0
|
||||
|
||||
# --- Is this call a push/PR boundary? ---
|
||||
payload="$(cat)"
|
||||
|
||||
# Cheap pure-bash pre-filter before paying for a python spawn. The gate below
|
||||
# can only answer "yes" for a payload containing "push" (a git push command) or
|
||||
# "pull_request" (the create_pull_request MCP tool), so anything else is a
|
||||
# guaranteed no. This hook runs on EVERY Bash tool call, and the spawn it skips
|
||||
# costs ~35ms each time.
|
||||
case "$payload" in
|
||||
*push*|*pull_request*) ;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
|
||||
printf '%s' "$payload" | python3 "$hook_dir/lib/git_push_gate.py" || exit 0
|
||||
|
||||
exec python3 "$hook_dir/orphan_strings_check.py"
|
||||
@@ -9,48 +9,23 @@
|
||||
# so a clean apply means a green check.
|
||||
set -uo pipefail
|
||||
|
||||
hook_dir="$(cd "$(dirname "$0")" && pwd)"
|
||||
cd "${CLAUDE_PROJECT_DIR:-.}" || exit 0
|
||||
|
||||
# --- Parse the tool call off stdin; decide whether this call is a boundary. ---
|
||||
# --- Is this call a push/PR boundary? ---
|
||||
payload="$(cat)"
|
||||
should_gate="$(
|
||||
printf '%s' "$payload" | python3 -c '
|
||||
import json, shlex, sys
|
||||
try:
|
||||
data = json.load(sys.stdin)
|
||||
except Exception:
|
||||
print("no"); sys.exit(0)
|
||||
tool = data.get("tool_name", "")
|
||||
if tool.endswith("create_pull_request"):
|
||||
print("yes"); sys.exit(0)
|
||||
if tool != "Bash":
|
||||
print("no"); sys.exit(0)
|
||||
cmd = (data.get("tool_input") or {}).get("command", "")
|
||||
# Tokenize like a shell so `push` inside a quoted commit message or heredoc
|
||||
# stays one token and is NOT mistaken for the push subcommand.
|
||||
try:
|
||||
tokens = shlex.split(cmd, comments=True)
|
||||
except ValueError:
|
||||
tokens = cmd.split()
|
||||
GLOBAL_WITH_ARG = {"-c", "-C", "--namespace", "--git-dir", "--work-tree", "--exec-path"}
|
||||
for i, t in enumerate(tokens):
|
||||
if t != "git" and not t.endswith("/git"):
|
||||
continue
|
||||
j = i + 1
|
||||
while j < len(tokens): # skip git global options to reach the subcommand
|
||||
tok = tokens[j]
|
||||
if tok in GLOBAL_WITH_ARG:
|
||||
j += 2; continue
|
||||
if tok.startswith("-"):
|
||||
j += 1; continue
|
||||
break
|
||||
if j < len(tokens) and tokens[j] == "push":
|
||||
print("yes"); sys.exit(0)
|
||||
print("no")
|
||||
' 2>/dev/null
|
||||
)"
|
||||
|
||||
[ "$should_gate" = "yes" ] || exit 0
|
||||
# Cheap pure-bash pre-filter before paying for a python spawn. The gate below
|
||||
# can only answer "yes" for a payload containing "push" (a git push command) or
|
||||
# "pull_request" (the create_pull_request MCP tool), so anything else is a
|
||||
# guaranteed no. This hook runs on EVERY Bash tool call, and the spawn it skips
|
||||
# costs ~35ms each time.
|
||||
case "$payload" in
|
||||
*push*|*pull_request*) ;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
|
||||
printf '%s' "$payload" | python3 "$hook_dir/lib/git_push_gate.py" || exit 0
|
||||
|
||||
# Nothing to format if no Kotlin is tracked/changed at all — cheap early out.
|
||||
if ! git ls-files --error-unmatch '*.kt' '*.kts' >/dev/null 2>&1; then
|
||||
|
||||
@@ -8,6 +8,11 @@
|
||||
"type": "command",
|
||||
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/pre-push-spotless.sh",
|
||||
"timeout": 180
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/pre-push-orphan-strings.sh",
|
||||
"timeout": 30
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -540,6 +540,7 @@ When adding translated strings to locale files:
|
||||
- **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`.)
|
||||
- **Declaring the pass done without running `:amethyst:lintPlayBenchmark`** — the duplicate-key + XML + `convertXmlValueResourcesForCommonMain` gate is necessary but nowhere near sufficient. `MissingQuantity` and `ImpliedQuantity` are errors, there is no lint baseline, and `abortOnError` is on, so a change that compiles and passes every check in Step 6's first half can still take CI red. Compiling is not evidence. (Happened 2026-08-13: 3 lint errors after a clean duplicate/XML gate and a green `compileFdroidDebugKotlin`.)
|
||||
- **Converting a `<string>` to `<plurals>` with `other` only** — "Crowdin fills the rest" is false; `MissingQuantity` errors immediately and CI fails before any sync. Supply every category the locale uses at conversion time, and re-check the declension rather than reusing the old text for `one`.
|
||||
- **Renaming or removing a key in `values/strings.xml` without deleting it from every locale in the same commit** — the surviving locale entries become orphans, and `ExtraTranslation` is an error. "Crowdin drops retired keys on its next sync" is the same false belief as the `other`-only shortcut above: lint runs on the tree you push. Worse, it's *partly* true — the sync cleans some locales and silently leaves others, so the files you happen to open look fine. Scan with the sub-second `.claude/hooks/orphan_strings_check.py` instead of the ~19-minute lint; see `amethyst/src/main/res/CLAUDE.md`, "Renaming or removing a string key". (Happened 2026-08-31: `route_video`/`new_short` left in 15 of 47 locales, 30 errors, red `main`.)
|
||||
- **Putting `tools:ignore` on a locale file** — Crowdin strips it on the next export. Suppressions belong on the source entry in `values/strings.xml`, which propagates. The `tools:ignore="Typos"` copies visible in cs/de/ar/eo/bn are the *result* of that propagation, not proof that locale-file attributes survive. (Happened 2026-08-13; it broke `main`.)
|
||||
- **Suppressing a lint rule on a key nothing references** — check `grep -rn "<key>" --include='*.kt'` first. `poll_results_voters` was a bare noun with no count, zero call sites, and an unlocalizable shape; deleting it retired the problem outright where a suppression would only have muted it.
|
||||
- **Comparing placeholders without a `(?<!\\)` guard** — `\%2$d` is an escaped literal to lint, but a naive `%\d+\$[sd]` regex matches the placeholder inside it and reports the string clean. A parity sweep missing this guard will certify a broken translation. Also treat a *repeated* index (`%1$s` twice where the base has it once) as legitimate — German does this where English says "They".
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
---
|
||||
name: find-non-lambda-logs
|
||||
description: Use when auditing or migrating Log calls — flags both interpolated Log.d/i/w/e that should use the lambda overload (allocation hygiene) and catch-block Log.w/e that interpolate ${e.message} but drop the throwable (lost stack traces)
|
||||
description: Use when auditing or migrating Log calls — flags interpolated Log.d/i/w/e that should use the lambda overload (allocation hygiene), catch-block Log.w/e that interpolate ${e.message} but drop the throwable (lost stack traces), and files still importing android.util.Log (no lambda overload, bypasses Log.minLevel)
|
||||
---
|
||||
|
||||
# Find Non-Lambda Log Calls
|
||||
|
||||
## Overview
|
||||
|
||||
Two related logging hygiene issues:
|
||||
Three related logging hygiene issues:
|
||||
|
||||
1. **Lambda overload missing.** `Log.d/i/w/e` calls that use string interpolation without the lambda overload waste string allocation when the log level is filtered out in release builds.
|
||||
2. **Throwable dropped in catch blocks.** `Log.w/e` calls inside `catch (e: ...)` blocks that interpolate `${e.message}` but don't pass `e` lose the stack trace, and log nothing useful when `e.message` is null (NPE, IOException with no message, etc.).
|
||||
3. **Still on `android.util.Log`.** Files importing the platform logger bypass `Log.minLevel` and the `LogSink`, and have no lambda overload — so neither fix above can be applied to them. Step 0 finds these; the last section migrates them.
|
||||
|
||||
## When to Use
|
||||
|
||||
@@ -39,6 +40,54 @@ Log.d("Tag", "Initialization complete")
|
||||
|
||||
**Important:** Tags can be string literals (`"Tag"`) or variables (`tag`, `LOG_TAG`). Run both patterns for each step.
|
||||
|
||||
**The throwable-name alternation, used by Steps 2 and 3** — define it once and reuse it, rather than writing a shorter list in one step and a longer one in another:
|
||||
|
||||
```bash
|
||||
THROWABLE='(e|t|it|ex|err|error|throwable|cause|tr)'
|
||||
```
|
||||
|
||||
**Filter the noise before counting**, or the totals mislead: drop `/build/`, `/androidTest/` and `/src/test/` (release filtering doesn't apply to tests), and drop lines whose first non-space character is `//` or `*` — commented-out calls and KDoc examples both match these patterns. A `grep -vE ':[0-9]+: *(//|\*)'` handles the last one.
|
||||
|
||||
### Step 0: Find files still on `android.util.Log` (run this first)
|
||||
|
||||
**Two patterns — the fully-qualified one alone is a false negative.** Almost nobody writes `android.util.Log.w(...)` at the call site; they `import android.util.Log` and then write `Log.w(...)`, which is indistinguishable from the wrapper by call shape. The import is the reliable signal:
|
||||
|
||||
```bash
|
||||
# the form that actually occurs
|
||||
grep -rln --include='*.kt' '^import android\.util\.Log$' . | grep -v '/build/' | grep -v PlatformLog
|
||||
|
||||
# the rare fully-qualified call
|
||||
grep -rnE --include='*.kt' 'android\.util\.Log\.(d|i|w|e|v)\(' . | grep -v '/build/' | grep -v PlatformLog
|
||||
```
|
||||
|
||||
On 2026-08-28 the fully-qualified pattern reported **0** while the import pattern found **16 production files** (9 in `nappletHost`, the rest in amethyst's `favorites/` and `napplet/`). Exclude `PlatformLog.android.kt`, which is the wrapper implementation and must call `android.util.Log`.
|
||||
|
||||
These bypass the `Log.minLevel` filter and the `LogSink` indirection entirely, and — the practical consequence for this skill — **they have no lambda overload**, so Steps 1–3 cannot be applied to them until they are migrated. Subtract these files from the Step 1–3 candidate lists, or migrate them first (see the last section).
|
||||
|
||||
### Step 0b: The patterns are line-anchored — sweep multi-line calls separately
|
||||
|
||||
Every `pattern:` in Steps 1–3 matches a call written on one line. A call formatted as
|
||||
|
||||
```kotlin
|
||||
Log.d(
|
||||
TAG,
|
||||
"WASTE ${url.url} dials=${r.tentatives.get()} " +
|
||||
"fail=[${r.failures.entries.joinToString { … }}]",
|
||||
)
|
||||
```
|
||||
|
||||
is **structurally invisible** to them. That biases the audit towards short calls and away from expensive ones — the multi-line form is what long, heavily interpolated messages look like, and those are exactly the ones worth deferring. A 2026-08-28 sweep converted three one-line banner calls in `BootRelayDiagnostics.kt` while walking past two `Log.d` calls in `forEach` loops immediately below them, running 25 and 20 iterations per census with nested `joinToString` in each — strictly the larger cost, three lines away.
|
||||
|
||||
Catch them with the open-paren-at-EOL form, then read each hit:
|
||||
|
||||
```bash
|
||||
grep -rnE --include='*.kt' 'Log\.[diwe]\($' . | grep -v '/build/'
|
||||
# or, to see the whole call:
|
||||
rg -U --multiline --type kotlin 'Log\.[diwe]\(\n[^)]*\$\{'
|
||||
```
|
||||
|
||||
**Prioritise call sites inside loops over one-liners.** A `Log.d` in a 25-iteration `forEach` discards 25 built strings per pass; a one-line banner discards one.
|
||||
|
||||
### Step 1: Find interpolated Log.d/Log.i (highest priority — filtered in release)
|
||||
|
||||
```
|
||||
@@ -61,7 +110,14 @@ pattern: Log\.(w|e)\(\w+,\s*"[^"]*\$
|
||||
type: kotlin
|
||||
```
|
||||
|
||||
Then **manually exclude** lines where a throwable is passed as third argument (ending with `, e)`, `, throwable)`, etc.). Check the actual line — a catch block catching `e` doesn't mean `e` is passed to the Log call.
|
||||
Then **manually exclude** lines where a throwable is passed as third argument. Check the actual line — a catch block catching `e` doesn't mean `e` is passed to the Log call.
|
||||
|
||||
**`it` is the name you will miss.** `Result.onFailure { ... }` is the dominant shape in this repo, so most correct calls end `, it)`, not `, e)`. Excluding only `e`/`throwable` inflates the result badly — a 2026-08-28 pass reported 23 hits where the real number was 8, because 14 of them were `.onFailure { Log.w(TAG, "...", it) }` and already correct. Also note the throwable is not always last on the line (`}.onFailure { Log.w(...) }.getOrDefault(false)`), so anchoring the exclusion to `$` misses them:
|
||||
|
||||
```bash
|
||||
grep -rnE --include='*.kt' 'Log\.(w|e)\([^,]+,\s*"[^"]*\$' . \
|
||||
| grep -vE ",\s*$THROWABLE\)" # note: no $ anchor, and `it` included
|
||||
```
|
||||
|
||||
### Step 3: Find catch-block Log.w/e that drop the throwable
|
||||
|
||||
@@ -70,23 +126,14 @@ Among the Step 2 hits, the calls that interpolate `${e.message}` (or `${t.messag
|
||||
Quick filter:
|
||||
|
||||
```
|
||||
pattern: Log\.(w|e)\([^)]*\$\{(e|t|throwable|cause)\.message\}[^)]*\)$
|
||||
pattern: Log\.(w|e)\([^)]*\$\{(e|t|it|ex|err|throwable|cause)\.message\}
|
||||
type: kotlin
|
||||
```
|
||||
|
||||
Then for each hit, open the file and confirm the line is **inside a `catch (e: ...)` block** and **does not pass `e` (or the matching name) as a third argument**. False positives: extension functions / helpers that accept an `e: SomeError` parameter and forward it elsewhere.
|
||||
Note this deliberately omits the `\)$` anchor and includes `it` — same reasons as Step 2. Then for each hit, open the file and confirm the line is **inside a `catch (e: ...)` block** and **does not pass `e` (or the matching name) as a third argument**. False positives: extension functions / helpers that accept an `e: SomeError` parameter and forward it elsewhere.
|
||||
|
||||
Both Step 2 and Step 3 may flag the same line — handle Step 3 first (different fix), then apply Step 2 to whatever remains.
|
||||
|
||||
### Step 4: Verify no android.util.Log leakage
|
||||
|
||||
```
|
||||
pattern: android\.util\.Log\.(d|i|w|e|v)\(
|
||||
type: kotlin
|
||||
```
|
||||
|
||||
These bypass the `Log.minLevel` filter entirely. Exclude `PlatformLog.android.kt` which is the wrapper implementation.
|
||||
|
||||
## Fix Patterns
|
||||
|
||||
### Lambda overload (Step 1 + Step 2)
|
||||
@@ -106,7 +153,7 @@ Switch to `(tag, msg, throwable)` — the lambda overload does **not** accept a
|
||||
```kotlin
|
||||
// Before — stack trace lost, prints "...failed: null" if e.message is null
|
||||
try { groupManager.clearAllState() } catch (e: Exception) {
|
||||
Log.w("MarmotManager") { "clearAllState failed: ${e.message}" }
|
||||
Log.w("MarmotManager", "clearAllState failed: ${e.message}")
|
||||
}
|
||||
|
||||
// After — full stack trace logged
|
||||
@@ -120,6 +167,25 @@ Trade-off: the message string is allocated eagerly even when warn is filtered, b
|
||||
## Do NOT Convert
|
||||
|
||||
- **To lambda:** calls passing a `Throwable` parameter — the lambda overload `(tag) { message }` has no throwable parameter.
|
||||
- **To lambda: any call in a file that imports `android.util.Log`.** The platform `Log` has no lambda overload, so the conversion fails to compile with `None of the following candidates is applicable`. Either migrate the file first (below) or leave the call alone. (Hit on 2026-08-28: three edits in two files had to be reverted.)
|
||||
- Static string calls with no `$` interpolation — no allocation benefit.
|
||||
- Commented-out log calls.
|
||||
- Informational/intentional log of `e.message` *outside* a catch block (rare; usually means the exception was already handled and only the message is meaningful).
|
||||
|
||||
## Migrating a file off `android.util.Log`
|
||||
|
||||
This is what unlocks Steps 1–3 for the files Step 0 finds. It is a behaviour change, so check it rather than assuming — but in this repo the check has come out safe, and here is the reasoning to redo:
|
||||
|
||||
1. **Which levels does the file use?** `grep -hoE 'Log\.[a-zA-Z]+' <files> | sort | uniq -c`. The wrapper has `d/i/w/e` only — **no `v`**, and no `getStackTraceString`. A `Log.v` call has no direct equivalent and needs a decision, not a rename.
|
||||
2. **Would the gate drop them?** `LogLevel { DEBUG, INFO, WARN, ERROR }`, the gate is `minLevel <= <level>`, and `Amethyst.DEFAULT_LOG_LEVEL` is INFO in debug, **WARN in release** (deliberately — so relay-protocol refusals stay visible in the field). The wrapper's own default is `DEBUG`. So `Log.w` and `Log.e` survive in every build type and in every process, including before `Amethyst.init` runs — which matters for `:napplet`. `Log.d`/`Log.i` **would** go silent in release; those need a conscious call.
|
||||
3. **Does the output move?** No. `PlatformLogSink` on Android delegates to `android.util.Log`, so lines land in logcat unchanged.
|
||||
4. **Can the module see quartz?** `nappletHost` already has `implementation(project(":quartz"))`. Check before assuming.
|
||||
|
||||
Then: swap `import android.util.Log` → `import com.vitorpamplona.quartz.utils.Log`, run `./gradlew spotlessApply` (import order changes), and convert only the interpolated no-throwable calls to the lambda form. Calls that already pass a throwable keep the eager three-arg shape — the wrapper's `w(tag, msg, throwable)` matches exactly, so only the import moves.
|
||||
|
||||
**Verify the throwables survived**, since a careless rewrite can drop the third argument silently:
|
||||
|
||||
```bash
|
||||
grep -hoE 'Log\.[diwe]\([^)]*,\s*(e|it)\)' <files> | wc -l # compare before/after
|
||||
```
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
Every concrete `SearchableEvent` implementor in Quartz, with the exact `indexableContent()`
|
||||
expression. **Update this file in the same PR as any change to the searchable set or to an
|
||||
`indexableContent()` body** (see SKILL.md). Verified against the code 2026-08-04.
|
||||
`indexableContent()` body** (see SKILL.md). Verified against the code 2026-08-25.
|
||||
|
||||
Counts: 126 concrete classes covering 129 kind values (`GitStatusEvent` spans 4 kinds;
|
||||
Counts: 130 concrete classes covering 133 kind values (`GitStatusEvent` spans 4 kinds;
|
||||
kind 30063 has a collision — see the footnote). File paths are under
|
||||
`quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/`.
|
||||
|
||||
@@ -100,6 +100,10 @@ Separator legend: **NL** = `joinToString("\n")`, **SP** = `joinToString(" ")`.
|
||||
| 30313 | MeetingRoomEvent | nip53LiveActivities/meetingSpaces | `listOfNotNull(title(), summary())` NL |
|
||||
| 30315 | StatusEvent | nip38UserStatus | `content` |
|
||||
| 30382 | ContactCardEvent | nip85TrustedAssertions/users | `(listOfNotNull(petName(), summary()) + topics())` NL — public tags only, never the NIP-44 content |
|
||||
| 30392 | UserTrustedListEvent | experimental/trustedLists/users | inherited `TrustedListEvent`: `title() ?: ""` — the label only; `metric`/`d` are machine ids and `content` is a JSON echo of the membership |
|
||||
| 30393 | EventTrustedListEvent | experimental/trustedLists/events | inherited `TrustedListEvent`: `title() ?: ""` |
|
||||
| 30394 | AddressableTrustedListEvent | experimental/trustedLists/addressables | inherited `TrustedListEvent`: `title() ?: ""` |
|
||||
| 30395 | ExternalIdTrustedListEvent | experimental/trustedLists/externalIds | inherited `TrustedListEvent`: `title() ?: ""` |
|
||||
| 30402 | ClassifiedsEvent | nip99Classifieds | `listOfNotNull(title(), summary(), content)` NL |
|
||||
| 30617 | GitRepositoryEvent | nip34Git/repository | `listOfNotNull(name(), description(), content)` NL |
|
||||
| 30620 | WorkflowDefEvent | buzz/workflow | `listOfNotNull(name(), content)` NL |
|
||||
@@ -150,6 +154,7 @@ declares `KIND = 30063` and implements `SearchableEvent` (`content`), but `Event
|
||||
| `InteractiveStoryBaseEvent` | `listOfNotNull(title(), summary(), content)` NL | 30296, 30297 |
|
||||
| `AddressableVideoEvent` | `listOfNotNull(title(), content)` NL | 34235, 34236 |
|
||||
| `RegularVideoEvent` | `listOfNotNull(title(), content)` NL | 21, 22 |
|
||||
| `TrustedListEvent` | `title() ?: ""` | 30392, 30393, 30394, 30395 |
|
||||
|
||||
## How to regenerate / verify this table
|
||||
|
||||
|
||||
+100
-6
@@ -21,8 +21,11 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Orphaned translations (no locale string may outlive its default key)
|
||||
run: .claude/hooks/orphan_strings_check.py
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v5.7.0
|
||||
uses: actions/setup-java@v6.0.0
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: 21
|
||||
@@ -69,7 +72,7 @@ jobs:
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v5.7.0
|
||||
uses: actions/setup-java@v6.0.0
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: 21
|
||||
@@ -94,6 +97,39 @@ jobs:
|
||||
$CMD
|
||||
fi
|
||||
|
||||
# This job runs five test suites (:quartz, :commons, :nestsClient, :cli,
|
||||
# :desktopApp) but, unlike test-geode / test-quartz-ios /
|
||||
# test-and-build-android, published nothing when one of them failed. The
|
||||
# console line names the failing test and the exception class and stops
|
||||
# there, so the message is lost with the runner. That is how the
|
||||
# NostrClientNegentropySyncTest failure in run 10540 became
|
||||
# undiagnosable: NegentropySyncException carries a `detail` naming which
|
||||
# branch fired (connect timeout / idle silence / NEG-ERR / disconnect),
|
||||
# and nobody could read it. Same action and pin as the Android job below.
|
||||
- name: Desktop Test Report
|
||||
uses: mikepenz/action-junit-report@a9170d5795813c01ab4901ffb045b52bab4ab09d # v6.5.0
|
||||
if: always()
|
||||
with:
|
||||
report_paths: '**/build/test-results/**/TEST-*.xml'
|
||||
annotate_only: true
|
||||
detailed_summary: true
|
||||
fail_on_failure: true
|
||||
|
||||
# The HTML reports carry the full stack traces and stdout/stderr the
|
||||
# annotations truncate. Named per-OS because the three matrix legs upload
|
||||
# into the same run and artifact names must be unique.
|
||||
- name: Upload Desktop Test Reports
|
||||
uses: actions/upload-artifact@v7
|
||||
if: failure()
|
||||
with:
|
||||
name: Desktop Test Reports (${{ matrix.os }})
|
||||
path: |
|
||||
quartz/build/reports/tests
|
||||
commons/build/reports/tests
|
||||
nestsClient/build/reports/tests
|
||||
cli/build/reports/tests
|
||||
desktopApp/build/reports/tests
|
||||
|
||||
# jpackage pins libicu to the build host's version (libicu74 on
|
||||
# ubuntu-24.04). Rewrite the .deb so testers on other Debian/Ubuntu
|
||||
# releases can install the uploaded artifact.
|
||||
@@ -126,7 +162,7 @@ jobs:
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v5.7.0
|
||||
uses: actions/setup-java@v6.0.0
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: 21
|
||||
@@ -158,6 +194,64 @@ jobs:
|
||||
name: geode Test Reports
|
||||
path: geode/build/reports
|
||||
|
||||
# Until this job existed nothing ran the linuxX64 target at all — it was compiled by
|
||||
# no CI leg. That is how a copy-on-write LargeCache with O(n) writes and a non-atomic
|
||||
# read-copy-write (concurrent writers silently dropped entries) sat in the tree
|
||||
# unnoticed, and how TestResourceLoader stayed a TODO() that failed every vector-driven
|
||||
# suite on the target.
|
||||
#
|
||||
# Runs the whole :quartz suite on a Linux Native frontend, which also catches a
|
||||
# commonMain or commonTest source reaching for a JVM-only API on a target that, unlike
|
||||
# Apple, has no Foundation to fall back on.
|
||||
test-quartz-linux-native:
|
||||
needs: lint
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v6.0.0
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: 21
|
||||
|
||||
- name: Set up Gradle
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
# The Kotlin/Native toolchain (compiler distribution + LLVM + the sysroot) lands
|
||||
# in ~/.konan, which setup-gradle does not cache. Without this the job re-downloads
|
||||
# well over a gigabyte on every run. Keyed on the version catalog so a Kotlin bump
|
||||
# re-populates it.
|
||||
- name: Cache Kotlin/Native toolchain
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.konan
|
||||
key: konan-${{ runner.os }}-${{ hashFiles('gradle/libs.versions.toml') }}
|
||||
restore-keys: konan-${{ runner.os }}-
|
||||
|
||||
- name: Test Quartz on Linux Native
|
||||
run: ./gradlew :quartz:linuxX64Test
|
||||
|
||||
- name: Linux Native Test Report
|
||||
uses: mikepenz/action-junit-report@a9170d5795813c01ab4901ffb045b52bab4ab09d # v6.5.0
|
||||
if: always()
|
||||
with:
|
||||
report_paths: 'quartz/build/test-results/linuxX64Test/TEST-*.xml'
|
||||
annotate_only: true
|
||||
detailed_summary: true
|
||||
fail_on_failure: true
|
||||
|
||||
- name: Upload Linux Native Test Reports
|
||||
uses: actions/upload-artifact@v7
|
||||
if: failure()
|
||||
with:
|
||||
name: Quartz Linux Native Test Reports
|
||||
path: quartz/build/reports
|
||||
|
||||
test-quartz-ios:
|
||||
# Phase 1 of the iOS support plan
|
||||
# (amethyst/plans/2026-05-24-ios-support.md): keep :quartz green on iOS
|
||||
@@ -173,7 +267,7 @@ jobs:
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v5.7.0
|
||||
uses: actions/setup-java@v6.0.0
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: 21
|
||||
@@ -232,7 +326,7 @@ jobs:
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v5.7.0
|
||||
uses: actions/setup-java@v6.0.0
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: 21
|
||||
@@ -289,7 +383,7 @@ jobs:
|
||||
# GITHUB_TOKEN is read-only). fail_on_failure preserves the old step's
|
||||
# behavior of marking the job red when a test fails.
|
||||
- name: Android Test Report
|
||||
uses: mikepenz/action-junit-report@d9f48fc87bc235f7e214acf696ca5abc0a986f16 # v6.4.2
|
||||
uses: mikepenz/action-junit-report@a9170d5795813c01ab4901ffb045b52bab4ab09d # v6.5.0
|
||||
if: always()
|
||||
with:
|
||||
report_paths: '**/build/test-results/**/TEST-*.xml'
|
||||
|
||||
@@ -3,11 +3,11 @@ name: Bump Homebrew Formula (geode relay)
|
||||
# Sibling of bump-homebrew-formula.yml (the amy CLI). Same mechanism, different
|
||||
# artifact:
|
||||
# - bump-homebrew-formula.yml -> Formula `amy` (the headless CLI)
|
||||
# - this workflow -> Formula `geode` (the standalone relay)
|
||||
# - this workflow -> Formula `geode-relay` (the standalone relay)
|
||||
#
|
||||
# After a stable release, download the published `geode-<version>-jvm.tar.gz`
|
||||
# bundle, compute its sha256, and open a PR that syncs
|
||||
# `geode/packaging/homebrew/geode.rb`'s url + sha256 to that release. Keeping the
|
||||
# `geode/packaging/homebrew/geode-relay.rb`'s url + sha256 to that release. Keeping the
|
||||
# in-repo reference formula accurate makes the eventual homebrew-core submission a
|
||||
# copy-paste.
|
||||
#
|
||||
@@ -101,7 +101,7 @@ jobs:
|
||||
- name: Update reference formula
|
||||
run: |
|
||||
set -euo pipefail
|
||||
FORMULA=geode/packaging/homebrew/geode.rb
|
||||
FORMULA=geode/packaging/homebrew/geode-relay.rb
|
||||
URL="${{ steps.asset.outputs.url }}"
|
||||
SHA="${{ steps.asset.outputs.sha256 }}"
|
||||
# Rewrite the two indented lines in the formula block. Anchoring on the
|
||||
@@ -119,11 +119,11 @@ jobs:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
base: main
|
||||
branch: chore/bump-geode-formula-${{ steps.rel.outputs.tag }}
|
||||
add-paths: geode/packaging/homebrew/geode.rb
|
||||
add-paths: geode/packaging/homebrew/geode-relay.rb
|
||||
commit-message: 'chore: sync geode Homebrew formula to ${{ steps.rel.outputs.tag }}'
|
||||
title: 'chore: sync geode Homebrew formula to ${{ steps.rel.outputs.tag }}'
|
||||
body: |
|
||||
Auto-synced `geode/packaging/homebrew/geode.rb` to the
|
||||
Auto-synced `geode/packaging/homebrew/geode-relay.rb` to the
|
||||
`${{ steps.rel.outputs.tag }}` release:
|
||||
|
||||
- `url` -> `${{ steps.asset.outputs.url }}`
|
||||
@@ -158,7 +158,7 @@ jobs:
|
||||
``,
|
||||
`Recovery options:`,
|
||||
`1. Re-run the workflow once the underlying issue is fixed`,
|
||||
`2. Manually update \`geode/packaging/homebrew/geode.rb\` (url + sha256) from the release asset`,
|
||||
`2. Manually update \`geode/packaging/homebrew/geode-relay.rb\` (url + sha256) from the release asset`,
|
||||
`3. Check the release actually published \`geode-${tag.replace(/^v/, '')}-jvm.tar.gz\``
|
||||
].join('\n'),
|
||||
labels: ['release-ops', 'bug']
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: Sync Homebrew Cask Reference
|
||||
|
||||
# Sibling of bump-homebrew-formula.yml (amy) and bump-homebrew-geode-formula.yml
|
||||
# (geode). Same mechanism, third artifact:
|
||||
# (geode-relay). Same mechanism, third artifact:
|
||||
# - this workflow -> Cask `amethyst-nostr` (the desktop GUI app / DMG)
|
||||
#
|
||||
# What it does: after a stable release, download the published macOS DMG, assert
|
||||
|
||||
@@ -2,7 +2,7 @@ name: Sync Winget Manifest Reference
|
||||
|
||||
# Fourth sibling of the three Homebrew sync workflows, same shape:
|
||||
# bump-homebrew-formula.yml -> Formula `amy`
|
||||
# bump-homebrew-geode-formula.yml -> Formula `geode`
|
||||
# bump-homebrew-geode-formula.yml -> Formula `geode-relay`
|
||||
# bump-homebrew.yml -> Cask `amethyst-nostr`
|
||||
# this workflow -> Winget `VitorPamplona.Amethyst`
|
||||
#
|
||||
|
||||
@@ -78,7 +78,7 @@ jobs:
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v5.7.0
|
||||
uses: actions/setup-java@v6.0.0
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: 21
|
||||
@@ -344,7 +344,7 @@ jobs:
|
||||
|
||||
- name: Upload to GH Release (skip on dry-run)
|
||||
if: github.event_name != 'workflow_dispatch' || github.event.inputs.dry_run != 'true'
|
||||
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
|
||||
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3
|
||||
with:
|
||||
files: dist/*
|
||||
tag_name: ${{ steps.ver.outputs.tag }}
|
||||
@@ -405,7 +405,7 @@ jobs:
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v5.7.0
|
||||
uses: actions/setup-java@v6.0.0
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: 21
|
||||
@@ -604,7 +604,7 @@ jobs:
|
||||
|
||||
- name: Upload to GH Release (skip on dry-run)
|
||||
if: github.event_name != 'workflow_dispatch' || github.event.inputs.dry_run != 'true'
|
||||
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
|
||||
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3
|
||||
with:
|
||||
files: dist/*
|
||||
tag_name: ${{ steps.ver.outputs.tag }}
|
||||
@@ -662,7 +662,7 @@ jobs:
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v5.7.0
|
||||
uses: actions/setup-java@v6.0.0
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: 21
|
||||
@@ -864,7 +864,7 @@ jobs:
|
||||
|
||||
- name: Upload to GH Release (skip on dry-run)
|
||||
if: github.event_name != 'workflow_dispatch' || github.event.inputs.dry_run != 'true'
|
||||
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
|
||||
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3
|
||||
with:
|
||||
files: dist/*
|
||||
tag_name: ${{ steps.ver.outputs.tag }}
|
||||
@@ -953,7 +953,7 @@ jobs:
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v5.7.0
|
||||
uses: actions/setup-java@v6.0.0
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: 21
|
||||
@@ -1097,7 +1097,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Upload Android assets to GH Release
|
||||
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
|
||||
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3
|
||||
with:
|
||||
files: dist/*
|
||||
tag_name: ${{ github.ref_name }}
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v5.7.0
|
||||
uses: actions/setup-java@v6.0.0
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: 21
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v5.7.0
|
||||
uses: actions/setup-java@v6.0.0
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: 21
|
||||
|
||||
@@ -186,6 +186,13 @@ device. PRs that introduce any of them will be sent back.
|
||||
body only runs when the log level is enabled. Plain
|
||||
`Log.d("msg $x")` allocates the formatted string on every call,
|
||||
including in feed and scroll hot paths.
|
||||
- **Never `import android.util.Log`.** The platform logger bypasses
|
||||
`Log.minLevel` and the `LogSink`, and it has no lambda overload, so
|
||||
the rule above cannot be applied at those call sites. The one
|
||||
legitimate user is `PlatformLog.android.kt`, which implements the
|
||||
wrapper. A call that must pass a throwable uses the eager three-arg
|
||||
form `Log.w(tag, "msg", e)` — the lambda overload takes no throwable,
|
||||
and dropping it to keep the lambda loses the stack trace.
|
||||
- **Strip diagnostic `Log.d` calls before commit.** Logs added
|
||||
during on-device debugging — even lambda-form ones — must be
|
||||
removed from the production diff. They survive R8 stripping only
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
# Upstream issue draft — Compose `WindowInsets.ime` permanently wedges after a cancelled IME animation
|
||||
|
||||
Target: Google IssueTracker → **component 612128 (Jetpack Compose)**.
|
||||
The library-specific component the docs link to (856989, from the "Create a new issue" button on
|
||||
the Compose Foundation release notes) does not grant public Create Issues permission, so this is
|
||||
filed one level up with a routing request at the top of the body.
|
||||
|
||||
Status: **FILED as https://issuetracker.google.com/issues/552500419 (b/552500419)** on 2026-08-25,
|
||||
against component 612128 with a routing request. Remaining open item: the AOSP commit that introduced `runningAnimation`
|
||||
between 1.3.0 and 1.4.0-alpha01 has not been identified (android.googlesource.com returned 403
|
||||
to automated fetch). Adding the commit link before filing would help triage.
|
||||
|
||||
---
|
||||
|
||||
## Title
|
||||
|
||||
`WindowInsets.ime` stops updating permanently when an IME animation is cancelled without `onEnd` (regression in 1.4.0, still present in 1.13.0-alpha01)
|
||||
|
||||
## Routing
|
||||
|
||||
Please reassign to the owner of **`androidx.compose.foundation` / `foundation-layout`**
|
||||
(WindowInsets). Filing here because component 856989 — the target of the "Create a new issue"
|
||||
button on the [Compose Foundation release notes](https://developer.android.com/jetpack/androidx/releases/compose-foundation)
|
||||
— does not grant Create Issues permission to external accounts. That documented path being
|
||||
unusable by the public is arguably a separate docs bug worth fixing.
|
||||
|
||||
## Affected versions
|
||||
|
||||
* **Broken:** `androidx.compose.foundation:foundation-layout` **1.4.0 → 1.12.0 (current stable) and 1.13.0-alpha01**
|
||||
* **Not broken:** 1.3.0 and earlier
|
||||
* Verified by inspecting published `-sources.jar` for 1.2.0, 1.3.0, 1.4.0-alpha01…rc01, 1.4.0,
|
||||
1.5.0, 1.6.0, 1.7.0, 1.8.0, 1.9.0, 1.10.0, 1.11.0, 1.12.0, 1.13.0-alpha01.
|
||||
`runningAnimation` and its guard are absent in 1.3.0 and present from 1.4.0-alpha01 onward,
|
||||
textually unchanged since.
|
||||
* Reproduced on a Pixel 8, Android 17 (API 37). The API-30-only self-heal (below) means API 31+
|
||||
has no recovery path at all.
|
||||
|
||||
## Summary
|
||||
|
||||
If a `WindowInsetsAnimation` is prepared and started but never ended — what a cancelled IME
|
||||
animation looks like — `InsetsListener.runningAnimation` stays `true` forever. From that point
|
||||
`onApplyWindowInsets` matches neither of its two branches, so `composeInsets.update()` is never
|
||||
called again and **`WindowInsets.ime` is frozen for the remaining life of the window**.
|
||||
|
||||
Every `Modifier.imePadding()` in the app then holds a keyboard-height gap open with no keyboard
|
||||
on screen, permanently. `WindowInsets.imeAnimationTarget` keeps reporting correctly, because
|
||||
`updateImeAnimationTarget()` is called outside the guard — that asymmetry is the only reason a
|
||||
workaround is possible at all.
|
||||
|
||||
## Reproduction
|
||||
|
||||
Deterministic instrumented test, ~3s, no gestures and no timing dependence. It drives Compose's
|
||||
own listener through the cancelled-animation sequence using **public** interfaces
|
||||
(`WindowInsetsAnimationCompat.Callback`, `OnApplyWindowInsetsListener`); reflection is used only
|
||||
to obtain the listener instance for the view. Inside the androidx codebase `InsetsListener` is
|
||||
directly accessible, so `listenerFor()` can be deleted and the rest of the test used verbatim.
|
||||
|
||||
```
|
||||
FAIL aCancelledImeAnimationMustNotWedgeTheAnimatedInset
|
||||
expected:<0> but was:<957>
|
||||
PASS theAnimationTargetSurvivesTheWedge
|
||||
```
|
||||
|
||||
The second test is expected to pass and is included on purpose: it pins the asymmetry between the
|
||||
two readings, and would catch a "fix" that broke `imeAnimationTarget` instead.
|
||||
|
||||
The full test source is attached below.
|
||||
|
||||
## Root cause
|
||||
|
||||
`compose/foundation/foundation-layout/src/androidMain/kotlin/androidx/compose/foundation/layout/WindowInsets.android.kt`
|
||||
|
||||
```kotlin
|
||||
override fun onPrepare(animation: WindowInsetsAnimationCompat) {
|
||||
prepared = true
|
||||
runningAnimation = true // set here…
|
||||
}
|
||||
|
||||
override fun onStart(animation, bounds): BoundsCompat {
|
||||
prepared = false // …prepared cleared, runningAnimation left set
|
||||
return super.onStart(animation, bounds)
|
||||
}
|
||||
|
||||
override fun onEnd(animation: WindowInsetsAnimationCompat) {
|
||||
prepared = false
|
||||
runningAnimation = false // …cleared ONLY here
|
||||
…
|
||||
}
|
||||
|
||||
override fun onApplyWindowInsets(view: View, insets: WindowInsetsCompat): WindowInsetsCompat {
|
||||
savedInsets = insets
|
||||
composeInsets.updateImeAnimationTarget(insets) // unconditional — stays correct
|
||||
if (prepared) {
|
||||
if (Build.VERSION.SDK_INT == Build.VERSION_CODES.R) {
|
||||
view.post(this) // self-heal, API 30 ONLY
|
||||
}
|
||||
} else if (!runningAnimation) {
|
||||
composeInsets.updateImeAnimationSource(insets)
|
||||
composeInsets.update(insets) // the animated inset — never reached when wedged
|
||||
}
|
||||
…
|
||||
}
|
||||
```
|
||||
|
||||
After a cancelled animation: `prepared == false` (cleared by `onStart`) and
|
||||
`runningAnimation == true` (never cleared, because `onEnd` never came). Neither branch runs.
|
||||
`composeInsets.update()` is dead.
|
||||
|
||||
### Why the existing self-heal does not help
|
||||
|
||||
`run()` exists precisely to handle a cancelled animation, but:
|
||||
|
||||
1. it is gated to `Build.VERSION.SDK_INT == Build.VERSION_CODES.R` (API 30 only), and
|
||||
2. it is posted only from the `if (prepared)` branch, and returns early unless `prepared` is still
|
||||
`true` — which `onStart` has already cleared.
|
||||
|
||||
So it covers "cancelled between `onPrepare` and `onStart`, on API 30". It does not cover
|
||||
"cancelled after `onStart`", on any API level.
|
||||
|
||||
### Why applications cannot recover
|
||||
|
||||
The only reset is `insetsListener.resetState()`, called from `WindowInsetsHolder.incrementAccessors()`
|
||||
when `accessCount` transitions `0 → 1`. `accessCount` is driven by `WindowInsetsHolder.current()`'s
|
||||
`DisposableEffect`, so it only reaches 0 when *every* insets consumer leaves composition
|
||||
simultaneously.
|
||||
|
||||
In a single-Activity app whose shell (scaffold / bottom bar / drawer) always reads insets, that
|
||||
never happens — the holder is created once and lives for the whole process. There is no public API
|
||||
to force the reset. `WindowInsetsHolder` is `internal`.
|
||||
|
||||
Multi-Activity apps mask this: a new Activity means a new `View`, a new holder, and fresh state, so
|
||||
the wedge dies with the Activity and reads as a transient glitch.
|
||||
|
||||
### Regression point
|
||||
|
||||
1.3.0's `onApplyWindowInsets` had no such gate and could not wedge:
|
||||
|
||||
```kotlin
|
||||
override fun onApplyWindowInsets(view: View, insets: WindowInsetsCompat): WindowInsetsCompat {
|
||||
if (prepared) {
|
||||
savedInsets = insets
|
||||
if (Build.VERSION.SDK_INT == Build.VERSION_CODES.R) view.post(this)
|
||||
return insets
|
||||
}
|
||||
composeInsets.update(insets) // unconditional once onStart cleared `prepared`
|
||||
return …
|
||||
}
|
||||
```
|
||||
|
||||
1.4.0 introduced `runningAnimation` and the `else if (!runningAnimation)` guard. Its own comment
|
||||
states the intent:
|
||||
|
||||
> `// If an animation is running, rely on onProgress() to update the insets`
|
||||
> `// On APIs less than 30 where the IME animation is backported, this avoids reporting`
|
||||
> `// the final insets for a frame while the animation is running.`
|
||||
|
||||
i.e. a **one-frame** cosmetic flash on **API < 30** was fixed by making the update path conditional
|
||||
on a flag that only `onEnd` clears — trading a single wrong frame on old devices for permanent
|
||||
state corruption on all of them. The compensating recovery was never widened past `SDK_INT == R`.
|
||||
|
||||
## Real-world impact
|
||||
|
||||
Observed in a production Compose app (Amethyst, a Nostr client; single-Activity, `NavHost`,
|
||||
77 `imePadding()` sites):
|
||||
|
||||
* On a Pixel 8 / Android 17, after ordinary manual use, `WindowInsets.ime` pinned at 957px while
|
||||
the window reported `ime frame=[0,0][0,0]` — keyboard gone — and stayed pinned for 85+ seconds
|
||||
until the process was restarted. Nothing in the app cleared it.
|
||||
* Instrumented `WindowInsets.ime` vs `WindowInsets.imeAnimationTarget` across the failure:
|
||||
|
||||
```
|
||||
17:12:58.803 animated=882 target=957 ← healthy open, 13 intermediate frames
|
||||
17:12:58.902 animated=957 target=957
|
||||
17:13:00.584 animated=957 target=0 ← dismissed; animated frozen
|
||||
17:13:02.430 animated=0 target=957 ← reopened; snaps, no intermediate frames
|
||||
17:13:03.479 animated=957 target=0 ← dismissed; frozen permanently
|
||||
```
|
||||
|
||||
Note the loss of per-frame updates after the wedge: healthy transitions carry ~13 intermediate
|
||||
values over ~264ms; post-wedge transitions carry none.
|
||||
* Because the app never navigates away from its single Activity and its shell always reads insets,
|
||||
`accessCount` never returns to 0, so the wedge is permanent for the session. Sessions in this app
|
||||
routinely run for days.
|
||||
|
||||
The trigger for the underlying cancellation was not isolated — it is infrequent and required
|
||||
extended manual use to hit. The defect being reported is not the cancellation itself but that
|
||||
Compose enters a state it can never leave when one occurs. The attached test reproduces that state
|
||||
directly and deterministically.
|
||||
|
||||
## Suggested fixes
|
||||
|
||||
Roughly in order of how targeted they are:
|
||||
|
||||
1. **Generalise the existing self-heal.** Post the `run()` reconciliation on all API levels, and
|
||||
arm it after `onStart` as well as after `onPrepare`, so that an `onApplyWindowInsets` that
|
||||
arrives with no intervening `onProgress` clears `runningAnimation` and applies `savedInsets`.
|
||||
This preserves the API<30 one-frame behaviour the guard was added for, while bounding the
|
||||
failure to a frame rather than forever.
|
||||
2. **Reconcile on dispatch.** In `onApplyWindowInsets`, if `runningAnimation` is set but no
|
||||
`onProgress` has been received since `onStart`, treat the animation as finished and update.
|
||||
3. **Expose a reset.** A public way to reach `WindowInsetsHolder.resetState()` (or a documented
|
||||
condition under which it runs) would at least let applications self-heal. Today they cannot,
|
||||
short of reflection into an `internal` class — which R8 can rename or strip in exactly the
|
||||
release builds where this occurs.
|
||||
|
||||
(1) or (2) is preferable: (3) only makes the bug survivable rather than fixing it.
|
||||
|
||||
## Environment
|
||||
|
||||
* `androidx.compose.foundation:foundation-layout` 1.12.0 (Compose BOM 2026.08.00)
|
||||
* Pixel 8 (`shiba`), Android 17 / API 37, gesture navigation, Gboard, 120Hz
|
||||
* Also inspected: 1.13.0-alpha01 — identical listener code
|
||||
|
||||
---
|
||||
|
||||
## Attachment — the failing test
|
||||
|
||||
```kotlin
|
||||
package com.vitorpamplona.amethyst.ui.insets
|
||||
|
||||
import android.view.View
|
||||
import android.view.animation.LinearInterpolator
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.ime
|
||||
import androidx.compose.foundation.layout.imeAnimationTarget
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.OnApplyWindowInsetsListener
|
||||
import androidx.core.view.WindowInsetsAnimationCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Upstream regression test for androidx.compose.foundation:foundation-layout.
|
||||
*
|
||||
* A `WindowInsetsAnimation` that is prepared and started but never ended — which is what a
|
||||
* cancelled IME animation looks like — leaves `InsetsListener.runningAnimation` set forever.
|
||||
* `onApplyWindowInsets` then matches neither of its two branches, so `composeInsets.update()`
|
||||
* is never called again and `WindowInsets.ime` is dead for the life of the window.
|
||||
*
|
||||
* Introduced in 1.4.0 (absent in 1.3.0, where `onApplyWindowInsets` updated unconditionally
|
||||
* once `onStart` had cleared `prepared`). Still present in 1.12.0 and 1.13.0-alpha01. The
|
||||
* compensating self-heal (`view.post(this)` -> `run()`) is scoped to `SDK_INT == R`, so on
|
||||
* API 31+ nothing clears the flag; `WindowInsetsHolder.resetState()` only runs when the
|
||||
* holder's accessCount transitions 0 -> 1, which never happens in an app whose shell always
|
||||
* reads insets.
|
||||
*
|
||||
* [aCancelledImeAnimationMustNotWedgeTheAnimatedInset] FAILS on every version from 1.4.0 on.
|
||||
* [theAnimationTargetSurvivesTheWedge] documents the asymmetry that makes a workaround possible
|
||||
* and is expected to PASS — `updateImeAnimationTarget` is called outside the guard.
|
||||
*/
|
||||
class ComposeImeInsetWedgeTest {
|
||||
@get:Rule val rule = createComposeRule()
|
||||
|
||||
private val keyboardHeight = 957
|
||||
|
||||
private fun imeInsets(bottom: Int): WindowInsetsCompat =
|
||||
WindowInsetsCompat
|
||||
.Builder()
|
||||
.setInsets(WindowInsetsCompat.Type.ime(), Insets.of(0, 0, 0, bottom))
|
||||
.setVisible(WindowInsetsCompat.Type.ime(), bottom > 0)
|
||||
.build()
|
||||
|
||||
/** Compose's own listener for this view. Private class, but both interfaces it exposes are public. */
|
||||
private fun listenerFor(view: View): Any {
|
||||
val holderClass = Class.forName("androidx.compose.foundation.layout.WindowInsetsHolder")
|
||||
val companion =
|
||||
holderClass.getDeclaredField("Companion").run {
|
||||
isAccessible = true
|
||||
get(null)
|
||||
}
|
||||
val holder =
|
||||
companion.javaClass
|
||||
.getDeclaredMethod("getOrCreateFor", View::class.java)
|
||||
.run {
|
||||
isAccessible = true
|
||||
invoke(companion, view)
|
||||
}
|
||||
return holderClass.getDeclaredField("insetsListener").run {
|
||||
isAccessible = true
|
||||
get(holder)!!
|
||||
}
|
||||
}
|
||||
|
||||
private fun anim() = WindowInsetsAnimationCompat(WindowInsetsCompat.Type.ime(), LinearInterpolator(), 250L)
|
||||
|
||||
private fun bounds() =
|
||||
WindowInsetsAnimationCompat.BoundsCompat(
|
||||
Insets.NONE,
|
||||
Insets.of(0, 0, 0, keyboardHeight),
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Test
|
||||
fun aCancelledImeAnimationMustNotWedgeTheAnimatedInset() {
|
||||
var animated by mutableIntStateOf(-1)
|
||||
lateinit var view: View
|
||||
|
||||
rule.setContent {
|
||||
view = LocalView.current
|
||||
val density = LocalDensity.current
|
||||
animated = WindowInsets.ime.getBottom(density)
|
||||
}
|
||||
rule.waitForIdle()
|
||||
|
||||
val listener = listenerFor(view)
|
||||
val onApply = listener as OnApplyWindowInsetsListener
|
||||
val callback = listener as WindowInsetsAnimationCompat.Callback
|
||||
|
||||
// Baseline: with no animation in flight the inset tracks normally.
|
||||
rule.runOnUiThread { onApply.onApplyWindowInsets(view, imeInsets(keyboardHeight)) }
|
||||
rule.waitForIdle()
|
||||
assertEquals("baseline: the inset must follow a plain dispatch", keyboardHeight, animated)
|
||||
|
||||
// A cancelled animation: prepared and started, but onEnd never arrives.
|
||||
rule.runOnUiThread {
|
||||
callback.onPrepare(anim())
|
||||
callback.onStart(anim(), bounds())
|
||||
}
|
||||
rule.waitForIdle()
|
||||
|
||||
// The keyboard is gone and the window says so. The animated inset must follow.
|
||||
rule.runOnUiThread { onApply.onApplyWindowInsets(view, imeInsets(0)) }
|
||||
rule.waitForIdle()
|
||||
|
||||
assertEquals(
|
||||
"WindowInsets.ime must still track the window after an animation was cancelled " +
|
||||
"without onEnd; it is instead frozen at the keyboard height forever",
|
||||
0,
|
||||
animated,
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Test
|
||||
fun theAnimationTargetSurvivesTheWedge() {
|
||||
var target by mutableIntStateOf(-1)
|
||||
lateinit var view: View
|
||||
|
||||
rule.setContent {
|
||||
view = LocalView.current
|
||||
val density = LocalDensity.current
|
||||
target = WindowInsets.imeAnimationTarget.getBottom(density)
|
||||
}
|
||||
rule.waitForIdle()
|
||||
|
||||
val listener = listenerFor(view)
|
||||
val onApply = listener as OnApplyWindowInsetsListener
|
||||
val callback = listener as WindowInsetsAnimationCompat.Callback
|
||||
|
||||
rule.runOnUiThread { onApply.onApplyWindowInsets(view, imeInsets(keyboardHeight)) }
|
||||
rule.waitForIdle()
|
||||
assertEquals(keyboardHeight, target)
|
||||
|
||||
rule.runOnUiThread {
|
||||
callback.onPrepare(anim())
|
||||
callback.onStart(anim(), bounds())
|
||||
}
|
||||
rule.waitForIdle()
|
||||
|
||||
rule.runOnUiThread { onApply.onApplyWindowInsets(view, imeInsets(0)) }
|
||||
rule.waitForIdle()
|
||||
|
||||
assertEquals(
|
||||
"updateImeAnimationTarget is called outside the guard, so this reading stays truthful",
|
||||
0,
|
||||
target,
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,182 @@
|
||||
# Defaults stand in for the user's relay lists only while we have no event
|
||||
|
||||
**Status:** proposal — not implemented
|
||||
**Goal:** first-login startup on a Tor-enabled install
|
||||
**Related:** `fix/tor-bootstrap-stall-and-ondemand`, `[[fresh-install-routes-everything-via-tor]]`
|
||||
|
||||
## The rule
|
||||
|
||||
Three states, currently collapsed into two:
|
||||
|
||||
| we have | effective list | today |
|
||||
|---|---|---|
|
||||
| **no event** for the user | app defaults | defaults ✅ |
|
||||
| event, **empty** list | **empty** — the user chose nothing | defaults ❌ |
|
||||
| event with relays | those relays | those relays ✅ |
|
||||
|
||||
Everything below follows from separating "we don't know" from "we know, and it's nothing".
|
||||
|
||||
## Why the first login is slow
|
||||
|
||||
On a fresh install **100% of relay traffic is Tor-routed by construction**.
|
||||
`TorRelayState.trustedRelays` is empty, so `TorRelayEvaluation.useTor()` falls through to
|
||||
`newRelaysViaTor` (**default true**) for every URL — and the kind-10002 that would populate it can
|
||||
only be fetched over Tor. Measured (SM-T220, same account, same ~app+8-10s login, fresh install
|
||||
each; the Tor-OFF arm sets the pref, force-stops, then starts the timed run so Arti never boots):
|
||||
|
||||
| @20s census | Tor ON | Tor OFF |
|
||||
|---|---|---|
|
||||
| feed on screen | login+18s | **login+11s** |
|
||||
| relays opened | 18/40 | **32/41** |
|
||||
| relays serving events | 9 | **22** |
|
||||
| events ingested | 2,830 | **6,134 / 7,641** |
|
||||
|
||||
≈7s of first paint and half the relay coverage.
|
||||
|
||||
## Finding 1 — every `WithBackup` helper keys on emptiness, not absence
|
||||
|
||||
This is a pre-existing bug against the rule above, and it must be fixed first because the whole
|
||||
feature depends on the distinction being real.
|
||||
|
||||
```kotlin
|
||||
// AdvertisedRelayListEvent
|
||||
fun relays() = tags.mapNotNull(AdvertisedRelayInfo::parse) // [] when none
|
||||
fun readRelaysNorm() = tags.mapNotNull(AdvertisedRelayInfo::parseReadNorm).ifEmpty { null } // null!
|
||||
fun writeRelaysNorm()= tags.mapNotNull(AdvertisedRelayInfo::parseWriteNorm).ifEmpty { null } // null!
|
||||
```
|
||||
|
||||
| helper | fallback fires when | correct |
|
||||
|---|---|---|
|
||||
| `normalizeNIP65AllRelayListWithBackup` | event absent only | ✅ (by accident — `relays()` has no `ifEmpty`) |
|
||||
| `normalizeNIP65Read/WriteRelayListWithBackup` | event absent **or list empty** | ❌ |
|
||||
| `normalizeIndexerRelayListWithBackup` | `?.ifEmpty { null } ?: DefaultIndexerRelayList` | ❌ |
|
||||
| `normalizeSearchRelayListWithBackup` | `?.ifEmpty { null } ?: DefaultSearchRelayList` | ❌ |
|
||||
|
||||
Consequence today: **a user who publishes a kind-10002 with only write relays gets
|
||||
`Constants.bootstrapInbox` silently substituted as their inbox list.** Same for a deliberately empty
|
||||
search or indexer list. The app overrides an explicit choice.
|
||||
|
||||
The mirror problem sinks the obvious implementation: the `NoDefaults` variants return `emptySet()`
|
||||
for *both* "no event" and "empty event", so `trustedRelays.isEmpty()` cannot be used as the
|
||||
"do we have data yet" signal.
|
||||
|
||||
**Fix:** make presence explicit, and never infer it from emptiness.
|
||||
|
||||
```kotlin
|
||||
// absent -> defaults; present -> whatever it says, including nothing
|
||||
fun readRelayList(note: Note): Set<NormalizedRelayUrl> =
|
||||
nip65Event(note)?.let { it.readRelaysNorm()?.toSet() ?: emptySet() } ?: Constants.bootstrapInbox
|
||||
```
|
||||
|
||||
Same shape for write/all, and drop the `?.ifEmpty { null }` from the indexer and search helpers.
|
||||
Worth doing on its own merits even if the rest of this plan is dropped.
|
||||
|
||||
**This removes the need for any window or timeout.** The fallback becomes a pure function of "do we
|
||||
have the event", so it ends the instant one arrives — even an empty one. No per-account bookkeeping,
|
||||
no 30s backstop, no race to close.
|
||||
|
||||
## Finding 2 — do NOT put defaults into `TrustedRelayListsState`
|
||||
|
||||
Tempting (it already merges all nine lists) but wrong: `account.trustedRelays.flow` feeds
|
||||
`Account.kt:454`
|
||||
|
||||
```kotlin
|
||||
isInMyRelayList = { relayUrl -> ... it in trustedRelays.flow.value }
|
||||
```
|
||||
|
||||
which feeds `RelayAuthPermissionLedger` -> `RelayAuthResolver` -> **the NIP-42 AUTH decision**.
|
||||
Adding defaults there would make the app **auto-AUTH to the six hardcoded bootstrap relays as if
|
||||
they were the user's own** — signing a challenge with the user's key and revealing the pubkey — at
|
||||
exactly the moment we are also going clearnet. That converts a modest timing leak into a signed
|
||||
identity assertion. See `[[relay-auth-always-was-gated]]` and `[[inbox-wine-notify-auth-billing]]`
|
||||
for why AUTH is the sensitive edge.
|
||||
|
||||
(The `saveTrustedRelayList(trustedRelays + relay)` write path in `RelayGroupChannelListScreen:449`
|
||||
is **not** a hazard — it reads `account.trustedRelayList` (the NIP-51 list), not the merged
|
||||
`trustedRelays`. Checked.)
|
||||
|
||||
**Instead:** add a separate, purpose-named flow consumed only by Tor evaluation, e.g.
|
||||
`Account.relaysAssumedWhileUnknown` — the union of the with-defaults views, non-empty only while the
|
||||
corresponding events are absent. `AccountsTorStateConnector` feeds it into a new
|
||||
`TorRelayState.assumedRelays`. Nothing else reads it.
|
||||
|
||||
## Where the check goes in `useTor()`
|
||||
|
||||
```
|
||||
torType == OFF -> false
|
||||
isLocalHost -> false
|
||||
isOverlayNetwork -> false
|
||||
isOnion -> onionRelaysViaTor
|
||||
in moneyOpRelayList -> moneyOperationsViaTor
|
||||
in dmRelayList -> dmRelaysViaTor
|
||||
in trustedRelayList -> trustedRelaysViaTor
|
||||
in assumedRelayList -> trustedRelaysViaTor <-- new, immediately above the fallback
|
||||
else -> newRelaysViaTor
|
||||
```
|
||||
|
||||
Landing immediately above the fallback means **.onion, money-operation and DM relays keep their own
|
||||
policy for free** — the change can only ever affect URLs that would have been treated as "new".
|
||||
|
||||
Resolve to `trustedRelaysViaTor`, **not** a hardcoded `false`:
|
||||
|
||||
- default user (`false`) -> clearnet -> fast start;
|
||||
- hardened user (`true`) -> stays on Tor, automatically, with no new setting to discover.
|
||||
|
||||
That is the difference between "the app overrides you" and "the app treats its stand-in list the way
|
||||
you asked your own list to be treated".
|
||||
|
||||
## Privacy, for the PR body
|
||||
|
||||
The window correlates the user's **IP with their pubkey** at ~6 hardcoded relays, because the REQ
|
||||
asks those relays for that pubkey's events. A first login is the most sensitive moment there is.
|
||||
|
||||
What makes it defensible: **`trustedRelaysViaTor` already defaults to false**, so the moment
|
||||
kind-10002 lands the user's own relays are dialled over clearnet anyway. This moves an existing
|
||||
disclosure slightly earlier, to a different well-known set. It is not a new class of exposure for
|
||||
the default configuration — and it is *not* an AUTH disclosure, provided Finding 2 is respected.
|
||||
|
||||
If `trustedRelaysViaTor` ever becomes default-true, **this feature must be revisited in the same
|
||||
commit** — its justification disappears. Leave a comment at the default linking the two.
|
||||
|
||||
Residual, worth verifying rather than assuming: `useTor()` is keyed by relay **URL**, and the pool
|
||||
multiplexes every subscription for a URL over one socket. During the window, anything addressed to a
|
||||
default relay rides that clearnet socket — including a kind-1059 giftwrap subscription, since the DM
|
||||
list is also absent. Measure it (below) before deciding it is acceptable.
|
||||
|
||||
## Testing
|
||||
|
||||
Unit — the rule itself, per list type: absent event -> defaults; present-but-empty -> **empty**;
|
||||
present-with-values -> values. The middle case is the regression guard and the one that fails today.
|
||||
|
||||
Unit (`TorRelayEvaluationTest`): an assumed relay resolves to `trustedRelaysViaTor` (both values);
|
||||
.onion / money-op / DM keep their own policy while also listed as assumed; a non-assumed "new" relay
|
||||
still resolves to `newRelaysViaTor`; an empty assumed set is byte-for-byte today's behaviour.
|
||||
|
||||
Unit: `isInMyRelayList` does **not** see assumed relays (guards Finding 2 permanently).
|
||||
|
||||
Device — the number that justifies the change. `relaytiming.sh` + `BootRelayDiag` census,
|
||||
`VERBOSE_LOGS=true` benchmark build, fresh install each, counterbalanced, n>=3:
|
||||
- primary: login -> first note; login -> own profile + follow list;
|
||||
- secondary: relays opened / serving / events at the 20s census;
|
||||
- guard: grep the verbose log for any request to a default relay during the window that is not for
|
||||
the account's own pubkey, and for any AUTH sent to one.
|
||||
|
||||
Harness traps (all in `[[fresh-install-routes-everything-via-tor]]`): the tablet raises its lock
|
||||
screen during long waits (`wm dismiss-keyguard`, not just `KEYCODE_WAKEUP`); the login layout shifts
|
||||
when the IME opens, so dismiss it before tapping fixed coordinates; `BACK` on the home screen exits
|
||||
the app; always assert the run left the login screen before trusting its timing.
|
||||
|
||||
## Expected outcome
|
||||
|
||||
Approach the Tor-OFF column: ≈**-7s to first paint, ~2x relay coverage** in the first 20s, with
|
||||
everything after the first event behaving exactly as today.
|
||||
|
||||
If the gain is materially smaller, the likely cause is that the feed is gated on outbox-discovered
|
||||
relays (which stay "new", hence Tor) rather than the user's own list — in which case the win is
|
||||
limited to profile and follows, and may not be worth the privacy cost. Decide on the numbers.
|
||||
|
||||
## Order of work
|
||||
|
||||
1. Fix the absent-vs-empty bug in the four helpers + tests. Independently correct; ship separately.
|
||||
2. Add `relaysAssumedWhileUnknown` + `TorRelayState.assumedRelays` + the `useTor()` branch.
|
||||
3. Device A/B. Keep only if it earns its keep.
|
||||
+14
-3
@@ -25,7 +25,10 @@ import androidx.test.filters.LargeTest
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorService
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorServiceStatus
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
@@ -58,7 +61,7 @@ import kotlin.system.measureTimeMillis
|
||||
* 3. `./gradlew :amethyst:connectedPlayDebugAndroidTest -P android.testInstrumentationRunnerArguments.class=com.vitorpamplona.amethyst.tor.TorBootstrapInstrumentedTest`
|
||||
*
|
||||
* **What it covers that [TorManagerTest] does not:**
|
||||
* - Real `ArtiNative.initialize` → `create_bootstrapped` → SOCKS listener bind.
|
||||
* - Real `ArtiNative.initialize` → `create_unbootstrapped_async` → SOCKS listener bind.
|
||||
* - Real rustls `CryptoProvider` install (regression check after the arti-v2.3.0 bump).
|
||||
* - Real `destroy()` releasing the state file lock so a second `initialize()` succeeds.
|
||||
* - OkHttp routing traffic through the SOCKS port and Arti exiting through the
|
||||
@@ -73,7 +76,14 @@ import kotlin.system.measureTimeMillis
|
||||
@Ignore("Tier-3 integration test — requires on-device network access to Tor. See class kdoc to enable.")
|
||||
class TorBootstrapInstrumentedTest {
|
||||
private val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
private val torService = TorService(context)
|
||||
|
||||
/**
|
||||
* [TorService] promotes Bootstrapping -> Active from a coroutine on this scope, so the test
|
||||
* must own one and cancel it — without a live scope `status` would never reach Active and every
|
||||
* assertion below would hang until its timeout.
|
||||
*/
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private val torService = TorService(context, scope)
|
||||
|
||||
@After
|
||||
fun tearDown() =
|
||||
@@ -81,11 +91,12 @@ class TorBootstrapInstrumentedTest {
|
||||
// Drop the native client so this test's state file lock doesn't bleed into
|
||||
// the next instrumented run on the same device.
|
||||
torService.reset()
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
/**
|
||||
* Cold-start bootstrap. The whole point of the custom Arti build is that this
|
||||
* works at all — if create_bootstrapped panics (e.g., because we forgot to install
|
||||
* works at all — if client creation panics (e.g., because we forgot to install
|
||||
* a rustls CryptoProvider after an arti bump) the test catches it.
|
||||
*/
|
||||
@Test
|
||||
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
import android.content.Context
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Shared harness for the MediaSaverToDisk instrumented tests: writes a small payload
|
||||
* file, drives [MediaSaverToDisk.save] with the given MIME type, and asserts the save
|
||||
* reported success. Package-level support object per the AvifInstrumentedTestSupport
|
||||
* precedent.
|
||||
*/
|
||||
object MediaSaverTestSupport {
|
||||
/** Drives one save and fails the test if it reported an error or never succeeded. */
|
||||
fun saveAndAssertSuccess(
|
||||
context: Context,
|
||||
mimeType: String,
|
||||
) {
|
||||
val localFile = File(context.cacheDir, "media-saver-${UUID.randomUUID()}.bin")
|
||||
localFile.writeBytes(ByteArray(2048) { it.toByte() })
|
||||
|
||||
var failure: Throwable? = null
|
||||
var succeeded = false
|
||||
|
||||
try {
|
||||
runBlocking {
|
||||
MediaSaverToDisk.save(
|
||||
localFile = localFile,
|
||||
mimeType = mimeType,
|
||||
context = context,
|
||||
onSuccess = { succeeded = true },
|
||||
onError = { failure = it },
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
localFile.delete()
|
||||
}
|
||||
|
||||
// Surfaces e.g. the #4009 IllegalArgumentException as the test failure message.
|
||||
assertNull("save() reported an error: ${failure?.message}", failure)
|
||||
assertTrue("save() never reported success", succeeded)
|
||||
}
|
||||
}
|
||||
+155
@@ -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.ui.actions
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.os.ParcelFileDescriptor
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assume.assumeTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
|
||||
/**
|
||||
* Covers the pre-Q writer, which MediaStore never sees: below API 29 saveContentDefault
|
||||
* writes straight to a public directory and lets the media scanner index it.
|
||||
*
|
||||
* That path used to hardcode Pictures for every content type, so videos, audio and PDFs
|
||||
* were all filed under Pictures/Amethyst. It now routes through the same MediaStoreTarget
|
||||
* as the MediaStore path. minSdk is 26, so this range ships.
|
||||
*
|
||||
* There is no JVM coverage of any of this: Build.VERSION.SDK_INT is 0 under
|
||||
* returnDefaultValues, so unit tests can only reach the routing function, never the writer.
|
||||
*
|
||||
* **Running this suite:** below Q the storage grant must exist before the app process
|
||||
* forks (external storage is mounted at fork time), and Gradle's connectedAndroidTest
|
||||
* installs and instruments with no window to grant in between - so these tests skip
|
||||
* under it. Drive them manually on an API 26-28 device:
|
||||
* ```
|
||||
* ./gradlew :amethyst:assemblePlayDebug :amethyst:assemblePlayDebugAndroidTest
|
||||
* adb install -r -g amethyst/build/outputs/apk/play/debug/amethyst-play-arm64-v8a-debug.apk
|
||||
* adb install -r -g amethyst/build/outputs/apk/androidTest/play/debug/amethyst-play-debug-androidTest.apk
|
||||
* adb shell am instrument -w -e class com.vitorpamplona.amethyst.ui.actions.MediaSaverToDiskLegacyStorageTest \
|
||||
* com.vitorpamplona.amethyst.debug.test/androidx.test.runner.AndroidJUnitRunner
|
||||
* ```
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class MediaSaverToDiskLegacyStorageTest {
|
||||
private val context get() = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
|
||||
/** Every directory production can write to, straight from the routing table. */
|
||||
private val watchedDirs = MediaSaverToDisk.MediaStoreTarget.entries.map { it.relativeDirectory }
|
||||
private val createdFiles = mutableListOf<File>()
|
||||
|
||||
@Before
|
||||
fun onlyBelowScopedStorage() {
|
||||
assumeTrue("saveContentDefault only runs below API 29", Build.VERSION.SDK_INT < Build.VERSION_CODES.Q)
|
||||
|
||||
// The legacy writer needs the runtime permission; no androidx.test:rules on the
|
||||
// classpath, so grant it through the instrumentation shell instead. The output has
|
||||
// to be drained: executeShellCommand runs asynchronously and closing the descriptor
|
||||
// early kills the command before it applies.
|
||||
val fd =
|
||||
InstrumentationRegistry
|
||||
.getInstrumentation()
|
||||
.uiAutomation
|
||||
.executeShellCommand(
|
||||
"pm grant ${context.packageName} android.permission.WRITE_EXTERNAL_STORAGE",
|
||||
)
|
||||
ParcelFileDescriptor.AutoCloseInputStream(fd).use { it.readBytes() }
|
||||
|
||||
assertEquals(
|
||||
"WRITE_EXTERNAL_STORAGE was not granted; the legacy writer cannot be exercised",
|
||||
PackageManager.PERMISSION_GRANTED,
|
||||
context.checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE),
|
||||
)
|
||||
|
||||
// Holding the permission is not enough below Q: external storage is mounted into
|
||||
// the process when it forks, so a grant to an already-running process never
|
||||
// reaches it and every write fails with EACCES. Probe for real writability and
|
||||
// skip rather than report a routing failure that is really a harness problem.
|
||||
assumeTrue(
|
||||
"External storage is not writable by this process; below API 29 the grant must " +
|
||||
"exist at install time. See this class's KDoc for the exact run recipe.",
|
||||
canWriteToPublicStorage(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun canWriteToPublicStorage(): Boolean =
|
||||
try {
|
||||
val dir = amethystDir("Movies").apply { if (!exists()) mkdirs() }
|
||||
val probe = File(dir, ".write-probe-${System.nanoTime()}")
|
||||
val writable = probe.createNewFile()
|
||||
probe.delete()
|
||||
writable
|
||||
} catch (e: IOException) {
|
||||
false
|
||||
}
|
||||
|
||||
@After
|
||||
fun cleanUp() {
|
||||
createdFiles.forEach { it.delete() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun videoGoesToMovies() = assertRoutes("video/mp4", "Movies")
|
||||
|
||||
@Test
|
||||
fun imageGoesToPictures() = assertRoutes("image/jpeg", "Pictures")
|
||||
|
||||
@Test
|
||||
fun audioGoesToMusic() = assertRoutes("audio/mpeg", "Music")
|
||||
|
||||
@Test
|
||||
fun pdfGoesToDownloads() = assertRoutes("application/pdf", "Download")
|
||||
|
||||
/**
|
||||
* Saves one file and asserts it appeared under [expectedDir]/Amethyst and nowhere else.
|
||||
* Checking the other directories is the point: the bug was everything landing in Pictures.
|
||||
*/
|
||||
private fun assertRoutes(
|
||||
mimeType: String,
|
||||
expectedDir: String,
|
||||
) {
|
||||
val before = snapshot()
|
||||
|
||||
MediaSaverTestSupport.saveAndAssertSuccess(context, mimeType)
|
||||
|
||||
val added = snapshot().mapValues { (dir, names) -> names - before.getValue(dir) }
|
||||
added.forEach { (dir, names) -> names.forEach { createdFiles.add(File(amethystDir(dir), it)) } }
|
||||
|
||||
val dirsThatGrew = added.filterValues { it.isNotEmpty() }.keys
|
||||
assertEquals("$mimeType should land only in $expectedDir/Amethyst", setOf(expectedDir), dirsThatGrew)
|
||||
assertEquals("expected exactly one new file", 1, added.getValue(expectedDir).size)
|
||||
}
|
||||
|
||||
private fun amethystDir(publicDir: String) = File(Environment.getExternalStoragePublicDirectory(publicDir), "Amethyst")
|
||||
|
||||
private fun snapshot(): Map<String, Set<String>> = watchedDirs.associateWith { amethystDir(it).list()?.toSet() ?: emptySet() }
|
||||
}
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentUris
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.MediaStore
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assume.assumeTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* End-to-end regression test for issue #4009: drives the real ContentResolver, so it
|
||||
* catches both symptoms of a collection/directory mismatch - Android 10 rejects the
|
||||
* insert outright (the quoted rejection lives in [MediaSaverToDisk.MediaStoreTarget]'s
|
||||
* KDoc), and later releases accept it and silently misfile the video.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class MediaSaverToDiskMediaStoreTest {
|
||||
private val context get() = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
private val resolver: ContentResolver get() = context.contentResolver
|
||||
|
||||
/** Only rows this test inserted, as item Uris in the collection they went into. */
|
||||
private val created = mutableListOf<Uri>()
|
||||
|
||||
@Before
|
||||
fun requiresScopedStorage() {
|
||||
assumeTrue("saveContentQ only runs on API 29+", Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
|
||||
}
|
||||
|
||||
@After
|
||||
fun cleanUp() {
|
||||
created.forEach { resolver.delete(it, null, null) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun savingAVideoLandsInMoviesAndNotPictures() {
|
||||
val relativePath = saveAndReadBackRelativePath("video/mp4", MediaStore.Video.Media.EXTERNAL_CONTENT_URI)
|
||||
|
||||
assertEquals("Movies/Amethyst/", relativePath)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun savingAnImageStillLandsInPictures() {
|
||||
val relativePath = saveAndReadBackRelativePath("image/jpeg", MediaStore.Images.Media.EXTERNAL_CONTENT_URI)
|
||||
|
||||
assertEquals("Pictures/Amethyst/", relativePath)
|
||||
}
|
||||
|
||||
private fun saveAndReadBackRelativePath(
|
||||
mimeType: String,
|
||||
collection: Uri,
|
||||
): String? {
|
||||
// Anything at or below this id predates the test and must never be read or deleted:
|
||||
// this suite is meant to be runnable on a real device holding real media.
|
||||
val highWaterMark = maxIdIn(collection)
|
||||
|
||||
MediaSaverTestSupport.saveAndAssertSuccess(context, mimeType)
|
||||
|
||||
return rowInsertedAfter(collection, highWaterMark)
|
||||
}
|
||||
|
||||
private fun maxIdIn(collection: Uri): Long {
|
||||
resolver
|
||||
.query(collection, arrayOf(MediaStore.MediaColumns._ID), null, null, "${MediaStore.MediaColumns._ID} DESC")
|
||||
?.use { cursor ->
|
||||
if (cursor.moveToFirst()) return cursor.getLong(0)
|
||||
}
|
||||
return -1L
|
||||
}
|
||||
|
||||
/** Reads back the row the save just inserted and records it for cleanup. */
|
||||
private fun rowInsertedAfter(
|
||||
collection: Uri,
|
||||
highWaterMark: Long,
|
||||
): String? {
|
||||
resolver
|
||||
.query(
|
||||
collection,
|
||||
arrayOf(MediaStore.MediaColumns._ID, MediaStore.MediaColumns.RELATIVE_PATH),
|
||||
"${MediaStore.MediaColumns._ID} > ?",
|
||||
arrayOf(highWaterMark.toString()),
|
||||
"${MediaStore.MediaColumns._ID} ASC",
|
||||
)?.use { cursor ->
|
||||
assertTrue("save() reported success but inserted no row into $collection", cursor.moveToFirst())
|
||||
created.add(ContentUris.withAppendedId(collection, cursor.getLong(0)))
|
||||
return cursor.getString(1)
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import android.os.Environment
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.vitorpamplona.amethyst.ui.actions.MediaSaverToDisk.MediaStoreTarget
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* [MediaStoreTarget] spells its directories out as literals because Environment's
|
||||
* DIRECTORY_* fields are plain statics that the unit-test android.jar leaves null.
|
||||
* This is the other half of that trade: on a real device the literals are checked
|
||||
* against the platform constants they stand in for.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class MediaStoreTargetInstrumentedTest {
|
||||
@Test
|
||||
fun directoriesMatchThePlatformConstants() {
|
||||
assertEquals(Environment.DIRECTORY_PICTURES, MediaStoreTarget.IMAGES.relativeDirectory)
|
||||
assertEquals(Environment.DIRECTORY_MUSIC, MediaStoreTarget.AUDIO.relativeDirectory)
|
||||
assertEquals(Environment.DIRECTORY_MOVIES, MediaStoreTarget.VIDEO.relativeDirectory)
|
||||
assertEquals(Environment.DIRECTORY_DOWNLOADS, MediaStoreTarget.DOWNLOADS.relativeDirectory)
|
||||
}
|
||||
}
|
||||
+84
@@ -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.ui.actions.uploads
|
||||
|
||||
import android.os.Environment
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Pins what `res/xml/file_paths.xml` is allowed to hand out.
|
||||
*
|
||||
* The provider root used to be `<external-path path=".">`, i.e. the whole of
|
||||
* `Environment.getExternalStorageDirectory()`. It is now the app-specific
|
||||
* `<external-files-path>`, which is the only external location Amethyst ever
|
||||
* shares from (camera/video capture). These tests fail if either half of that
|
||||
* regresses: the capture paths must still resolve, and the external-storage
|
||||
* root must not.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class FileProviderPathsTest {
|
||||
private val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
private val authority = "${context.packageName}.provider"
|
||||
|
||||
@Test
|
||||
fun photoCaptureUriResolves() {
|
||||
val uri = getPhotoUri(context)
|
||||
assertEquals("content", uri.scheme)
|
||||
assertEquals(authority, uri.authority)
|
||||
assertTrue("expected the external_files root, got $uri", uri.path!!.startsWith("/external_files/"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun videoCaptureUriResolves() {
|
||||
val uri = getVideoUri(context)
|
||||
assertEquals("content", uri.scheme)
|
||||
assertEquals(authority, uri.authority)
|
||||
assertTrue("expected the external_files root, got $uri", uri.path!!.startsWith("/external_files/"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cacheDirStillResolves() {
|
||||
val file = File(context.cacheDir, "amethyst_share_probe.png")
|
||||
val uri = FileProvider.getUriForFile(context, authority, file)
|
||||
assertEquals(authority, uri.authority)
|
||||
assertTrue("expected the cache root, got $uri", uri.path!!.startsWith("/cache/"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun externalStorageRootIsNoLongerShareable() {
|
||||
@Suppress("DEPRECATION")
|
||||
val outside = File(Environment.getExternalStorageDirectory(), "Download/not-ours.pdf")
|
||||
try {
|
||||
val uri = FileProvider.getUriForFile(context, authority, outside)
|
||||
fail("FileProvider should not map $outside, but produced $uri")
|
||||
} catch (expected: IllegalArgumentException) {
|
||||
// Correct: no configured root contains it.
|
||||
}
|
||||
}
|
||||
}
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.insets
|
||||
|
||||
import android.view.View
|
||||
import android.view.animation.LinearInterpolator
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.ime
|
||||
import androidx.compose.foundation.layout.imeAnimationTarget
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.OnApplyWindowInsetsListener
|
||||
import androidx.core.view.WindowInsetsAnimationCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Ignore
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Upstream regression test for androidx.compose.foundation:foundation-layout.
|
||||
*
|
||||
* A `WindowInsetsAnimation` that is prepared and started but never ended — which is what a
|
||||
* cancelled IME animation looks like — leaves `InsetsListener.runningAnimation` set forever.
|
||||
* `onApplyWindowInsets` then matches neither of its two branches, so `composeInsets.update()`
|
||||
* is never called again and `WindowInsets.ime` is dead for the life of the window.
|
||||
*
|
||||
* Introduced in 1.4.0 (absent in 1.3.0, where `onApplyWindowInsets` updated unconditionally
|
||||
* once `onStart` had cleared `prepared`). Still present in 1.12.0 and 1.13.0-alpha01. The
|
||||
* compensating self-heal (`view.post(this)` -> `run()`) is scoped to `SDK_INT == R`, so on
|
||||
* API 31+ nothing clears the flag; `WindowInsetsHolder.resetState()` only runs when the
|
||||
* holder's accessCount transitions 0 -> 1, which never happens in an app whose shell always
|
||||
* reads insets.
|
||||
*
|
||||
* Filed upstream as b/552500419.
|
||||
*
|
||||
* [aCancelledImeAnimationMustNotWedgeTheAnimatedInset] FAILS on every version from 1.4.0 on, so it
|
||||
* is [Ignore]d to keep CI green. It is not a test of Amethyst code — it is the upstream repro we
|
||||
* attached to the bug. **Re-run it by hand after every Compose upgrade**: when it passes, the
|
||||
* upstream fix has landed and [com.vitorpamplona.amethyst.ui.insets.SafeImeInsets] can be retired.
|
||||
*
|
||||
* [theAnimationTargetSurvivesTheWedge] documents the asymmetry that makes a workaround possible
|
||||
* and is expected to PASS — `updateImeAnimationTarget` is called outside the guard. It stays
|
||||
* enabled, because it guards the premise [com.vitorpamplona.amethyst.ui.insets.SafeImeInsets]
|
||||
* depends on: if a future Compose release stopped keeping `imeAnimationTarget` current, our
|
||||
* fallback would silently start reading a dead value too.
|
||||
*/
|
||||
class ComposeImeInsetWedgeTest {
|
||||
@get:Rule val rule = createComposeRule()
|
||||
|
||||
private val keyboardHeight = 957
|
||||
|
||||
private fun imeInsets(bottom: Int): WindowInsetsCompat =
|
||||
WindowInsetsCompat
|
||||
.Builder()
|
||||
.setInsets(WindowInsetsCompat.Type.ime(), Insets.of(0, 0, 0, bottom))
|
||||
.setVisible(WindowInsetsCompat.Type.ime(), bottom > 0)
|
||||
.build()
|
||||
|
||||
/** Compose's own listener for this view. Private class, but both interfaces it exposes are public. */
|
||||
private fun listenerFor(view: View): Any {
|
||||
val holderClass = Class.forName("androidx.compose.foundation.layout.WindowInsetsHolder")
|
||||
val companion =
|
||||
holderClass.getDeclaredField("Companion").run {
|
||||
isAccessible = true
|
||||
get(null)
|
||||
}
|
||||
val holder =
|
||||
companion.javaClass
|
||||
.getDeclaredMethod("getOrCreateFor", View::class.java)
|
||||
.run {
|
||||
isAccessible = true
|
||||
invoke(companion, view)
|
||||
}
|
||||
return holderClass.getDeclaredField("insetsListener").run {
|
||||
isAccessible = true
|
||||
get(holder)!!
|
||||
}
|
||||
}
|
||||
|
||||
private fun anim() = WindowInsetsAnimationCompat(WindowInsetsCompat.Type.ime(), LinearInterpolator(), 250L)
|
||||
|
||||
private fun bounds() =
|
||||
WindowInsetsAnimationCompat.BoundsCompat(
|
||||
Insets.NONE,
|
||||
Insets.of(0, 0, 0, keyboardHeight),
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Test
|
||||
@Ignore("Fails by design until upstream fixes b/552500419 — re-run by hand on every Compose upgrade")
|
||||
fun aCancelledImeAnimationMustNotWedgeTheAnimatedInset() {
|
||||
var animated by mutableIntStateOf(-1)
|
||||
lateinit var view: View
|
||||
|
||||
rule.setContent {
|
||||
view = LocalView.current
|
||||
val density = LocalDensity.current
|
||||
animated = WindowInsets.ime.getBottom(density)
|
||||
}
|
||||
rule.waitForIdle()
|
||||
|
||||
val listener = listenerFor(view)
|
||||
val onApply = listener as OnApplyWindowInsetsListener
|
||||
val callback = listener as WindowInsetsAnimationCompat.Callback
|
||||
|
||||
// Baseline: with no animation in flight the inset tracks normally.
|
||||
rule.runOnUiThread { onApply.onApplyWindowInsets(view, imeInsets(keyboardHeight)) }
|
||||
rule.waitForIdle()
|
||||
assertEquals("baseline: the inset must follow a plain dispatch", keyboardHeight, animated)
|
||||
|
||||
// A cancelled animation: prepared and started, but onEnd never arrives.
|
||||
rule.runOnUiThread {
|
||||
callback.onPrepare(anim())
|
||||
callback.onStart(anim(), bounds())
|
||||
}
|
||||
rule.waitForIdle()
|
||||
|
||||
// The keyboard is gone and the window says so. The animated inset must follow.
|
||||
rule.runOnUiThread { onApply.onApplyWindowInsets(view, imeInsets(0)) }
|
||||
rule.waitForIdle()
|
||||
|
||||
assertEquals(
|
||||
"WindowInsets.ime must still track the window after an animation was cancelled " +
|
||||
"without onEnd; it is instead frozen at the keyboard height forever",
|
||||
0,
|
||||
animated,
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Test
|
||||
fun theAnimationTargetSurvivesTheWedge() {
|
||||
var target by mutableIntStateOf(-1)
|
||||
lateinit var view: View
|
||||
|
||||
rule.setContent {
|
||||
view = LocalView.current
|
||||
val density = LocalDensity.current
|
||||
target = WindowInsets.imeAnimationTarget.getBottom(density)
|
||||
}
|
||||
rule.waitForIdle()
|
||||
|
||||
val listener = listenerFor(view)
|
||||
val onApply = listener as OnApplyWindowInsetsListener
|
||||
val callback = listener as WindowInsetsAnimationCompat.Callback
|
||||
|
||||
rule.runOnUiThread { onApply.onApplyWindowInsets(view, imeInsets(keyboardHeight)) }
|
||||
rule.waitForIdle()
|
||||
assertEquals(keyboardHeight, target)
|
||||
|
||||
rule.runOnUiThread {
|
||||
callback.onPrepare(anim())
|
||||
callback.onStart(anim(), bounds())
|
||||
}
|
||||
rule.waitForIdle()
|
||||
|
||||
rule.runOnUiThread { onApply.onApplyWindowInsets(view, imeInsets(0)) }
|
||||
rule.waitForIdle()
|
||||
|
||||
assertEquals(
|
||||
"updateImeAnimationTarget is called outside the guard, so this reading stays truthful",
|
||||
0,
|
||||
target,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,8 @@ package com.vitorpamplona.amethyst.service.ai
|
||||
class NoOpWritingAssistant : WritingAssistant {
|
||||
override suspend fun checkAvailability(): WritingAssistantStatus = WritingAssistantStatus.Unavailable
|
||||
|
||||
override suspend fun requestDownload(): WritingAssistantStatus = WritingAssistantStatus.Unavailable
|
||||
|
||||
override suspend fun transform(
|
||||
text: String,
|
||||
tone: WritingTone,
|
||||
|
||||
+3
@@ -23,6 +23,9 @@ package com.vitorpamplona.amethyst.service.ai
|
||||
import android.content.Context
|
||||
|
||||
object WritingAssistantFactory {
|
||||
/** Whether this flavor ships a real assistant. Drives the Settings tile. */
|
||||
const val IS_SUPPORTED = false
|
||||
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
fun create(context: Context): WritingAssistant = NoOpWritingAssistant()
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CAMERA" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
|
||||
<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" />
|
||||
@@ -453,7 +454,7 @@
|
||||
|
||||
<service
|
||||
android:name=".service.call.CallForegroundService"
|
||||
android:foregroundServiceType="microphone|camera|phoneCall"
|
||||
android:foregroundServiceType="microphone|camera|phoneCall|mediaProjection"
|
||||
android:stopWithTask="false"
|
||||
android:exported="false" />
|
||||
|
||||
@@ -579,6 +580,17 @@
|
||||
android:excludeFromRecents="true"
|
||||
android:launchMode="singleTop"
|
||||
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
|
||||
<!-- Invisible host that runs the system file picker for an embedded WebView surface. The
|
||||
`:napplet` providers are windowless services with no Activity of their own, so the main
|
||||
process collects the pick and relays the URIs back to the sandbox. -->
|
||||
<!-- Standard launch mode on purpose: two embedded surfaces can each have a pick in flight, and
|
||||
singleTop would collapse the second onto the first and strand its page's file input. -->
|
||||
<activity
|
||||
android:name=".napplet.WebFileChooserActivity"
|
||||
android:exported="false"
|
||||
android:excludeFromRecents="true"
|
||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden|keyboard|uiMode|navigation|fontScale|density"
|
||||
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
|
||||
<!-- First-connect "Connect to Nostr" dialog. -->
|
||||
<activity
|
||||
android:name=".connectedApps.consent.SignerConnectActivity"
|
||||
|
||||
@@ -57,6 +57,9 @@ import java.io.File
|
||||
*/
|
||||
class Amethyst : Application() {
|
||||
init {
|
||||
// Deliberately in init, not onCreate: this runs in EVERY process, including the
|
||||
// :napplet sandbox, whose onCreate early-returns. Moving it would leave that
|
||||
// process on the wrapper's DEBUG default.
|
||||
Log.minLevel = DEFAULT_LOG_LEVEL
|
||||
Log.d("AmethystApp") { "Creating App $this" }
|
||||
}
|
||||
@@ -82,9 +85,14 @@ class Amethyst : Application() {
|
||||
*/
|
||||
val DEFAULT_LOG_LEVEL: LogLevel =
|
||||
when {
|
||||
!BuildConfig.DEBUG -> LogLevel.WARN
|
||||
VERBOSE_LOGS -> LogLevel.DEBUG
|
||||
else -> LogLevel.INFO
|
||||
// `isDebug` also covers the `benchmark` build type — a release build (R8 + AOT)
|
||||
// that exists purely to be measured and is never shipped. Treating it as a release
|
||||
// build left it at WARN, which drops every INFO milestone the boot narrative is
|
||||
// made of (account load timings, Tor status transitions, the relay census), so the
|
||||
// one variant whose numbers are trustworthy was also the one we could not read.
|
||||
VERBOSE_LOGS && isDebug -> LogLevel.DEBUG
|
||||
isDebug -> LogLevel.INFO
|
||||
else -> LogLevel.WARN
|
||||
}
|
||||
|
||||
lateinit var instance: AppModules
|
||||
|
||||
@@ -50,11 +50,13 @@ import com.vitorpamplona.amethyst.model.nip11RelayInfo.Nip11CachedRetriever
|
||||
import com.vitorpamplona.amethyst.model.preferences.BuzzAttestationPreferences
|
||||
import com.vitorpamplona.amethyst.model.preferences.BuzzChannelStarPreferences
|
||||
import com.vitorpamplona.amethyst.model.preferences.BuzzWorkspacePreferences
|
||||
import com.vitorpamplona.amethyst.model.preferences.DrawerSectionCollapsePreferences
|
||||
import com.vitorpamplona.amethyst.model.preferences.NamecoinSharedPreferences
|
||||
import com.vitorpamplona.amethyst.model.preferences.OtsSharedPreferences
|
||||
import com.vitorpamplona.amethyst.model.preferences.RelayGroupDeletionPreferences
|
||||
import com.vitorpamplona.amethyst.model.preferences.TorSharedPreferences
|
||||
import com.vitorpamplona.amethyst.model.preferences.UiSharedPreferences
|
||||
import com.vitorpamplona.amethyst.model.preferences.sharedPreferencesDataStore
|
||||
import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilder
|
||||
import com.vitorpamplona.amethyst.model.torState.AccountsTorStateConnector
|
||||
import com.vitorpamplona.amethyst.model.torState.TorRelayState
|
||||
@@ -130,7 +132,6 @@ import com.vitorpamplona.amethyst.ui.screen.AccountState
|
||||
import com.vitorpamplona.amethyst.ui.screen.UiSettingsState
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorManager
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorService
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorServiceStatus
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
@@ -208,6 +209,19 @@ class AppModules(
|
||||
|
||||
val applicationIOScope = CoroutineScope(Dispatchers.IO + SupervisorJob() + exceptionHandler)
|
||||
|
||||
/**
|
||||
* Mints and caches BUD-01 read-auth tokens for auth-gated Blossom hosts.
|
||||
* Shared by the OkHttp interceptor (which only reads the cache) and Coil's
|
||||
* [com.vitorpamplona.amethyst.service.images.BlossomReadAuthFetcher] (which
|
||||
* awaits a signature), so both see one token and one in-flight signature per
|
||||
* host. Signing runs on [applicationIOScope], never on an OkHttp thread.
|
||||
*/
|
||||
val blossomReadAuthTokens =
|
||||
BlossomReadAuthTokenProvider(
|
||||
signerProvider = { sessionManager.loggedInAccount()?.signer },
|
||||
scope = applicationIOScope,
|
||||
)
|
||||
|
||||
private val _trimLevelEvents = MutableSharedFlow<Int>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
val trimLevelEvents = _trimLevelEvents.asSharedFlow()
|
||||
|
||||
@@ -269,7 +283,7 @@ class AppModules(
|
||||
UiSettingsState(uiPrefs.value, connManager.isMobileOrFalse, applicationIOScope)
|
||||
}
|
||||
|
||||
private val torService = TorService(appContext)
|
||||
private val torService = TorService(appContext, applicationIOScope)
|
||||
val torManager = TorManager(torPrefs, torService, applicationIOScope)
|
||||
|
||||
// Network identity change (wifi↔cellular, regained from offline, captive portal
|
||||
@@ -292,6 +306,11 @@ class AppModules(
|
||||
// kind-44100 for it (device-global; a delete is authoritative and terminal for everyone).
|
||||
val relayGroupDeletionPrefs = RelayGroupDeletionPreferences(appContext, applicationIOScope)
|
||||
|
||||
// Restore + persist which drawer section headings the user has folded away, so the side menu
|
||||
// opens the way they left it (device-global: a collapsed heading is a per-device view choice,
|
||||
// not an account setting worth syncing, unlike the hidden rows beside it in the drawer).
|
||||
val drawerSectionCollapsePrefs = DrawerSectionCollapsePreferences(appContext.sharedPreferencesDataStore, applicationIOScope)
|
||||
|
||||
// Service that will run at all times to receive events from Pokey
|
||||
val pokeyReceiver = PokeyReceiver()
|
||||
|
||||
@@ -401,7 +420,11 @@ class AppModules(
|
||||
init {
|
||||
applicationIOScope.launch {
|
||||
torService.status
|
||||
.map { it is TorServiceStatus.Active }
|
||||
// Battery ledger: Tor is doing work from the moment the client exists — the
|
||||
// directory download is the most expensive part of a launch — so this tracks
|
||||
// "running", not "bootstrapped". Keying it on Active alone would silently omit the
|
||||
// 12-34s download from every cold start.
|
||||
.map { it.socksPort != null }
|
||||
.distinctUntilChanged()
|
||||
.collect { torSession.setActive(it) }
|
||||
}
|
||||
@@ -448,9 +471,8 @@ class AppModules(
|
||||
// tracks the logged-in account.
|
||||
blossomReadAuth =
|
||||
BlossomReadAuthInterceptor(
|
||||
BlossomReadAuthTokenProvider(
|
||||
signerProvider = { sessionManager.loggedInAccount()?.signer },
|
||||
)::authHeader,
|
||||
cachedHeaderProvider = blossomReadAuthTokens::cachedHeader,
|
||||
onAuthRequired = blossomReadAuthTokens::warm,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -635,7 +657,7 @@ class AppModules(
|
||||
// proxy during bootstrap. RelayProxyClientConnector reconnects them (with
|
||||
// ignoreRetryDelays=true) the instant Tor flips to Active.
|
||||
canDial = { url ->
|
||||
!torEvaluatorFlow.shouldUseTorForRelay(url) || torManager.isSocksReady()
|
||||
!torEvaluatorFlow.shouldUseTorForRelay(url) || torManager.isTorReady()
|
||||
},
|
||||
)
|
||||
|
||||
@@ -704,7 +726,7 @@ class AppModules(
|
||||
TorCircuitHealthTracker(
|
||||
client = client,
|
||||
isTorRouted = { torEvaluatorFlow.shouldUseTorForRelay(it) },
|
||||
isTorActive = { torManager.isSocksReady() },
|
||||
isTorActive = { torManager.isTorReady() },
|
||||
isConnectivityActive = { connManager.status.value is ConnectivityStatus.Active },
|
||||
onCircuitsDead = { torManager.onTorCircuitsDead() },
|
||||
).also { it.register() }
|
||||
@@ -1083,6 +1105,7 @@ class AppModules(
|
||||
callFactory = { roleBasedHttpClientBuilder.okHttpClientForImage(it) },
|
||||
thumbnailCache = thumbnailDiskCache,
|
||||
backgroundScope = applicationIOScope,
|
||||
readAuth = blossomReadAuthTokens,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -21,12 +21,12 @@
|
||||
package com.vitorpamplona.amethyst.favorites
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
|
||||
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
package com.vitorpamplona.amethyst.favorites
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
|
||||
@@ -24,7 +24,6 @@ import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.R
|
||||
@@ -41,6 +40,7 @@ import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent
|
||||
import com.vitorpamplona.quartz.nip5aStaticWebsites.RootSiteEvent
|
||||
import com.vitorpamplona.quartz.nip5dNapplets.NamedNappletEvent
|
||||
import com.vitorpamplona.quartz.nip5dNapplets.RootNappletEvent
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
|
||||
/**
|
||||
* Turns a [FavoriteApp] back into a running app. The two cases map to the two launch paths in the
|
||||
@@ -145,7 +145,7 @@ object FavoriteAppLauncher {
|
||||
profile = HostProfile.WEBSITE,
|
||||
)
|
||||
else -> {
|
||||
Log.w("FavoriteAppLauncher", "Favorited app not resolvable yet: $coordinate")
|
||||
Log.w("FavoriteAppLauncher") { "Favorited app not resolvable yet: $coordinate" }
|
||||
Toast.makeText(context, R.string.favorite_app_still_loading, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,12 +21,12 @@
|
||||
package com.vitorpamplona.amethyst.favorites
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
|
||||
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
|
||||
@@ -31,7 +31,6 @@ import com.vitorpamplona.amethyst.commons.connectedApps.signers.InMemoryNostrSig
|
||||
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionLedger
|
||||
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionStore
|
||||
import com.vitorpamplona.amethyst.commons.defaults.Constants
|
||||
import com.vitorpamplona.amethyst.commons.defaults.DefaultIndexerRelayList
|
||||
import com.vitorpamplona.amethyst.commons.marmot.MarmotManager
|
||||
import com.vitorpamplona.amethyst.commons.model.IAccount
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelStars
|
||||
@@ -64,6 +63,7 @@ import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.ContactCa
|
||||
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.nipACWebRtcCalls.CallManager
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderAccount
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthCustomToggles
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPermissionStore
|
||||
@@ -138,6 +138,7 @@ import com.vitorpamplona.amethyst.model.nip78AppSpecific.AppSpecificState
|
||||
import com.vitorpamplona.amethyst.model.nip89AppHandlers.AppRecommendationsState
|
||||
import com.vitorpamplona.amethyst.model.nipA3PaymentTargets.NipA3PaymentTargetsState
|
||||
import com.vitorpamplona.amethyst.model.nipB7Blossom.BlossomServerListState
|
||||
import com.vitorpamplona.amethyst.model.serverList.AssumedRelayListsState
|
||||
import com.vitorpamplona.amethyst.model.serverList.MergedFollowListsState
|
||||
import com.vitorpamplona.amethyst.model.serverList.MergedFollowPlusMineRelayListsState
|
||||
import com.vitorpamplona.amethyst.model.serverList.MergedFollowPlusMineWithIndexRelayListsState
|
||||
@@ -384,12 +385,16 @@ class Account(
|
||||
// doubles as the attribution pubkey for ExplainedFilter.accountPubKeys.
|
||||
override val userFinderPubkeyHex: HexKey get() = userProfile().pubkeyHex
|
||||
|
||||
override fun indexRelays(): Set<NormalizedRelayUrl> = indexerRelayList.flow.value.ifEmpty { DefaultIndexerRelayList }
|
||||
// No ifEmpty here on purpose: an empty kind:10086 is the user asking for no indexers, and
|
||||
// IndexerRelayListState already substitutes the defaults for the only case we may override —
|
||||
// never having seen the event. Re-substituting here would undo that choice.
|
||||
override fun indexRelays(): Set<NormalizedRelayUrl> = indexerRelayList.flow.value
|
||||
|
||||
override fun outboxHomeRelays(): Set<NormalizedRelayUrl> = nip65RelayList.allFlowNoDefaults.value + privateStorageRelayList.flow.value + localRelayList.flow.value
|
||||
|
||||
// searchRelayList.flow already applies the DefaultSearchRelayList fallback internally
|
||||
// (SearchRelayListState.normalizeSearchRelayListWithBackup), so no ifEmpty needed here.
|
||||
// searchRelayList.flow applies DefaultSearchRelayList internally when no kind:10007 has ever
|
||||
// been seen (SearchRelayListState.normalizeSearchRelayListWithBackup); an empty published list
|
||||
// stays empty. No ifEmpty here either way.
|
||||
override fun searchRelays(): Set<NormalizedRelayUrl> = (trustedRelayList.flow.value + searchRelayList.flow.value).toSet()
|
||||
|
||||
override fun searchOnlyRelays(): Set<NormalizedRelayUrl> = searchRelayList.flow.value
|
||||
@@ -809,6 +814,9 @@ class Account(
|
||||
|
||||
val trustedRelays = TrustedRelayListsState(nip65RelayList, privateStorageRelayList, localRelayList, dmRelayList, searchRelayList, indexerRelayList, proxyRelayList, trustedRelayList, broadcastRelayList, scope)
|
||||
|
||||
/** Relays guessed on the user's behalf until their own lists arrive. Read only by Tor routing. */
|
||||
val assumedRelays = AssumedRelayListsState(nip65RelayList, searchRelayList, indexerRelayList, scope)
|
||||
|
||||
// Follows Relays
|
||||
val followOutboxesOrProxy = FollowListOutboxOrProxyRelays(kind3FollowList, blockedRelayList, proxyRelayList, cache, scope)
|
||||
|
||||
@@ -840,6 +848,30 @@ class Account(
|
||||
|
||||
val newNotesPreProcessor = EventProcessor(this, cache)
|
||||
|
||||
/**
|
||||
* Owns the WebRTC call state machine.
|
||||
*
|
||||
* Account-scoped on purpose: a call outlives the main UI. It runs in its own
|
||||
* [com.vitorpamplona.amethyst.ui.call.CallActivity] (a separate task, since MainActivity is
|
||||
* `singleInstance`) backed by a foreground service, so Android is free to destroy the
|
||||
* backgrounded MainActivity while the call is up — which it does routinely, e.g. a few hundred
|
||||
* milliseconds after CallActivity enters picture-in-picture on HOME. While this lived on
|
||||
* `AccountViewModel` (and ran on `viewModelScope`), that destruction cleared the ViewModel and
|
||||
* reset the call to Idle, hanging up mid-conversation.
|
||||
*
|
||||
* Torn down with the account: [scope] is cancelled by
|
||||
* `AccountCacheState.removeAccount`, which also calls [CallManager.dispose] for the
|
||||
* independent watchdog scope.
|
||||
*/
|
||||
val callManager =
|
||||
CallManager(
|
||||
signer = signer,
|
||||
scope = scope,
|
||||
isFollowing = { isFollowing(it) },
|
||||
publishEvent = { wrap -> scope.launch { publishCallSignaling(wrap) } },
|
||||
isCallsEnabled = { settings.callsEnabled.value },
|
||||
)
|
||||
|
||||
// Per-message publish acceptance (relay OKs), feeding the delivery ticks on
|
||||
// own chat bubbles.
|
||||
val chatDeliveryTracker = ChatDeliveryTracker(client)
|
||||
@@ -3624,6 +3656,10 @@ class Account(
|
||||
init {
|
||||
Log.d("AccountRegisterObservers", "Init")
|
||||
|
||||
// Route incoming call signaling into the state machine as soon as the account exists, so
|
||||
// offers are not missed while no UI is mounted.
|
||||
newNotesPreProcessor.callManager = callManager
|
||||
|
||||
// Blocking a relay has to forget any "just for now" login to it, or unblocking later would
|
||||
// silently resume authenticating off an answer given before the block. Blocking is the
|
||||
// strongest signal available here — the weaker "never allow" already drops the grant via
|
||||
|
||||
@@ -1381,7 +1381,7 @@ class AccountConcordActions(
|
||||
val bannedHere = authority.isBanned(account.signer.pubKey)
|
||||
val merged = ConcordActions.recoverStranded(entry, bundle, bannedHere) ?: continue
|
||||
if (!adoptedConcordRotations.add("${entry.id}:${merged.rootEpoch}")) continue
|
||||
Log.i("Concord", "Stranded recovery: ${entry.id} ${entry.rootEpoch} -> ${merged.rootEpoch}")
|
||||
Log.i("Concord") { "Stranded recovery: ${entry.id} ${entry.rootEpoch} -> ${merged.rootEpoch}" }
|
||||
account.sendMyPublicAndPrivateOutbox(account.concordChannelList.follow(merged))
|
||||
announceConcordGuestbookJoin(merged, inviteCreator = null, inviteLabel = null)
|
||||
}
|
||||
@@ -1521,11 +1521,10 @@ class AccountConcordActions(
|
||||
val events = account.client.fetchAll(filters = relays.associateWith { listOf(filter) }, idleTimeoutMs = 30_000L)
|
||||
val newest = events.filterIsInstance<ConcordCommunityListEvent>().maxByOrNull { it.createdAt }
|
||||
val entryCount = newest?.let { runCatching { it.decrypt(account.signer).size }.getOrElse { -1 } } ?: 0
|
||||
Log.d(
|
||||
"Concord",
|
||||
Log.d("Concord") {
|
||||
"importConcordCommunities: queried ${relays.size} relays, fetched ${events.size} 13302 event(s), " +
|
||||
"newest=${newest?.id?.take(8)}@${newest?.createdAt}, decoded $entryCount entr${if (entryCount == 1) "y" else "ies"}",
|
||||
)
|
||||
"newest=${newest?.id?.take(8)}@${newest?.createdAt}, decoded $entryCount entr${if (entryCount == 1) "y" else "ies"}"
|
||||
}
|
||||
newest?.let { account.cache.justConsumeMyOwnEvent(it) }
|
||||
}
|
||||
|
||||
@@ -1600,6 +1599,6 @@ class AccountConcordActions(
|
||||
val byRelay = authorsByRelay.mapValues { (_, authors) -> listOf(ConcordActions.planeFilterFor(authors.toList())) }
|
||||
var drained = 0
|
||||
account.client.fetchAllPagesFromPool(filters = byRelay) { _, _ -> drained++ }
|
||||
Log.d("Concord", "syncConcordControlPlanes: paged ${authorsByRelay.size} relay(s), drained $drained control wrap(s)")
|
||||
Log.d("Concord") { "syncConcordControlPlanes: paged ${authorsByRelay.size} relay(s), drained $drained control wrap(s)" }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -788,6 +788,62 @@ class AccountSettings(
|
||||
// list names
|
||||
// ---
|
||||
|
||||
/**
|
||||
* All per-screen persisted feed filters paired with their factory default.
|
||||
* Deleting a list (NIP-51 people list / follow pack) must reset any screen whose
|
||||
* filter still points at the deleted address — otherwise the screen keeps a
|
||||
* dangling [TopFilter.PeopleList] that re-creates an empty AddressableNote shell
|
||||
* on every start and shows the list's dTag/UUID in the top bar instead of a name.
|
||||
*/
|
||||
private val feedFiltersWithDefaults: List<Pair<MutableStateFlow<TopFilter>, TopFilter>> =
|
||||
listOf(
|
||||
defaultHomeFollowList to TopFilter.AllFollows,
|
||||
defaultStoriesFollowList to TopFilter.Global,
|
||||
defaultNotificationFollowList to TopFilter.Selected,
|
||||
defaultDiscoveryFollowList to TopFilter.Global,
|
||||
defaultPollsFollowList to TopFilter.Global,
|
||||
defaultPicturesFollowList to TopFilter.Global,
|
||||
defaultNappletsFollowList to TopFilter.Global,
|
||||
defaultNsitesFollowList to TopFilter.Global,
|
||||
defaultWorkoutsFollowList to TopFilter.Global,
|
||||
defaultGitRepositoriesFollowList to TopFilter.Global,
|
||||
defaultHighlightsFollowList to TopFilter.Global,
|
||||
defaultCalendarsFollowList to TopFilter.Global,
|
||||
defaultProductsFollowList to TopFilter.AroundMe,
|
||||
defaultShortsFollowList to TopFilter.Global,
|
||||
defaultPublicChatsFollowList to TopFilter.Global,
|
||||
defaultLiveStreamsFollowList to TopFilter.Global,
|
||||
defaultNestsFollowList to TopFilter.Global,
|
||||
defaultLongsFollowList to TopFilter.Global,
|
||||
defaultArticlesFollowList to TopFilter.AllFollows,
|
||||
defaultMusicTracksFollowList to TopFilter.Global,
|
||||
defaultMusicPlaylistsFollowList to TopFilter.Global,
|
||||
defaultPodcastEpisodesFollowList to TopFilter.Global,
|
||||
defaultPodcastsFollowList to TopFilter.Global,
|
||||
defaultSoftwareAppsFollowList to TopFilter.Global,
|
||||
defaultBadgesFollowList to TopFilter.Mine,
|
||||
defaultBrowseEmojiSetsFollowList to TopFilter.Global,
|
||||
defaultCommunitiesFollowList to TopFilter.AllFollows,
|
||||
defaultFollowPacksFollowList to TopFilter.Global,
|
||||
defaultAppRecommendationsFollowList to TopFilter.Global,
|
||||
defaultRelayGroupsDiscoveryFollowList to TopFilter.Mine,
|
||||
)
|
||||
|
||||
/** Resets every persisted feed filter that points at the deleted list's address. */
|
||||
fun resetFeedFiltersPointingTo(address: Address) {
|
||||
var changed = false
|
||||
|
||||
feedFiltersWithDefaults.forEach { (flow, default) ->
|
||||
val current = flow.value
|
||||
if (current is TopFilter.AddressableTopFilter && current.address == address) {
|
||||
flow.tryEmit(default)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) saveAccountSettings()
|
||||
}
|
||||
|
||||
fun changeDefaultHomeFollowList(name: FeedDefinition) {
|
||||
changeDefaultHomeFollowList(name.code)
|
||||
}
|
||||
|
||||
@@ -99,18 +99,20 @@ class AccountZapActions(
|
||||
|
||||
suspend fun sendNwcRequest(
|
||||
request: Request,
|
||||
onTimeout: () -> Unit = {},
|
||||
onResponse: (Response?) -> Unit,
|
||||
) {
|
||||
val (event, relay) = account.nip47SignerState.sendNwcRequest(request, onResponse)
|
||||
val (event, relay) = account.nip47SignerState.sendNwcRequest(request, onTimeout, onResponse)
|
||||
account.client.publish(event, setOf(relay))
|
||||
}
|
||||
|
||||
suspend fun sendNwcRequestToWallet(
|
||||
walletUri: Nip47WalletConnect.Nip47URINorm,
|
||||
request: Request,
|
||||
onTimeout: () -> Unit = {},
|
||||
onResponse: (Response?) -> Unit,
|
||||
): HexKey {
|
||||
val (event, relay) = account.nip47SignerState.sendNwcRequestToWallet(walletUri, request, onResponse)
|
||||
val (event, relay) = account.nip47SignerState.sendNwcRequestToWallet(walletUri, request, onTimeout, onResponse)
|
||||
account.client.publish(event, setOf(relay))
|
||||
return event.id
|
||||
}
|
||||
@@ -127,12 +129,20 @@ class AccountZapActions(
|
||||
*/
|
||||
fun cleanupNwcRequest(requestId: HexKey) = LocalCache.paymentTracker.cleanup(requestId)
|
||||
|
||||
/**
|
||||
* @param onTimeout invoked when no kind-23195 reply arrives before
|
||||
* [NwcSignerState.NWC_RESPONSE_TIMEOUT_MS]. Pass one on any path with a user
|
||||
* watching: without it a response lost in transit is indistinguishable from
|
||||
* the action never having happened.
|
||||
*/
|
||||
suspend fun sendZapPaymentRequestFor(
|
||||
bolt11: String,
|
||||
zappedNote: Note?,
|
||||
onTimeout: () -> Unit = {},
|
||||
metadata: Map<String, Any?>? = null,
|
||||
onResponse: (Response?) -> Unit,
|
||||
) {
|
||||
val (event, relay) = account.nip47SignerState.sendZapPaymentRequestFor(bolt11, zappedNote, onResponse)
|
||||
val (event, relay) = account.nip47SignerState.sendZapPaymentRequestFor(bolt11, zappedNote, onTimeout, metadata, onResponse)
|
||||
account.client.publish(event, setOf(relay))
|
||||
}
|
||||
|
||||
|
||||
@@ -825,6 +825,12 @@ object LocalCache : ILocalCache, ICacheProvider, Dao {
|
||||
false
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a kind-5 event from the addressable's own author has deleted this
|
||||
* address. Works for empty addressable shells whose event is not loaded yet.
|
||||
*/
|
||||
fun hasBeenDeleted(address: Address): Boolean = deletionIndex.hasBeenDeleted(address, address.pubKeyHex)
|
||||
|
||||
fun getOrAddAliasNote(
|
||||
idHex: String,
|
||||
note: Note,
|
||||
@@ -1272,6 +1278,22 @@ object LocalCache : ILocalCache, ICacheProvider, Dao {
|
||||
event.tagsWithoutCitations().filter { it != event.repository()?.toTag() }.mapNotNull { checkGetOrCreateNote(it) }
|
||||
}
|
||||
|
||||
is GitPullRequestUpdateEvent -> {
|
||||
// Link the update to its parent PR so it lands in the PR's
|
||||
// replies collection (and picks up its target for threading).
|
||||
// The repository ATag isn't a reply target — skip it.
|
||||
listOfNotNull(event.parentPullRequestId()?.let { checkGetOrCreateNote(it) })
|
||||
}
|
||||
|
||||
is GitStatusEvent -> {
|
||||
// A status event roots itself at a patch/PR/issue via a
|
||||
// marked-`root` `e` tag; link only that so the transition
|
||||
// appears in the target's replies (GitStatusIndex reduces the
|
||||
// observed stream separately and doesn't need this wiring, but
|
||||
// ThreadFeedView and the notifications-tab reply chain do).
|
||||
listOfNotNull(event.rootEventId()?.let { checkGetOrCreateNote(it) })
|
||||
}
|
||||
|
||||
is TextNoteEvent -> {
|
||||
event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) }
|
||||
}
|
||||
@@ -3102,6 +3124,16 @@ object LocalCache : ILocalCache, ICacheProvider, Dao {
|
||||
wasVerified: Boolean,
|
||||
): Boolean {
|
||||
val requestId = event.requestId()
|
||||
|
||||
// Duplicate delivery, checked before the tracker so the warnings below mean one
|
||||
// thing each. Some NWC relays replay every cached kind-23195 whenever the REQ
|
||||
// filter changes (see NWCPaymentFilterAssembler), so an already-answered response
|
||||
// arrives again and again. Its first copy consumed the pending request, so the
|
||||
// replays would otherwise be reported as "no pending request is registered" —
|
||||
// the same line a genuinely late response produces, which made the two
|
||||
// indistinguishable in the field.
|
||||
if (getNoteIfExists(event.id)?.event != null) return false
|
||||
|
||||
val pending =
|
||||
when (val match = paymentTracker.onResponseReceived(requestId, event.pubKey)) {
|
||||
is NwcPaymentTracker.MatchResult.Matched -> {
|
||||
@@ -3121,9 +3153,12 @@ object LocalCache : ILocalCache, ICacheProvider, Dao {
|
||||
}
|
||||
|
||||
NwcPaymentTracker.MatchResult.NoMatch -> {
|
||||
// Not a replay — those are filtered above — so this is the first time we
|
||||
// have seen this response and nothing is waiting for it.
|
||||
Log.w("LocalCache") {
|
||||
"NWC response ${event.id} from ${event.pubKey} references request e=$requestId but no pending request is registered. " +
|
||||
"The response was either delivered after timeout, the user holds a stale subscription, or the wallet service set the wrong e tag."
|
||||
"The response arrived after the client gave up waiting, the user holds a stale subscription, " +
|
||||
"or the wallet service set the wrong e tag."
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -3137,7 +3172,8 @@ object LocalCache : ILocalCache, ICacheProvider, Dao {
|
||||
val note = getOrCreateNote(event.id)
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
// Already processed this event.
|
||||
// Backstop for a concurrent delivery that loaded the event between the replay
|
||||
// check above and here. Same outcome, no warning: it is not a protocol problem.
|
||||
if (note.event != null) return false
|
||||
|
||||
if (wasVerified || justVerify(event)) {
|
||||
|
||||
+7
-4
@@ -90,6 +90,9 @@ class AccountCacheState(
|
||||
accounts.update { existingAccounts ->
|
||||
val oldValue = existingAccounts[pubkey]
|
||||
oldValue?.scope?.cancel()
|
||||
// CallManager keeps its own watchdog scope, independent of the account scope
|
||||
// cancelled above, so it has to be disposed explicitly.
|
||||
oldValue?.callManager?.dispose()
|
||||
// Unregisters the tracker's persistent listener from the shared
|
||||
// client; without this every removed account leaks a listener.
|
||||
oldValue?.chatDeliveryTracker?.destroy()
|
||||
@@ -111,7 +114,7 @@ class AccountCacheState(
|
||||
loadAccount(accountSettings)
|
||||
} catch (e: Exception) {
|
||||
if (e is kotlinx.coroutines.CancellationException) throw e
|
||||
Log.w("AccountCacheState", "Failed to preload account ${savedAccount.npub}: ${e.message}", e)
|
||||
Log.w("AccountCacheState", "Failed to preload account ${savedAccount.npub}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -144,7 +147,7 @@ class AccountCacheState(
|
||||
fun deleteAccountFiles(pubkey: HexKey) {
|
||||
val dir = File(accountsRootDir(), pubkey)
|
||||
if (dir.exists() && !dir.deleteRecursively()) {
|
||||
Log.w("AccountCacheState", "Failed to delete account directory ${dir.absolutePath}")
|
||||
Log.w("AccountCacheState") { "Failed to delete account directory ${dir.absolutePath}" }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,7 +163,7 @@ class AccountCacheState(
|
||||
if (child.deleteRecursively()) {
|
||||
Log.d("AccountCacheState") { "Pruned orphan account dir ${child.name.take(8)}…" }
|
||||
} else {
|
||||
Log.w("AccountCacheState", "Failed to prune orphan account dir ${child.absolutePath}")
|
||||
Log.w("AccountCacheState") { "Failed to prune orphan account dir ${child.absolutePath}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -282,7 +285,7 @@ class AccountCacheState(
|
||||
Dispatchers.IO +
|
||||
SupervisorJob() +
|
||||
CoroutineExceptionHandler { _, throwable ->
|
||||
Log.e("AccountCacheState", "Account ${signer.pubKey} caught exception: ${throwable.message}", throwable)
|
||||
Log.e("AccountCacheState", "Account ${signer.pubKey} caught exception", throwable)
|
||||
},
|
||||
),
|
||||
mlsGroupStateStore = mlsStore,
|
||||
|
||||
+84
-15
@@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcInfoEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -37,16 +38,23 @@ import java.util.concurrent.ConcurrentHashMap
|
||||
* supported RPC methods, and whether it emits notifications.
|
||||
*
|
||||
* Entries expire after [ttlSeconds] (default 2 days) so a wallet that later
|
||||
* changes its advertised capabilities is eventually re-checked. Reads never block
|
||||
* on the network:
|
||||
* changes its advertised capabilities is eventually re-checked. Four entry
|
||||
* points, in increasing order of how much they will wait:
|
||||
*
|
||||
* - [current] returns whatever is cached (possibly stale, possibly null) with no
|
||||
* side effect — for the payment hot path.
|
||||
* side effect and never blocks — for callers that can act on "don't know".
|
||||
* - [refreshIfStale] triggers a background fetch when the entry is missing or
|
||||
* expired, and returns immediately — call it right before using a wallet so a
|
||||
* stale entry self-heals without holding up the transaction.
|
||||
* - [getFresh] is the suspending variant for callers that can await (e.g. the
|
||||
* notification watcher deciding whether to open a subscription).
|
||||
* - [currentOrFetch] waits only when nothing at all is cached, and returns a
|
||||
* stale entry as-is — for callers where "don't know" and "no" are different
|
||||
* answers, such as NIP-44 negotiation.
|
||||
* - [getFresh] waits whenever the entry is missing *or* expired, for a caller
|
||||
* that must not act on a stale answer. No production caller needs that today.
|
||||
*
|
||||
* Every fetching path funnels through one request per wallet, so the startup
|
||||
* warm-up, a payment waiting on a cold cache and the notification watcher join
|
||||
* the same call rather than racing each other.
|
||||
*
|
||||
* A completed fetch — including a definitive "wallet published no info event"
|
||||
* (null) — is cached with a timestamp. A *failed* fetch (network error/timeout)
|
||||
@@ -65,7 +73,9 @@ class NwcInfoCache(
|
||||
)
|
||||
|
||||
private val cache = ConcurrentHashMap<HexKey, Entry>()
|
||||
private val inFlight = ConcurrentHashMap.newKeySet<HexKey>()
|
||||
|
||||
// Fetches in progress, keyed like [cache]. Every fetching path goes through [fetchOnce].
|
||||
private val inFlight = ConcurrentHashMap<HexKey, CompletableDeferred<NwcInfoEvent?>>()
|
||||
|
||||
private fun isFresh(entry: Entry): Boolean = now() - entry.fetchedAt < ttlSeconds
|
||||
|
||||
@@ -80,26 +90,85 @@ class NwcInfoCache(
|
||||
fun refreshIfStale(uri: Nip47WalletConnect.Nip47URINorm) {
|
||||
val entry = cache[uri.pubKeyHex]
|
||||
if (entry != null && isFresh(entry)) return
|
||||
if (!inFlight.add(uri.pubKeyHex)) return
|
||||
|
||||
scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
fetchAndStore(uri)
|
||||
} finally {
|
||||
inFlight.remove(uri.pubKeyHex)
|
||||
}
|
||||
}
|
||||
scope.launch(Dispatchers.IO) { fetchOnce(uri) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Suspends until a fresh-enough info event is available, fetching when the
|
||||
* entry is missing or expired. Returns the last cached (possibly stale) value
|
||||
* if the fetch fails.
|
||||
*
|
||||
* For a caller that must not act on a stale answer. No production caller needs
|
||||
* that today; prefer [currentOrFetch], which only waits on a cold cache.
|
||||
*/
|
||||
suspend fun getFresh(uri: Nip47WalletConnect.Nip47URINorm): NwcInfoEvent? {
|
||||
val entry = cache[uri.pubKeyHex]
|
||||
if (entry != null && isFresh(entry)) return entry.info
|
||||
return fetchAndStore(uri)
|
||||
return fetchOnce(uri)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whatever is cached, waiting for a fetch only when there is nothing
|
||||
* cached at all.
|
||||
*
|
||||
* This is the encryption-negotiation entry point. [current] answers "what does
|
||||
* this wallet advertise" from memory, but on a cold cache it answers null, and
|
||||
* a null there is indistinguishable from "no NIP-44" — so the caller silently
|
||||
* downgrades to NIP-04 on the first transaction after every app start. Waiting
|
||||
* once, only when nothing is known, removes that.
|
||||
*
|
||||
* A stale entry is returned as-is without waiting: it still says which
|
||||
* encryption the wallet advertises, and [refreshIfStale] self-heals it in the
|
||||
* background for next time.
|
||||
*/
|
||||
suspend fun currentOrFetch(uri: Nip47WalletConnect.Nip47URINorm): NwcInfoEvent? {
|
||||
val entry = cache[uri.pubKeyHex] ?: return fetchOnce(uri)
|
||||
refreshIfStale(uri)
|
||||
return entry.info
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs [fetchAndStore] for [uri] exactly once, however many callers ask at
|
||||
* once; every caller awaits that one result.
|
||||
*
|
||||
* The fetch runs in this cache's own [scope], never in the caller's. A caller
|
||||
* on the payment path is a `viewModelScope` coroutine that dies when the user
|
||||
* backs out of the screen — owning the fetch there would abandon it, leave the
|
||||
* cache cold and make the next attempt pay the whole cost again. Here a caller
|
||||
* giving up cancels only its own `await`, and the fetch it started still lands.
|
||||
*/
|
||||
private suspend fun fetchOnce(uri: Nip47WalletConnect.Nip47URINorm): NwcInfoEvent? {
|
||||
val key = uri.pubKeyHex
|
||||
val ours = CompletableDeferred<NwcInfoEvent?>()
|
||||
// putIfAbsent returns the previous entry, so a non-null result means
|
||||
// someone else already started this wallet's fetch.
|
||||
inFlight.putIfAbsent(key, ours)?.let { return it.await() }
|
||||
|
||||
val job =
|
||||
scope.launch(Dispatchers.IO) {
|
||||
var info: NwcInfoEvent? = null
|
||||
try {
|
||||
info = fetchAndStore(uri)
|
||||
} finally {
|
||||
// Non-suspending, so awaiters are released even if the fetch is cancelled.
|
||||
inFlight.remove(key, ours)
|
||||
ours.complete(info)
|
||||
}
|
||||
}
|
||||
|
||||
// A scope that was already cancelled — this account was logged off while a
|
||||
// payment was in flight — never runs the body, so the finally above never
|
||||
// releases anyone. Without this, the caller awaits forever and the abandoned
|
||||
// slot makes every later call for this wallet do the same. Fires immediately
|
||||
// when the job is already complete, and is a no-op on the normal path.
|
||||
job.invokeOnCompletion {
|
||||
if (!ours.isCompleted) {
|
||||
inFlight.remove(key, ours)
|
||||
ours.complete(null)
|
||||
}
|
||||
}
|
||||
return ours.await()
|
||||
}
|
||||
|
||||
private suspend fun fetchAndStore(uri: Nip47WalletConnect.Nip47URINorm): NwcInfoEvent? {
|
||||
|
||||
+140
-29
@@ -37,11 +37,15 @@ import com.vitorpamplona.quartz.nip47WalletConnect.cache.NostrWalletConnectReque
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.cache.NostrWalletConnectResponseCache
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcInfoEvent
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcNotificationEvent
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcTransaction
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceMethod
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaymentReceivedNotification
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.tags.ExtensionsTag
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -60,6 +64,7 @@ import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
|
||||
/**
|
||||
* Manages NIP-47 (Nostr Wallet Connect) related signing operations and decryption cache for a given account.
|
||||
@@ -135,17 +140,58 @@ class NwcSignerState(
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-blocking read of the negotiated encryption preference for a wallet.
|
||||
* NIP-47 says a client "should always prefer nip44 if supported by the wallet
|
||||
* service". Returns true only when the cached info event advertises `nip44_v2`;
|
||||
* otherwise NIP-04 (the legacy default). Also nudges a background refresh so a
|
||||
* stale/expired entry self-heals for the next transaction without blocking this
|
||||
* one.
|
||||
* The negotiated encryption preference for a wallet. NIP-47 says a client
|
||||
* "should always prefer nip44 if supported by the wallet service", so a false
|
||||
* here has to mean "the wallet does not offer NIP-44" — not "we have not asked
|
||||
* yet". [walletInfo] is what makes that distinction true.
|
||||
*/
|
||||
private fun prefersNip44(uri: Nip47WalletConnect.Nip47URINorm?): Boolean {
|
||||
uri ?: return false
|
||||
infoCache?.refreshIfStale(uri)
|
||||
return infoCache?.current(uri)?.encryptionSchemes()?.any { it.equals("nip44_v2", ignoreCase = true) } ?: false
|
||||
private fun prefersNip44(info: NwcInfoEvent?): Boolean = info?.encryptionSchemes()?.any { it.equals("nip44_v2", ignoreCase = true) } == true
|
||||
|
||||
/**
|
||||
* The wallet's advertised capabilities: the one place a send waits on them, and
|
||||
* it waits AT MOST ONCE.
|
||||
*
|
||||
* WAITING IS THE POINT. The info cache is per-account and in memory only, so it
|
||||
* starts empty on every app launch, and reading it without waiting makes "not
|
||||
* fetched yet" indistinguishable from "not supported". That shipped twice: the
|
||||
* first transaction to each wallet after a launch fell back to NIP-04 against a
|
||||
* wallet advertising `nip44_v2`, and a payment to a wallet that had been
|
||||
* advertising NWC-06 for twenty minutes still went out bare — with nothing, on
|
||||
* either side, reporting an error.
|
||||
*
|
||||
* ONCE, because both questions read the same event. Each used to fetch for
|
||||
* itself, which is free on a warm cache and doubles the stall on a cold one:
|
||||
* [NwcInfoCache] deliberately does not cache a FAILED fetch, so with the relay
|
||||
* down both waits ran in full and a 3s worst case became 6s.
|
||||
*
|
||||
* BOUNDED, because this sits in front of a payment the user has already tapped
|
||||
* and the no-response timer does not start until it returns. On expiry the
|
||||
* answer is null — read as NIP-04 and as no-metadata, both of them the safe
|
||||
* direction — while the fetch keeps running in the cache's own scope so the next
|
||||
* request gets the negotiated scheme. Never bound the fetch itself instead: a
|
||||
* null from it is cached as a definitive "no info event" for the whole TTL,
|
||||
* which would pin the wallet to NIP-04 for days.
|
||||
*/
|
||||
private suspend fun walletInfo(uri: Nip47WalletConnect.Nip47URINorm?): NwcInfoEvent? {
|
||||
uri ?: return null
|
||||
return withTimeoutOrNull(NIP44_NEGOTIATION_WAIT_MS) { infoCache?.currentOrFetch(uri) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips NWC-06 `metadata` from a request bound for a wallet that never said it
|
||||
* understands the field — [MetadataCarrying] has the reason that matters.
|
||||
*
|
||||
* APPLIED WHERE THE REQUEST IS BUILT rather than at each call site, so populating
|
||||
* `metadata` anywhere upstream is safe by construction.
|
||||
*
|
||||
* MUTATES the request in place — see the callers' KDoc. Requests are built per
|
||||
* send and not reused, and stripping a copy would mean rebuilding a params object
|
||||
* whose field list would then drift from the original.
|
||||
*/
|
||||
private fun Request.dropMetadataIfUnsupported(info: NwcInfoEvent?) {
|
||||
val carrier = metadataCarrier ?: return
|
||||
if (carrier.metadata == null || info?.supportsExtension(ExtensionsTag.METADATA_CONVENTIONS) == true) return
|
||||
carrier.metadata = null
|
||||
}
|
||||
|
||||
fun hasWalletConnectSetup(): Boolean = settings.nwcWallets.value.isNotEmpty()
|
||||
@@ -203,21 +249,30 @@ class NwcSignerState(
|
||||
*/
|
||||
suspend fun sendNwcRequest(
|
||||
request: Request,
|
||||
onTimeout: () -> Unit = {},
|
||||
onResponse: (Response?) -> Unit,
|
||||
): Pair<LnZapPaymentRequestEvent, NormalizedRelayUrl> = sendNwcRequestToWallet(defaultWalletUri.value, request, onResponse)
|
||||
): Pair<LnZapPaymentRequestEvent, NormalizedRelayUrl> = sendNwcRequestToWallet(defaultWalletUri.value, request, onTimeout, onResponse)
|
||||
|
||||
/**
|
||||
* Sends a generic NIP-47 request to a specific wallet.
|
||||
*
|
||||
* [request] MAY BE MUTATED: NWC-06 `metadata` is stripped in place when the
|
||||
* wallet has not advertised support for it. Build a fresh request per send
|
||||
* rather than retaining or re-reading this one.
|
||||
*/
|
||||
suspend fun sendNwcRequestToWallet(
|
||||
walletUri: Nip47WalletConnect.Nip47URINorm?,
|
||||
request: Request,
|
||||
onTimeout: () -> Unit = {},
|
||||
onResponse: (Response?) -> Unit,
|
||||
): Pair<LnZapPaymentRequestEvent, NormalizedRelayUrl> {
|
||||
val walletService = walletUri ?: throw IllegalArgumentException("No NIP47 setup")
|
||||
val walletSigner = buildSigner(walletService) ?: signer
|
||||
|
||||
val event = LnZapPaymentRequestEvent.createRequest(request, walletService.pubKeyHex, walletSigner, useNip44 = prefersNip44(walletService))
|
||||
val info = walletInfo(walletService)
|
||||
request.dropMetadataIfUnsupported(info)
|
||||
|
||||
val event = LnZapPaymentRequestEvent.createRequest(request, walletService.pubKeyHex, walletSigner, useNip44 = prefersNip44(info))
|
||||
|
||||
val filter =
|
||||
NWCPaymentQueryState(
|
||||
@@ -234,14 +289,7 @@ class NwcSignerState(
|
||||
// be missed.
|
||||
assembler.subscribeAndFlush(filter)
|
||||
|
||||
// Safety net: drop the filter after 60s if the wallet never replies.
|
||||
// The happy path (response arrives) cancels this job and unsubscribes
|
||||
// through assembler.unsubscribeSoon, which debounces.
|
||||
val timeoutJob =
|
||||
scope.launch(Dispatchers.IO) {
|
||||
delay(60000)
|
||||
assembler.unsubscribe(filter)
|
||||
}
|
||||
val timeoutJob = launchGiveUpTimer(assembler, filter, event.id, onTimeout)
|
||||
|
||||
val responseCache = NostrWalletConnectResponseCache(walletSigner)
|
||||
cache.consume(event, null, true, walletService.relayUri) {
|
||||
@@ -255,15 +303,30 @@ class NwcSignerState(
|
||||
|
||||
/**
|
||||
* Sends a zap payment request to the default wallet.
|
||||
*
|
||||
* [metadata] is NWC-06's per-payment blob and is dropped unless the wallet
|
||||
* advertises `06`; see [dropMetadataIfUnsupported].
|
||||
*/
|
||||
suspend fun sendZapPaymentRequestFor(
|
||||
bolt11: String,
|
||||
zappedNote: Note?,
|
||||
onTimeout: () -> Unit = {},
|
||||
metadata: Map<String, Any?>? = null,
|
||||
onResponse: (Response?) -> Unit,
|
||||
): Pair<LnZapPaymentRequestEvent, NormalizedRelayUrl> {
|
||||
val walletService = defaultWalletUri.value ?: throw IllegalArgumentException("No NIP47 setup")
|
||||
|
||||
val event = LnZapPaymentRequestEvent.create(bolt11, walletService.pubKeyHex, nip47Signer.value, useNip44 = prefersNip44(walletService))
|
||||
val info = walletInfo(walletService)
|
||||
val request = PayInvoiceMethod.create(bolt11, metadata)
|
||||
request.dropMetadataIfUnsupported(info)
|
||||
|
||||
val event =
|
||||
LnZapPaymentRequestEvent.createRequest(
|
||||
request,
|
||||
walletService.pubKeyHex,
|
||||
nip47Signer.value,
|
||||
useNip44 = prefersNip44(info),
|
||||
)
|
||||
|
||||
val filter =
|
||||
NWCPaymentQueryState(
|
||||
@@ -278,14 +341,7 @@ class NwcSignerState(
|
||||
// See sendNwcRequestToWallet above for the rationale.
|
||||
assembler.subscribeAndFlush(filter)
|
||||
|
||||
// Safety net: drop the filter after 60s if the wallet never replies.
|
||||
// The happy path (response arrives) cancels this job and instead
|
||||
// hands off to assembler.unsubscribeSoon, which debounces.
|
||||
val timeoutJob =
|
||||
scope.launch(Dispatchers.IO) {
|
||||
delay(60000) // waits 1 minute to complete payment.
|
||||
assembler.unsubscribe(filter)
|
||||
}
|
||||
val timeoutJob = launchGiveUpTimer(assembler, filter, event.id, onTimeout)
|
||||
|
||||
cache.consume(event, zappedNote, true, walletService.relayUri) {
|
||||
timeoutJob.cancel()
|
||||
@@ -295,4 +351,59 @@ class NwcSignerState(
|
||||
|
||||
return Pair(event, walletService.relayUri)
|
||||
}
|
||||
|
||||
/**
|
||||
* Safety net for a wallet that never replies: drops the subscription filter and retires
|
||||
* the request. The happy path cancels this job and unsubscribes through
|
||||
* [NWCPaymentFilterAssembler.unsubscribeSoon] instead, which debounces.
|
||||
*/
|
||||
private fun launchGiveUpTimer(
|
||||
assembler: NWCPaymentFilterAssembler,
|
||||
filter: NWCPaymentQueryState,
|
||||
requestId: HexKey,
|
||||
onTimeout: () -> Unit,
|
||||
) = scope.launch(Dispatchers.IO) {
|
||||
delay(NWC_RESPONSE_TIMEOUT_MS)
|
||||
assembler.unsubscribe(filter)
|
||||
giveUpWaiting(requestId, onTimeout)
|
||||
}
|
||||
|
||||
/**
|
||||
* Retires a request whose response never arrived: removes the tracker entry so it
|
||||
* does not leak, and tells the caller so the user hears about it. A silent give-up
|
||||
* is the worst outcome for a payment UI — the action just appears not to have
|
||||
* happened, which is indistinguishable from a refusal the wallet did send.
|
||||
*
|
||||
* A `cleanup` that returns false means a response beat us to the tracker entry,
|
||||
* so the response path is already reporting and this must stay quiet.
|
||||
*/
|
||||
private fun giveUpWaiting(
|
||||
requestId: HexKey,
|
||||
onTimeout: () -> Unit,
|
||||
) {
|
||||
val wasStillPending = cache.paymentTracker.cleanup(requestId)
|
||||
if (wasStillPending) {
|
||||
Log.w("NwcSignerState") {
|
||||
"No NIP-47 response for request $requestId after ${NWC_RESPONSE_TIMEOUT_MS}ms; giving up and dropping the subscription."
|
||||
}
|
||||
onTimeout()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* How long a NIP-47 request waits for its kind-23195 reply before the client
|
||||
* gives up. Exposed in seconds so the UI can name the number it shows the user.
|
||||
*/
|
||||
const val NWC_RESPONSE_TIMEOUT_SECONDS = 60
|
||||
|
||||
const val NWC_RESPONSE_TIMEOUT_MS = NWC_RESPONSE_TIMEOUT_SECONDS * 1000L
|
||||
|
||||
/**
|
||||
* How long a request will wait for a cold info cache before falling back to
|
||||
* NIP-04. Comfortably over a healthy single-relay round trip, far under the
|
||||
* 30s the fetch itself would otherwise allow in front of a payment tap.
|
||||
*/
|
||||
const val NIP44_NEGOTIATION_WAIT_MS = 3_000L
|
||||
}
|
||||
}
|
||||
|
||||
+24
-6
@@ -58,7 +58,11 @@ class IndexerRelayListState(
|
||||
|
||||
fun indexListEvent(note: Note) = note.event as? IndexerRelayListEvent ?: settings.backupIndexRelayList
|
||||
|
||||
suspend fun normalizeIndexerRelayListWithBackup(note: Note): Set<NormalizedRelayUrl> = indexListEvent(note)?.let { decryptionCache.relays(it) }?.ifEmpty { null } ?: DefaultIndexerRelayList
|
||||
suspend fun normalizeIndexerRelayListWithBackup(note: Note): Set<NormalizedRelayUrl> {
|
||||
val event = indexListEvent(note) ?: return DefaultIndexerRelayList
|
||||
// Fully decrypted here, so empty means the user listed nothing — not "not decrypted yet".
|
||||
return decryptionCache.relays(event)
|
||||
}
|
||||
|
||||
suspend fun normalizeIndexerRelayListWithBackupNoDefaults(note: Note): Set<NormalizedRelayUrl> = indexListEvent(note)?.let { decryptionCache.relays(it) } ?: emptySet()
|
||||
|
||||
@@ -73,12 +77,26 @@ class IndexerRelayListState(
|
||||
*/
|
||||
fun normalizeIndexerRelayListPrecached(note: Note): Set<NormalizedRelayUrl> = indexListEvent(note)?.let { decryptionCache.cachedRelays(it) }?.ifEmpty { null } ?: DefaultIndexerRelayList
|
||||
|
||||
/** See `Nip65RelayListState.assumedDefaults`. Empty as soon as any kind:10086 exists. */
|
||||
fun assumedDefaults(note: Note): Set<NormalizedRelayUrl> = if (indexListEvent(note) == null) DefaultIndexerRelayList else emptySet()
|
||||
|
||||
val assumedDefaultsFlow =
|
||||
getIndexerRelayListFlow()
|
||||
.map { assumedDefaults(it.note) }
|
||||
.onStart { emit(assumedDefaults(indexerListNote)) }
|
||||
.flowOn(Dispatchers.IO)
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
assumedDefaults(indexerListNote),
|
||||
)
|
||||
|
||||
/**
|
||||
* The account's indexer relays, **never empty** — [normalizeIndexerRelayListWithBackup]
|
||||
* substitutes [DefaultIndexerRelayList] both when there is no kind:10086 and when the
|
||||
* one we have decodes to zero relays. Callers assembling metadata / relay-list REQs read
|
||||
* this and can rely on getting a usable set; use [flowNoDefaults] instead to show or diff
|
||||
* what the user actually configured.
|
||||
* The account's indexer relays. [normalizeIndexerRelayListWithBackup] substitutes
|
||||
* [DefaultIndexerRelayList] when there is no kind:10086 at all — but **not** when the one we
|
||||
* have decodes to zero relays, which is the user saying "no indexers" and is honored. Callers
|
||||
* assembling metadata / relay-list REQs must therefore tolerate an empty set; use
|
||||
* [flowNoDefaults] to show or diff what the user actually configured.
|
||||
*
|
||||
* Seeded via [normalizeIndexerRelayListPrecached] rather than `emptySet()`, for the same
|
||||
* reason as the search list: `flowOn(IO)` makes the first real emission asynchronous, so an
|
||||
|
||||
+13
-1
@@ -71,7 +71,15 @@ class FollowListsState(
|
||||
) {
|
||||
val user = cache.getOrCreateUser(signer.pubKey)
|
||||
|
||||
fun existingPeopleListNotes() = cache.addressables.filter(FollowListEvent.KIND, user.pubkeyHex)
|
||||
// Hides shells that a kind-5 deletion event from the list's author has already
|
||||
// deleted (e.g. a persisted TopFilter re-creates an empty shell for the deleted
|
||||
// address after a restart, and its name falls back to the dTag/UUID). Shells that
|
||||
// are merely not loaded yet stay in the list so the UI can subscribe and fetch
|
||||
// them from relays.
|
||||
fun existingPeopleListNotes() =
|
||||
cache.addressables
|
||||
.filter(FollowListEvent.KIND, user.pubkeyHex)
|
||||
.filter { it.event != null || !cache.hasBeenDeleted(it.address) }
|
||||
|
||||
val followListVersions = MutableStateFlow(0)
|
||||
|
||||
@@ -255,6 +263,10 @@ class FollowListsState(
|
||||
val followListEvent = getPeopleList(identifierTag)
|
||||
val deletionEvent = account.signer.sign(DeletionEvent.build(listOf(followListEvent)))
|
||||
account.sendMyPublicAndPrivateOutbox(deletionEvent)
|
||||
// Any screen whose persisted feed filter still points at this follow pack would
|
||||
// keep re-creating an empty shell for its address (and render the dTag/UUID in
|
||||
// the top bar) — reset those filters to their default.
|
||||
account.settings.resetFeedFiltersPointingTo(followListEvent.address())
|
||||
}
|
||||
|
||||
suspend fun addUserToSet(
|
||||
|
||||
+11
@@ -69,10 +69,17 @@ class PeopleListsState(
|
||||
) {
|
||||
val user = cache.getOrCreateUser(signer.pubKey)
|
||||
|
||||
// Hides the fixed-dTag block-list shell when it is not loaded (it has no
|
||||
// meaningful name until it exists) and shells that a kind-5 deletion event from
|
||||
// the list's author has already deleted (e.g. a persisted TopFilter re-creates an
|
||||
// empty shell for the deleted address after a restart, and its name falls back to
|
||||
// the dTag/UUID). Shells that are merely not loaded yet stay in the list so the UI
|
||||
// can subscribe and fetch them from relays.
|
||||
fun existingPeopleListNotes() =
|
||||
cache.addressables
|
||||
.filter(PeopleListEvent.KIND, user.pubkeyHex)
|
||||
.filter { it.dTag() != PeopleListEvent.BLOCK_LIST_D_TAG || it.event != null }
|
||||
.filter { it.event != null || !cache.hasBeenDeleted(it.address) }
|
||||
|
||||
val peopleListVersions = MutableStateFlow(0)
|
||||
|
||||
@@ -262,6 +269,10 @@ class PeopleListsState(
|
||||
val followListEvent = getPeopleList(identifierTag)
|
||||
val deletionEvent = account.signer.sign(DeletionEvent.build(listOf(followListEvent)))
|
||||
account.sendMyPublicAndPrivateOutbox(deletionEvent)
|
||||
// Any screen whose persisted feed filter still points at this list would keep
|
||||
// re-creating an empty shell for its address (and render the dTag/UUID in the
|
||||
// top bar) — reset those filters to their default.
|
||||
account.settings.resetFeedFiltersPointingTo(followListEvent.address())
|
||||
}
|
||||
|
||||
suspend fun addUserToSet(
|
||||
|
||||
+24
-5
@@ -58,7 +58,11 @@ class SearchRelayListState(
|
||||
|
||||
fun searchListEvent(note: Note) = note.event as? SearchRelayListEvent ?: settings.backupSearchRelayList
|
||||
|
||||
suspend fun normalizeSearchRelayListWithBackup(note: Note): Set<NormalizedRelayUrl> = searchListEvent(note)?.let { decryptionCache.relays(it) }?.ifEmpty { null } ?: DefaultSearchRelayList
|
||||
suspend fun normalizeSearchRelayListWithBackup(note: Note): Set<NormalizedRelayUrl> {
|
||||
val event = searchListEvent(note) ?: return DefaultSearchRelayList
|
||||
// Fully decrypted here, so empty means the user listed nothing — not "not decrypted yet".
|
||||
return decryptionCache.relays(event)
|
||||
}
|
||||
|
||||
suspend fun normalizeSearchRelayListWithBackupNoDefaults(note: Note): Set<NormalizedRelayUrl> = searchListEvent(note)?.let { decryptionCache.relays(it) } ?: emptySet()
|
||||
|
||||
@@ -74,16 +78,31 @@ class SearchRelayListState(
|
||||
*/
|
||||
fun normalizeSearchRelayListPrecached(note: Note): Set<NormalizedRelayUrl> = searchListEvent(note)?.let { decryptionCache.cachedRelays(it) }?.ifEmpty { null } ?: DefaultSearchRelayList
|
||||
|
||||
/** See `Nip65RelayListState.assumedDefaults`. Empty as soon as any kind:10007 exists. */
|
||||
fun assumedDefaults(note: Note): Set<NormalizedRelayUrl> = if (searchListEvent(note) == null) DefaultSearchRelayList else emptySet()
|
||||
|
||||
val assumedDefaultsFlow =
|
||||
getSearchRelayListFlow()
|
||||
.map { assumedDefaults(it.note) }
|
||||
.onStart { emit(assumedDefaults(searchListNote)) }
|
||||
.flowOn(Dispatchers.IO)
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
assumedDefaults(searchListNote),
|
||||
)
|
||||
|
||||
/**
|
||||
* The account's search relays, **never empty** — [normalizeSearchRelayListWithBackup]
|
||||
* substitutes [DefaultSearchRelayList] both when there is no kind:10007 and when the
|
||||
* one we have decodes to zero relays. Callers assembling NIP-50 REQs read this and can
|
||||
* The account's search relays. [normalizeSearchRelayListWithBackup] substitutes
|
||||
* [DefaultSearchRelayList] when there is no kind:10007 at all — but **not** when the one we
|
||||
* have decodes to zero relays, which is the user saying "no search relays" and is honored.
|
||||
* Callers assembling NIP-50 REQs must tolerate an empty set, and can
|
||||
* rely on getting a usable set; use [flowNoDefaults] instead to show or diff what the
|
||||
* user actually configured.
|
||||
*
|
||||
* Seeded via [normalizeSearchRelayListPrecached] rather than `emptySet()`: `flowOn(IO)` means
|
||||
* the first real emission can never be synchronous with `stateIn`, so an `emptySet()` seed
|
||||
* left a window where `.value` contradicted the "never empty" contract above and search
|
||||
* left a window where `.value` reported nothing before the event had been read at all, so search
|
||||
* silently queried nothing. That window is unbounded for a NIP-46 signer whose list has
|
||||
* private entries, since the first emission waits on a remote decrypt.
|
||||
*/
|
||||
|
||||
+24
-2
@@ -21,6 +21,7 @@
|
||||
package com.vitorpamplona.amethyst.model.nip65RelayList
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.defaults.Constants
|
||||
import com.vitorpamplona.amethyst.commons.defaults.relayListOrDefaultsWhenUnknown
|
||||
import com.vitorpamplona.amethyst.model.AccountSettings
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
@@ -58,9 +59,9 @@ class Nip65RelayListState(
|
||||
|
||||
fun nip65Event(note: Note) = note.event as? AdvertisedRelayListEvent ?: settings.backupNIP65RelayList
|
||||
|
||||
fun normalizeNIP65WriteRelayListWithBackup(note: Note): Set<NormalizedRelayUrl> = nip65Event(note)?.writeRelaysNorm()?.toSet() ?: Constants.eventFinderRelays
|
||||
fun normalizeNIP65WriteRelayListWithBackup(note: Note): Set<NormalizedRelayUrl> = relayListOrDefaultsWhenUnknown(nip65Event(note), Constants.eventFinderRelays) { it.writeRelaysNorm()?.toSet() }
|
||||
|
||||
fun normalizeNIP65ReadRelayListWithBackup(note: Note): Set<NormalizedRelayUrl> = nip65Event(note)?.readRelaysNorm()?.toSet() ?: Constants.bootstrapInbox
|
||||
fun normalizeNIP65ReadRelayListWithBackup(note: Note): Set<NormalizedRelayUrl> = relayListOrDefaultsWhenUnknown(nip65Event(note), Constants.bootstrapInbox) { it.readRelaysNorm()?.toSet() }
|
||||
|
||||
fun normalizeNIP65WriteRelayListNoDefaults(note: Note): Set<NormalizedRelayUrl> = nip65Event(note)?.writeRelaysNorm()?.toSet() ?: emptySet()
|
||||
|
||||
@@ -70,6 +71,27 @@ class Nip65RelayListState(
|
||||
|
||||
fun normalizeNIP65AllRelayListWithBackupNoDefaults(note: Note): Set<NormalizedRelayUrl> = nip65Event(note)?.relays()?.map { it.relayUrl }?.toSet() ?: emptySet()
|
||||
|
||||
/**
|
||||
* The app defaults currently standing in for a user we have no kind:10002 for — empty as soon
|
||||
* as one exists, including an empty one.
|
||||
*
|
||||
* Uses the same `nip65Event(note) == null` predicate the substitution itself uses, so the two
|
||||
* cannot drift: whatever is listed here is exactly what the app is guessing on the user's
|
||||
* behalf. See [relayListOrDefaultsWhenUnknown].
|
||||
*/
|
||||
fun assumedDefaults(note: Note): Set<NormalizedRelayUrl> = if (nip65Event(note) == null) Constants.bootstrapInbox + Constants.eventFinderRelays else emptySet()
|
||||
|
||||
val assumedDefaultsFlow =
|
||||
getNIP65RelayListFlow()
|
||||
.map { assumedDefaults(it.note) }
|
||||
.onStart { emit(assumedDefaults(nip65ListNote)) }
|
||||
.flowOn(Dispatchers.IO)
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
assumedDefaults(nip65ListNote),
|
||||
)
|
||||
|
||||
val outboxFlow =
|
||||
getNIP65RelayListFlow()
|
||||
.map { normalizeNIP65WriteRelayListWithBackup(it.note) }
|
||||
|
||||
+27
-1
@@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.model.AccountSyncedSettingsInternal
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.NoteState
|
||||
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
|
||||
import com.vitorpamplona.quartz.nip01Core.core.awaitCreatedAtToSupersede
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
@@ -33,6 +34,8 @@ import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
class AppSpecificState(
|
||||
@@ -52,12 +55,35 @@ class AppSpecificState(
|
||||
|
||||
fun getAppSpecificDataFlow(): StateFlow<NoteState> = amethystSettingsNote.flow().metadata.stateFlow
|
||||
|
||||
/**
|
||||
* Serializes the state snapshot and the timestamp it is stamped with. Two rapid toggles publish
|
||||
* from separate coroutines on the signer's dispatcher; without this they could read the same
|
||||
* previous timestamp and collide again, or take timestamps in the opposite order to the state
|
||||
* they captured. Signing and encrypting stay outside the lock — those can wait on an external
|
||||
* signer, and they don't affect ordering.
|
||||
*/
|
||||
private val stampOrder = Mutex()
|
||||
|
||||
/**
|
||||
* The newest version this instance has published, which is not always in [amethystSettingsNote]
|
||||
* yet: the cache is only updated once the event comes back through the broadcaster.
|
||||
*/
|
||||
private var lastPublishedAt = 0L
|
||||
|
||||
suspend fun saveNewAppSpecificData(): AppSpecificDataEvent {
|
||||
val toInternal = settings.syncedSettings.toInternal(settings.mutedPublicChats.value)
|
||||
val (toInternal, createdAt) =
|
||||
stampOrder.withLock {
|
||||
val snapshot = settings.syncedSettings.toInternal(settings.mutedPublicChats.value)
|
||||
val stamp = awaitCreatedAtToSupersede(maxOf(lastPublishedAt, amethystSettingsNote.event?.createdAt ?: 0L))
|
||||
lastPublishedAt = stamp
|
||||
snapshot to stamp
|
||||
}
|
||||
|
||||
return signer.sign(
|
||||
AppSpecificDataEvent.build(
|
||||
dTag = APP_SPECIFIC_DATA_D_TAG,
|
||||
description = signer.nip44Encrypt(JsonMapper.toJson(toInternal), signer.pubKey),
|
||||
createdAt = createdAt,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
+7
-7
@@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.filterIntoSet
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.awaitCreatedAtToSupersede
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
@@ -31,7 +32,6 @@ import com.vitorpamplona.quartz.nip89AppHandlers.PlatformType
|
||||
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
|
||||
import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.AppRecommendationEvent
|
||||
import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.tags.RecommendationTag
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
@@ -85,15 +85,15 @@ class AppRecommendationsState(
|
||||
private val publishMutex = Mutex()
|
||||
|
||||
/**
|
||||
* Returns a createdAt strictly greater than whatever AppRecommendationEvent
|
||||
* currently sits in cache for this d-tag. Needed because
|
||||
* LocalCache.consumeBaseReplaceable drops updates whose createdAt isn't
|
||||
* strictly greater, and TimeUtils.now() has only second resolution.
|
||||
* The createdAt this d-tag's next version needs to supersede whatever is in cache for it. Waits
|
||||
* out the second rather than stamping the future, so repeatedly toggling one recommendation
|
||||
* cannot drift its `created_at` ahead of the clock. Runs under [publishMutex], which is what
|
||||
* keeps two waits from racing each other onto the same second.
|
||||
*/
|
||||
private fun nextCreatedAt(supportedKind: String): Long {
|
||||
private suspend fun nextCreatedAt(supportedKind: String): Long {
|
||||
val address = Address(AppRecommendationEvent.KIND, signer.pubKey, supportedKind)
|
||||
val latest = cache.getAddressableNoteIfExists(address)?.event?.createdAt ?: 0L
|
||||
return maxOf(TimeUtils.now(), latest + 1)
|
||||
return awaitCreatedAtToSupersede(latest)
|
||||
}
|
||||
|
||||
private fun currentRecommendations(supportedKind: String): List<RecommendationTag> {
|
||||
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.preferences
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||
import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerSectionId
|
||||
import com.vitorpamplona.amethyst.ui.navigation.drawer.drawerSectionIdsFromNames
|
||||
import com.vitorpamplona.amethyst.ui.navigation.drawer.toNames
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
/**
|
||||
* Device-global persistence for the drawer section headings the user has collapsed, so the side menu
|
||||
* comes back folded the way they left it instead of springing fully open on every launch. Which
|
||||
* headings are folded is a per-device view choice, so unlike the hidden rows beside it in the drawer
|
||||
* it is never published to relays.
|
||||
*
|
||||
* Mirrors [RelayGroupDeletionPreferences]: app-wide (not per-account), loads the saved names on
|
||||
* construction, then writes every later change back. Takes the [DataStore] rather than a `Context`
|
||||
* so the whole cycle is exercised by a plain unit test against a temp file.
|
||||
*
|
||||
* Construct once, eagerly. The restore is a fire-and-forget coroutine, not a barrier, so the flow
|
||||
* reads as "nothing collapsed" until it lands; building this at app startup rather than on first use
|
||||
* puts that read many frames ahead of the drawer's first composition (and the store's file has
|
||||
* already been parsed by then, for `UiSharedPreferences`). Worst case if it ever lost that race is
|
||||
* cosmetic — a heading renders open and then folds — which is why no one waits on it.
|
||||
*/
|
||||
@Stable
|
||||
class DrawerSectionCollapsePreferences(
|
||||
private val store: DataStore<Preferences>,
|
||||
scope: CoroutineScope,
|
||||
) {
|
||||
private val collapsed = MutableStateFlow<Set<DrawerSectionId>>(emptySet())
|
||||
|
||||
/** The collapsed headings; the drawer collects this to decide which sections render their rows. */
|
||||
val flow: StateFlow<Set<DrawerSectionId>> = collapsed.asStateFlow()
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
restoreFromDisk()
|
||||
// drop(1) skips the value present at collection start, which restoreFromDisk already wrote.
|
||||
collapsed.drop(1).collect { persist(it) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Collapses [section] if expanded, expands it if collapsed. Safe to call from the main thread. */
|
||||
fun toggle(section: DrawerSectionId) = collapsed.update { if (section in it) it - section else it + section }
|
||||
|
||||
private suspend fun restoreFromDisk() {
|
||||
try {
|
||||
val raw = store.data.first()[KEY] ?: return
|
||||
collapsed.value = drawerSectionIdsFromNames(raw)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.e("DrawerSectionCollapsePrefs") { "Error reading collapsed drawer sections: ${e.message}" }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun persist(sections: Set<DrawerSectionId>) {
|
||||
try {
|
||||
store.edit { prefs -> prefs[KEY] = sections.toNames() }
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.e("DrawerSectionCollapsePrefs") { "Error writing collapsed drawer sections: ${e.message}" }
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val KEY = stringSetPreferencesKey("ui.drawer.collapsedSections")
|
||||
}
|
||||
}
|
||||
+68
@@ -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.model.serverList
|
||||
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.indexerRelays.IndexerRelayListState
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.searchRelays.SearchRelayListState
|
||||
import com.vitorpamplona.amethyst.model.nip65RelayList.Nip65RelayListState
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
|
||||
/**
|
||||
* The relays the app is **guessing** on the user's behalf because it has not seen their lists yet.
|
||||
*
|
||||
* Non-empty only while the corresponding event is absent — never because a list is empty, which is
|
||||
* a choice we honor (see `relayListOrDefaultsWhenUnknown`). It therefore empties itself, per list,
|
||||
* the moment the user's own data lands; no window, no timeout, no bookkeeping.
|
||||
*
|
||||
* **Deliberately NOT merged into [TrustedRelayListsState].** That one feeds `Account.isInMyRelayList`
|
||||
* -> `RelayAuthPermissionLedger` -> `RelayAuthResolver`, i.e. the NIP-42 AUTH decision. Guessed
|
||||
* relays must never make the app sign an AUTH challenge as though they were the user's own — that
|
||||
* would turn a timing signal into a signed identity assertion. The single consumer of this flow is
|
||||
* Tor routing.
|
||||
*/
|
||||
class AssumedRelayListsState(
|
||||
val nip65RelayList: Nip65RelayListState,
|
||||
val searchRelayList: SearchRelayListState,
|
||||
val indexerRelayList: IndexerRelayListState,
|
||||
val scope: CoroutineScope,
|
||||
) {
|
||||
val flow: StateFlow<Set<NormalizedRelayUrl>> =
|
||||
combine(
|
||||
nip65RelayList.assumedDefaultsFlow,
|
||||
searchRelayList.assumedDefaultsFlow,
|
||||
indexerRelayList.assumedDefaultsFlow,
|
||||
) { nip65, search, indexer ->
|
||||
nip65 + search + indexer
|
||||
}.flowOn(Dispatchers.IO)
|
||||
.stateIn(
|
||||
scope,
|
||||
kotlinx.coroutines.flow.SharingStarted.Eagerly,
|
||||
nip65RelayList.assumedDefaultsFlow.value +
|
||||
searchRelayList.assumedDefaultsFlow.value +
|
||||
indexerRelayList.assumedDefaultsFlow.value,
|
||||
)
|
||||
}
|
||||
+114
-91
@@ -20,14 +20,17 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.torState
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
@@ -35,112 +38,132 @@ import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.transformLatest
|
||||
|
||||
/**
|
||||
* Pushes the relay classifications [TorRelayState] needs — which relays are DM, trusted, guessed, or
|
||||
* money-operation relays — as a union across every logged-in account.
|
||||
*
|
||||
* All four are the same fold: pick one set per account, union them, publish. It used to be written
|
||||
* out four times at ~30 lines each, and the copies had already drifted apart in trivial ways (an
|
||||
* `if (isEmpty)` guard that could never fire, differently-named accumulators). Sharing one
|
||||
* implementation is what keeps a fifth classification from being another 30 lines of the same
|
||||
* thing — and, more importantly, from being 30 lines that quietly forget a step.
|
||||
*/
|
||||
class AccountsTorStateConnector(
|
||||
accountsCache: AccountCacheState,
|
||||
torEvaluatorFlow: TorRelayState,
|
||||
scope: CoroutineScope,
|
||||
) {
|
||||
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
|
||||
val allDmRelayFlows: Flow<Set<NormalizedRelayUrl>> =
|
||||
accountsCache.accounts
|
||||
.debounce(200)
|
||||
.transformLatest { snapshot ->
|
||||
val dmFlows = snapshot.map { it.value.dmRelayList.flow }
|
||||
|
||||
val dmFlowReady =
|
||||
dmFlows.ifEmpty {
|
||||
listOf(MutableStateFlow(emptySet()))
|
||||
}
|
||||
|
||||
if (dmFlowReady.isEmpty()) {
|
||||
emit(emptySet())
|
||||
} else {
|
||||
emitAll(
|
||||
combine(dmFlowReady) {
|
||||
val dmRelays = mutableSetOf<NormalizedRelayUrl>()
|
||||
it.forEach {
|
||||
dmRelays.addAll(it)
|
||||
}
|
||||
dmRelays.toSet()
|
||||
},
|
||||
)
|
||||
}
|
||||
}.onEach {
|
||||
torEvaluatorFlow.dmRelays.tryEmit(it)
|
||||
}.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
emptySet(),
|
||||
)
|
||||
|
||||
/**
|
||||
* Union of one relay set across all logged-in accounts, republished into [TorRelayState].
|
||||
*
|
||||
* `debounce(200)` rides out the burst of account churn at login; `transformLatest` drops the
|
||||
* previous fan-in when the account set changes so a logged-out account cannot keep contributing.
|
||||
* The seed is `emptySet()` for every classification: before any account exists, nothing is
|
||||
* classified.
|
||||
*
|
||||
* Takes its collaborators as parameters rather than reading constructor properties because the
|
||||
* call sites are property initializers, where non-`val` constructor parameters are in scope but
|
||||
* member functions cannot see them.
|
||||
*/
|
||||
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
|
||||
val allTrustedRelaysFlow: Flow<Set<NormalizedRelayUrl>> =
|
||||
private fun unionAcrossAccounts(
|
||||
accountsCache: AccountCacheState,
|
||||
scope: CoroutineScope,
|
||||
select: (Account) -> Flow<Set<NormalizedRelayUrl>>,
|
||||
publish: (Set<NormalizedRelayUrl>) -> Unit,
|
||||
): StateFlow<Set<NormalizedRelayUrl>> =
|
||||
accountsCache.accounts
|
||||
.debounce(200)
|
||||
.transformLatest { snapshot ->
|
||||
val trustedRelayFlows = snapshot.map { it.value.trustedRelays.flow }
|
||||
|
||||
val trustedRelayFlowReady =
|
||||
trustedRelayFlows.ifEmpty {
|
||||
listOf(MutableStateFlow(emptySet()))
|
||||
}
|
||||
|
||||
if (trustedRelayFlowReady.isEmpty()) {
|
||||
emit(emptySet())
|
||||
} else {
|
||||
emitAll(
|
||||
combine(trustedRelayFlowReady) {
|
||||
val trustedRelays = mutableSetOf<NormalizedRelayUrl>()
|
||||
it.forEach {
|
||||
trustedRelays.addAll(it)
|
||||
}
|
||||
trustedRelays.toSet()
|
||||
},
|
||||
)
|
||||
}
|
||||
}.onEach {
|
||||
torEvaluatorFlow.trustedRelays.tryEmit(it)
|
||||
}.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
emptySet(),
|
||||
)
|
||||
|
||||
// Persistent money-operation relays across all accounts: NIP-47 wallet relays and saved CLINK
|
||||
// Debits service relays. Feeds TorRelayState.moneyOpRelays so these connections honor the
|
||||
// money-operations Tor preference instead of being classified as generic "new" relays.
|
||||
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
|
||||
val allMoneyOpRelaysFlow: Flow<Set<NormalizedRelayUrl>> =
|
||||
accountsCache.accounts
|
||||
.debounce(200)
|
||||
.transformLatest { snapshot ->
|
||||
val perAccountFlows =
|
||||
snapshot.map { (_, account) ->
|
||||
combine(
|
||||
account.settings.nwcWallets,
|
||||
account.settings.clinkDebitWallets,
|
||||
) { nwcWallets, clinkDebitWallets ->
|
||||
val relays = mutableSetOf<NormalizedRelayUrl>()
|
||||
nwcWallets.forEach { relays.add(it.uri.relayUri) }
|
||||
clinkDebitWallets.forEach { relays.addAll(it.pointer.relays) }
|
||||
relays.toSet()
|
||||
}
|
||||
}
|
||||
|
||||
val ready = perAccountFlows.ifEmpty { listOf(MutableStateFlow(emptySet())) }
|
||||
val perAccount =
|
||||
snapshot
|
||||
.map { select(it.value) }
|
||||
.ifEmpty { listOf(MutableStateFlow(emptySet())) }
|
||||
|
||||
emitAll(
|
||||
combine(ready) { perAccount ->
|
||||
val moneyOpRelays = mutableSetOf<NormalizedRelayUrl>()
|
||||
perAccount.forEach { moneyOpRelays.addAll(it) }
|
||||
moneyOpRelays.toSet()
|
||||
combine(perAccount) { sets ->
|
||||
sets.flatMapTo(mutableSetOf()) { it }
|
||||
},
|
||||
)
|
||||
}.onEach {
|
||||
torEvaluatorFlow.moneyOpRelays.tryEmit(it)
|
||||
}.stateIn(
|
||||
}.onEach(publish)
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
emptySet(),
|
||||
)
|
||||
|
||||
/** NIP-17 DM relays: these follow the dedicated DM preference, never the generic "new" one. */
|
||||
val allDmRelayFlows: StateFlow<Set<NormalizedRelayUrl>> =
|
||||
unionAcrossAccounts(
|
||||
accountsCache,
|
||||
scope,
|
||||
select = { it.dmRelayList.flow },
|
||||
publish = { torEvaluatorFlow.dmRelays.tryEmit(it) },
|
||||
)
|
||||
|
||||
/** Everything the user actually put in one of their own relay lists. */
|
||||
val allTrustedRelaysFlow: StateFlow<Set<NormalizedRelayUrl>> =
|
||||
unionAcrossAccounts(
|
||||
accountsCache,
|
||||
scope,
|
||||
select = { it.trustedRelays.flow },
|
||||
publish = { torEvaluatorFlow.trustedRelays.tryEmit(it) },
|
||||
)
|
||||
|
||||
/**
|
||||
* Relays the app is *guessing* while an account's own lists are unknown. Feeds
|
||||
* [TorRelayState.assumedRelays] and nothing else — see `AssumedRelayListsState` for why these
|
||||
* must never reach the AUTH decision.
|
||||
*
|
||||
* Per account, so a second login cannot re-open the guess for an established one; each
|
||||
* account's contribution empties itself as soon as that account's own lists land.
|
||||
*/
|
||||
val allAssumedRelaysFlow: StateFlow<Set<NormalizedRelayUrl>> =
|
||||
unionAcrossAccounts(
|
||||
accountsCache,
|
||||
scope,
|
||||
select = { it.assumedRelays.flow },
|
||||
publish = {
|
||||
logHandover(it)
|
||||
torEvaluatorFlow.assumedRelays.tryEmit(it)
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* Persistent money-operation relays: NIP-47 wallet relays and saved CLINK Debits service
|
||||
* relays, so these connections honor the money-operations preference rather than being
|
||||
* classified as generic "new" relays.
|
||||
*/
|
||||
val allMoneyOpRelaysFlow: StateFlow<Set<NormalizedRelayUrl>> =
|
||||
unionAcrossAccounts(
|
||||
accountsCache,
|
||||
scope,
|
||||
select = { account ->
|
||||
combine(
|
||||
account.settings.nwcWallets,
|
||||
account.settings.clinkDebitWallets,
|
||||
) { nwcWallets, clinkDebitWallets ->
|
||||
val relays = mutableSetOf<NormalizedRelayUrl>()
|
||||
nwcWallets.forEach { relays.add(it.uri.relayUri) }
|
||||
clinkDebitWallets.forEach { relays.addAll(it.pointer.relays) }
|
||||
relays.toSet()
|
||||
}
|
||||
},
|
||||
publish = { torEvaluatorFlow.moneyOpRelays.tryEmit(it) },
|
||||
)
|
||||
|
||||
@Volatile private var lastAssumedCount: Int = -1
|
||||
|
||||
/**
|
||||
* The handover is the whole contract of the guessed-relay feature: the moment a user's own
|
||||
* lists arrive, every relay we were guessing about goes back to the policy they actually asked
|
||||
* for. Logged at INFO because "did it hand over, and when" is not answerable from any other
|
||||
* line — the reconnect that follows looks identical to an ordinary one.
|
||||
*/
|
||||
private fun logHandover(relays: Set<NormalizedRelayUrl>) {
|
||||
if (relays.size == lastAssumedCount) return
|
||||
val released = if (relays.isEmpty()) " (own lists arrived; released to their real Tor policy)" else ""
|
||||
Log.i("AccountsTorState") { "Guessed relays: $lastAssumedCount -> ${relays.size}$released" }
|
||||
lastAssumedCount = relays.size
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,3 +22,5 @@ package com.vitorpamplona.amethyst.model.torState
|
||||
|
||||
// Canonical type now lives in commons
|
||||
typealias TorRelayEvaluation = com.vitorpamplona.amethyst.commons.tor.TorRelayEvaluation
|
||||
|
||||
typealias RelayClassification = com.vitorpamplona.amethyst.commons.tor.RelayClassification
|
||||
|
||||
@@ -46,6 +46,13 @@ class TorRelayState(
|
||||
val dmRelays = MutableStateFlow<Set<NormalizedRelayUrl>>(emptySet())
|
||||
val trustedRelays = MutableStateFlow<Set<NormalizedRelayUrl>>(emptySet())
|
||||
|
||||
/**
|
||||
* Relays guessed on the user's behalf while their own lists are unknown. Fed by
|
||||
* [AccountsTorStateConnector]; see `AssumedRelayListsState` for why this is separate from
|
||||
* [trustedRelays] rather than merged into it.
|
||||
*/
|
||||
val assumedRelays = MutableStateFlow<Set<NormalizedRelayUrl>>(emptySet())
|
||||
|
||||
/**
|
||||
* Relays known to be used for money operations from persistent configuration: NIP-47 wallet
|
||||
* relays and saved CLINK Debits service relays. Fed by [AccountsTorStateConnector] across all
|
||||
@@ -130,47 +137,49 @@ class TorRelayState(
|
||||
currentSettings(),
|
||||
)
|
||||
|
||||
val flow =
|
||||
combineTransform(
|
||||
torSettings,
|
||||
private fun currentClassification() =
|
||||
RelayClassification(
|
||||
trusted = trustedRelays.value,
|
||||
dm = dmRelays.value,
|
||||
moneyOp = currentMoneyOpRelays(),
|
||||
assumed = assumedRelays.value,
|
||||
)
|
||||
|
||||
/**
|
||||
* The four category sets as one value. Folding them here also keeps the evaluation flow below
|
||||
* at two sources instead of six — `combineTransform`'s typed overloads stop at five.
|
||||
*/
|
||||
private val classification =
|
||||
combine(
|
||||
trustedRelays,
|
||||
dmRelays,
|
||||
moneyOpRelays,
|
||||
adHocMoneyOpCounts,
|
||||
) {
|
||||
torSettings: TorRelaySettings,
|
||||
trustedRelayList: Set<NormalizedRelayUrl>,
|
||||
dmRelayList: Set<NormalizedRelayUrl>,
|
||||
moneyOpRelayList: Set<NormalizedRelayUrl>,
|
||||
adHocMoneyOps: Map<NormalizedRelayUrl, Int>,
|
||||
->
|
||||
emit(
|
||||
TorRelayEvaluation(
|
||||
torSettings = torSettings,
|
||||
trustedRelayList = trustedRelayList,
|
||||
dmRelayList = dmRelayList,
|
||||
moneyOpRelayList = moneyOpRelayList + adHocMoneyOps.keys,
|
||||
),
|
||||
assumedRelays,
|
||||
) { trusted, dm, moneyOp, adHocMoneyOps, assumed ->
|
||||
RelayClassification(
|
||||
trusted = trusted,
|
||||
dm = dm,
|
||||
moneyOp = moneyOp + adHocMoneyOps.keys,
|
||||
assumed = assumed,
|
||||
)
|
||||
}
|
||||
|
||||
val flow =
|
||||
combineTransform(
|
||||
torSettings,
|
||||
classification,
|
||||
) { torSettings: TorRelaySettings, classification: RelayClassification ->
|
||||
emit(TorRelayEvaluation(torSettings, classification))
|
||||
}.onStart {
|
||||
emit(
|
||||
TorRelayEvaluation(
|
||||
torSettings = torSettings.value,
|
||||
trustedRelayList = trustedRelays.value,
|
||||
dmRelayList = dmRelays.value,
|
||||
moneyOpRelayList = currentMoneyOpRelays(),
|
||||
),
|
||||
TorRelayEvaluation(torSettings.value, currentClassification()),
|
||||
)
|
||||
}.flowOn(Dispatchers.IO)
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
TorRelayEvaluation(
|
||||
torSettings = torSettings.value,
|
||||
trustedRelayList = trustedRelays.value,
|
||||
dmRelayList = dmRelays.value,
|
||||
moneyOpRelayList = currentMoneyOpRelays(),
|
||||
),
|
||||
TorRelayEvaluation(torSettings.value, currentClassification()),
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -178,13 +187,7 @@ class TorRelayState(
|
||||
* snapshot. This makes ad-hoc money-op registration ([registerMoneyOpRelays]) take effect on the
|
||||
* very next connection attempt, with no dependency on the combine pipeline having propagated yet.
|
||||
*/
|
||||
fun shouldUseTorForRelay(relay: NormalizedRelayUrl) =
|
||||
TorRelayEvaluation(
|
||||
torSettings = currentSettings(),
|
||||
trustedRelayList = trustedRelays.value,
|
||||
dmRelayList = dmRelays.value,
|
||||
moneyOpRelayList = currentMoneyOpRelays(),
|
||||
).useTor(relay)
|
||||
fun shouldUseTorForRelay(relay: NormalizedRelayUrl) = TorRelayEvaluation(currentSettings(), currentClassification()).useTor(relay)
|
||||
|
||||
fun okHttpClientForRelay(url: NormalizedRelayUrl): OkHttpClient = okHttpClient.getHttpClient(shouldUseTorForRelay(url))
|
||||
}
|
||||
|
||||
@@ -31,7 +31,6 @@ import android.os.Message
|
||||
import android.os.Messenger
|
||||
import android.os.RemoteException
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import androidx.core.net.toUri
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionLedger
|
||||
@@ -50,6 +49,7 @@ import com.vitorpamplona.amethyst.napplet.gateways.AccountNappletGateways
|
||||
import com.vitorpamplona.amethyst.napplethost.NappletIpc
|
||||
import com.vitorpamplona.amethyst.ui.MainActivity
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -182,7 +182,7 @@ class NappletBrokerService : Service() {
|
||||
// could still spam distinct keys to keep the network up. Bound the damage: refuse new
|
||||
// lease keys past the cap. Real usage holds only a handful of foreground surfaces.
|
||||
if (firstReport && foregroundLeases.size >= MAX_FOREGROUND_LEASES) {
|
||||
Log.w("NappletBrokerService", "Foreground lease cap reached; ignoring new lease $token")
|
||||
Log.w("NappletBrokerService") { "Foreground lease cap reached; ignoring new lease $token" }
|
||||
return true
|
||||
}
|
||||
foregroundLeases[token] = SystemClock.elapsedRealtime()
|
||||
@@ -374,7 +374,7 @@ class NappletBrokerService : Service() {
|
||||
while (iterator.hasNext()) {
|
||||
val entry = iterator.next()
|
||||
if (now - entry.value > FOREGROUND_LEASE_TTL_MS) {
|
||||
Log.w("NappletBrokerService", "Foreground lease ${entry.key} expired (host process gone?); releasing hold")
|
||||
Log.w("NappletBrokerService") { "Foreground lease ${entry.key} expired (host process gone?); releasing hold" }
|
||||
iterator.remove()
|
||||
SandboxForegroundHold.release()
|
||||
}
|
||||
|
||||
@@ -117,7 +117,7 @@ object SandboxForegroundHold {
|
||||
synchronized(this@SandboxForegroundHold) {
|
||||
// A surface may have re-acquired while we waited; only tear down if still released.
|
||||
if (holdCount == 0) {
|
||||
Log.d("SandboxForegroundHold", "No foreground sandbox surface for ${LINGER_MS}ms; releasing the resource hold")
|
||||
Log.d("SandboxForegroundHold") { "No foreground sandbox surface for ${LINGER_MS}ms; releasing the resource hold" }
|
||||
holdJob?.cancel()
|
||||
holdJob = null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* 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 com.vitorpamplona.amethyst.napplethost.WebFileChooserLauncher
|
||||
|
||||
/**
|
||||
* Invisible main-process host for one file pick made on behalf of an **embedded** WebView surface.
|
||||
*
|
||||
* The surface renders from the keyless `:napplet` process, which has no Activity to start a picker or
|
||||
* a permission prompt from, so [WebFileChooserCoordinator] launches this instead. It exists only long
|
||||
* enough to run the pick and report the result, and it reports on every exit — a chosen file, a
|
||||
* cancel, a system teardown — because the page's `<input type="file">` stays busy until it hears
|
||||
* something back.
|
||||
*/
|
||||
class WebFileChooserActivity : ComponentActivity() {
|
||||
private var token: String? = null
|
||||
private var reported = false
|
||||
|
||||
// Field, not a local: registerForActivityResult must run before this activity reaches STARTED.
|
||||
private val chooser =
|
||||
WebFileChooserLauncher(this) { uris ->
|
||||
report(uris?.map { it.toString() }?.toTypedArray())
|
||||
finish()
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val token = intent.getStringExtra(WebFileChooserCoordinator.EXTRA_TOKEN)
|
||||
this.token = token
|
||||
val ask = token?.let { WebFileChooserCoordinator.pendingFor(it) }
|
||||
if (ask == null) {
|
||||
// No pending request under this token: the surface went away, or the process restarted and
|
||||
// the request died with it. Nothing to report to.
|
||||
reported = true
|
||||
finish()
|
||||
return
|
||||
}
|
||||
|
||||
// A recreated instance has lost the in-flight request that its result would be matched against
|
||||
// (configChanges keeps this rare — a system kill, not a rotation), and relaunching would stack a
|
||||
// second picker on the first. Release the page's input now rather than let it wait on a result
|
||||
// that can no longer be routed anywhere.
|
||||
if (savedInstanceState != null) {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
|
||||
chooser.launch(
|
||||
acceptTypes = ask.acceptTypes,
|
||||
allowMultiple = ask.allowMultiple,
|
||||
captureEnabled = ask.captureEnabled,
|
||||
pageTitle = ask.pageTitle,
|
||||
)
|
||||
}
|
||||
|
||||
/** Fail-open toward the page: any unreported teardown still releases its file input. */
|
||||
override fun finish() {
|
||||
report(null)
|
||||
super.finish()
|
||||
}
|
||||
|
||||
/**
|
||||
* The system can destroy this host without ever calling [finish] — a low-memory kill while the
|
||||
* picker is on top. Without this the page's file input would wait on a result nobody is left to
|
||||
* send, dead for the life of the page, and the coordinator would hold the reply callback (and the
|
||||
* controller behind it) forever.
|
||||
*/
|
||||
override fun onDestroy() {
|
||||
chooser.teardown()
|
||||
report(null)
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun report(uris: Array<String>?) {
|
||||
if (reported) return
|
||||
reported = true
|
||||
token?.let { WebFileChooserCoordinator.complete(it, uris) }
|
||||
}
|
||||
}
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* 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.quartz.utils.Log
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* Runs the system file picker on behalf of an **embedded** WebView surface.
|
||||
*
|
||||
* The two embedded providers ([NappletBrowserService][com.vitorpamplona.amethyst.napplethost.NappletBrowserService]
|
||||
* and [NappletHostService][com.vitorpamplona.amethyst.napplethost.NappletHostService]) host their
|
||||
* WebView in the keyless `:napplet` process as a windowless Service, so when a page taps
|
||||
* `<input type="file">` there is no Activity there to start a picker from. They send the *description*
|
||||
* of the request over Messenger instead; this holds it here in the main process and launches
|
||||
* [WebFileChooserActivity], which runs the picker (and the camera, and the CAMERA permission prompt
|
||||
* that a `capture` input needs) and reports back to the caller, which relays the URIs to the sandbox.
|
||||
*
|
||||
* Mirrors [NappletConsentCoordinator]: the pending request is keyed by a one-time token so the
|
||||
* throwaway Activity carries nothing but that token. Every request completes exactly once — a
|
||||
* dismissed picker resolves to null, which is what releases the page's file input.
|
||||
*
|
||||
* URI read grants are per-UID, so the `content://` URIs granted to this process are readable by the
|
||||
* WebView in `:napplet` without any re-granting.
|
||||
*/
|
||||
object WebFileChooserCoordinator {
|
||||
/** The request as it arrived from the sandbox, plus where to send the answer. */
|
||||
class Pending(
|
||||
val acceptTypes: List<String>,
|
||||
val allowMultiple: Boolean,
|
||||
val captureEnabled: Boolean,
|
||||
val pageTitle: String?,
|
||||
val onResult: (Array<String>?) -> Unit,
|
||||
)
|
||||
|
||||
private val pending = ConcurrentHashMap<String, Pending>()
|
||||
|
||||
/**
|
||||
* Shows a picker for [acceptTypes] and calls [onResult] with the picked URIs as strings, or null
|
||||
* when nothing was chosen. [onResult] always runs, including when no picker host could be started
|
||||
* at all — the page's file input is waiting on it.
|
||||
*/
|
||||
fun request(
|
||||
context: Context,
|
||||
acceptTypes: List<String>,
|
||||
allowMultiple: Boolean,
|
||||
captureEnabled: Boolean,
|
||||
pageTitle: String?,
|
||||
onResult: (Array<String>?) -> Unit,
|
||||
) {
|
||||
val token = UUID.randomUUID().toString()
|
||||
pending[token] = Pending(acceptTypes, allowMultiple, captureEnabled, pageTitle, onResult)
|
||||
|
||||
val launch =
|
||||
Intent(context, WebFileChooserActivity::class.java)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
.putExtra(EXTRA_TOKEN, token)
|
||||
|
||||
runCatching { context.startActivity(launch) }
|
||||
.onFailure { e ->
|
||||
Log.w(TAG, "Could not start the file chooser host", e)
|
||||
complete(token, null)
|
||||
}
|
||||
}
|
||||
|
||||
/** Called by [WebFileChooserActivity] to learn what to ask the user for. */
|
||||
fun pendingFor(token: String): Pending? = pending[token]
|
||||
|
||||
/** Called by [WebFileChooserActivity] with the outcome; null = nothing chosen. Resolves at most once. */
|
||||
fun complete(
|
||||
token: String,
|
||||
uris: Array<String>?,
|
||||
) {
|
||||
pending.remove(token)?.onResult?.invoke(uris)
|
||||
}
|
||||
|
||||
const val EXTRA_TOKEN = "web_file_chooser_token"
|
||||
|
||||
private const val TAG = "WebFileChooser"
|
||||
}
|
||||
+15
-15
@@ -46,7 +46,6 @@ import okhttp3.Authenticator
|
||||
import okhttp3.Call
|
||||
import okhttp3.Callback
|
||||
import okhttp3.CookieJar
|
||||
import okhttp3.Dns
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
@@ -54,6 +53,7 @@ import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.io.InterruptedIOException
|
||||
import java.net.InetAddress
|
||||
import java.net.URLDecoder
|
||||
@@ -112,15 +112,13 @@ class NappletResourceFetcher(
|
||||
.authenticator(Authenticator.NONE)
|
||||
.proxyAuthenticator(Authenticator.NONE)
|
||||
.callTimeout(FETCH_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
.dns(
|
||||
Dns { hostname ->
|
||||
baseClient.dns.lookup(hostname).also { addresses ->
|
||||
if (addresses.isEmpty() || !addresses.all(::isPublicAddress)) {
|
||||
throw BlockedResourceException("Resolved address is not public.")
|
||||
}
|
||||
.dns { hostname ->
|
||||
baseClient.dns.lookup(hostname).also { addresses ->
|
||||
if (addresses.isEmpty() || !addresses.all(::isPublicAddress)) {
|
||||
throw BlockedResourceException("Resolved address is not public.")
|
||||
}
|
||||
},
|
||||
).addNetworkInterceptor { chain ->
|
||||
}
|
||||
}.addNetworkInterceptor { chain ->
|
||||
chain.proceed(
|
||||
chain
|
||||
.request()
|
||||
@@ -193,7 +191,7 @@ class NappletResourceFetcher(
|
||||
is NProfile -> resolveReplaceable(0, entity.hex)
|
||||
else -> null
|
||||
} ?: return null
|
||||
return NappletResource(event.toJson().encodeToByteArray(), "application/json")
|
||||
return NappletResource(event.toJson().encodeToByteArray(), MIME_JSON)
|
||||
}
|
||||
|
||||
/** A non-replaceable event by id: local cache first, then a bounded relay fetch. */
|
||||
@@ -300,7 +298,7 @@ class NappletResourceFetcher(
|
||||
meta
|
||||
.removeSuffix(";base64")
|
||||
.substringBefore(';')
|
||||
.ifEmpty { "text/plain" }
|
||||
.ifEmpty { MIME_PLAIN_TEXT }
|
||||
.lowercase()
|
||||
val bytes =
|
||||
if (isBase64) {
|
||||
@@ -323,8 +321,8 @@ class NappletResourceFetcher(
|
||||
val type =
|
||||
when {
|
||||
sniffed in ALLOWED_SNIFFED_TYPES -> sniffed
|
||||
declaredType == "application/json" && isJson(bytes) -> "application/json"
|
||||
declaredType == "text/plain" && isPlainText(bytes) -> "text/plain"
|
||||
declaredType == MIME_JSON && isJson(bytes) -> MIME_JSON
|
||||
declaredType == MIME_PLAIN_TEXT && isPlainText(bytes) -> MIME_PLAIN_TEXT
|
||||
else -> null
|
||||
} ?: return failure(ERROR_DECODE_FAILED, "Resource MIME is not in the runtime allowlist.")
|
||||
return success(NappletResource(bytes, type))
|
||||
@@ -353,7 +351,7 @@ class NappletResourceFetcher(
|
||||
message: String? = null,
|
||||
): NappletResourceResult = NappletResourceResult.Failure(error, message)
|
||||
|
||||
private fun readBounded(input: java.io.InputStream): ByteArray? {
|
||||
private fun readBounded(input: InputStream): ByteArray? {
|
||||
input.use { source ->
|
||||
val output = ByteArrayOutputStream()
|
||||
val buffer = ByteArray(8 * 1024)
|
||||
@@ -417,6 +415,8 @@ class NappletResourceFetcher(
|
||||
private const val ERROR_UNSUPPORTED_SCHEME = "unsupported-scheme"
|
||||
private const val ERROR_DECODE_FAILED = "decode-failed"
|
||||
private const val ERROR_NETWORK = "network-error"
|
||||
private const val MIME_JSON = "application/json"
|
||||
private const val MIME_PLAIN_TEXT = "text/plain"
|
||||
private val SHA256 = Regex("^[0-9a-f]{64}$")
|
||||
private val ALLOWED_SNIFFED_TYPES =
|
||||
setOf(
|
||||
@@ -432,5 +432,5 @@ class NappletResourceFetcher(
|
||||
|
||||
private class BlockedResourceException(
|
||||
message: String,
|
||||
) : java.io.IOException(message)
|
||||
) : IOException(message)
|
||||
}
|
||||
|
||||
@@ -26,9 +26,10 @@ import com.vitorpamplona.amethyst.commons.model.payments.PaymentSource
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
|
||||
import com.vitorpamplona.amethyst.ui.nwc.nwcFailureDetail
|
||||
import com.vitorpamplona.amethyst.ui.nwc.nwcTimeoutMessage
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.IErrorResponseLike
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendMethod
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.TlvRecord
|
||||
@@ -159,15 +160,17 @@ class V4VPaymentHandler(
|
||||
tlvRecords = tlvRecords,
|
||||
)
|
||||
|
||||
account.zaps.sendNwcRequest(request) { response: Response? ->
|
||||
if (response is IErrorResponseLike) {
|
||||
onError(
|
||||
stringRes(context, R.string.error_dialog_pay_invoice_error),
|
||||
response.errorMessage()
|
||||
?: stringRes(context, R.string.error_parsing_error_message),
|
||||
)
|
||||
}
|
||||
}
|
||||
account.zaps.sendNwcRequest(
|
||||
request = request,
|
||||
onResponse = { response: Response? ->
|
||||
response.nwcFailureDetail(context)?.let { detail ->
|
||||
onError(stringRes(context, R.string.error_dialog_pay_invoice_error), detail)
|
||||
}
|
||||
},
|
||||
onTimeout = {
|
||||
onError(stringRes(context, R.string.error_dialog_pay_invoice_error), nwcTimeoutMessage(context))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,15 +253,18 @@ class V4VPaymentHandler(
|
||||
is PaymentSource.Nwc -> {
|
||||
var done = 0
|
||||
payables.forEach { payable ->
|
||||
account.zaps.sendZapPaymentRequestFor(payable.invoice, zappedNote) { response ->
|
||||
if (response is IErrorResponseLike) {
|
||||
onError(
|
||||
stringRes(context, R.string.error_dialog_pay_invoice_error),
|
||||
response.errorMessage()
|
||||
?: stringRes(context, R.string.error_parsing_error_message),
|
||||
)
|
||||
}
|
||||
}
|
||||
account.zaps.sendZapPaymentRequestFor(
|
||||
bolt11 = payable.invoice,
|
||||
zappedNote = zappedNote,
|
||||
onResponse = { response ->
|
||||
response.nwcFailureDetail(context)?.let { detail ->
|
||||
onError(stringRes(context, R.string.error_dialog_pay_invoice_error), detail)
|
||||
}
|
||||
},
|
||||
onTimeout = {
|
||||
onError(stringRes(context, R.string.error_dialog_pay_invoice_error), nwcTimeoutMessage(context))
|
||||
},
|
||||
)
|
||||
done++
|
||||
onProgress(done.toFloat() / payables.size)
|
||||
}
|
||||
|
||||
@@ -29,10 +29,12 @@ import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
|
||||
import com.vitorpamplona.amethyst.ui.nwc.nwcFailureDetail
|
||||
import com.vitorpamplona.amethyst.ui.nwc.nwcTimeoutMessage
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.experimental.clink.pointers.NDebit
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceErrorResponse
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcTransactionMetadata
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
|
||||
@@ -60,6 +62,11 @@ class ZapPaymentHandler(
|
||||
val info: MyZapSplitSetup,
|
||||
val amountMilliSats: Long,
|
||||
val invoice: String,
|
||||
// The signed kind 9734 this invoice was fetched with, and the message on it.
|
||||
// Carried so the NWC payment can name the payee (NWC-06 `metadata`); null for
|
||||
// a NONZAP split, which has no zap request to send.
|
||||
val zapRequest: LnZapRequestEvent? = null,
|
||||
val message: String = "",
|
||||
)
|
||||
|
||||
data class UnverifiedZapSplitSetup(
|
||||
@@ -417,21 +424,30 @@ class ZapPaymentHandler(
|
||||
account.zaps.sendZapPaymentRequestFor(
|
||||
bolt11 = payable.invoice,
|
||||
zappedNote = note,
|
||||
// Dropped unless the wallet advertises NWC-06 — see NwcSignerState.
|
||||
metadata =
|
||||
NwcTransactionMetadata.build(
|
||||
zapRequest = payable.zapRequest,
|
||||
recipientIdentifier = payable.info.lnAddress,
|
||||
comment = payable.message,
|
||||
),
|
||||
onResponse = { response ->
|
||||
progress.step()
|
||||
if (response is PayInvoiceErrorResponse) {
|
||||
response.nwcFailureDetail(context)?.let { detail ->
|
||||
onError(
|
||||
stringRes(context, R.string.error_dialog_pay_invoice_error),
|
||||
stringRes(
|
||||
context,
|
||||
R.string.wallet_connect_pay_invoice_error_error,
|
||||
response.error?.message
|
||||
?: response.error?.code?.toString() ?: "Error parsing error message",
|
||||
),
|
||||
stringRes(context, R.string.wallet_connect_pay_invoice_error_error, detail),
|
||||
payable.info.user,
|
||||
)
|
||||
}
|
||||
},
|
||||
onTimeout = {
|
||||
onError(
|
||||
stringRes(context, R.string.error_dialog_pay_invoice_error),
|
||||
nwcTimeoutMessage(context),
|
||||
payable.info.user,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
progress.step()
|
||||
@@ -551,6 +567,10 @@ class ZapPaymentHandler(
|
||||
): Payable {
|
||||
var progressThisPayment = 0.00f
|
||||
|
||||
// Only the request the provider actually accepted may be claimed as bound to
|
||||
// this invoice; see lnAddressInvoice's onZapRequestSent.
|
||||
var sentZapRequest: LnZapRequestEvent? = null
|
||||
|
||||
val invoice =
|
||||
LightningAddressResolver().lnAddressInvoice(
|
||||
lnAddress = lud16,
|
||||
@@ -564,6 +584,7 @@ class ZapPaymentHandler(
|
||||
onProgressStep(step)
|
||||
},
|
||||
context = context,
|
||||
onZapRequestSent = { sentZapRequest = it },
|
||||
)
|
||||
|
||||
onProgressStep(1 - progressThisPayment)
|
||||
@@ -572,6 +593,8 @@ class ZapPaymentHandler(
|
||||
info = splitSetup,
|
||||
amountMilliSats = zapValue,
|
||||
invoice = invoice,
|
||||
zapRequest = sentZapRequest,
|
||||
message = message,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.ai
|
||||
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
/**
|
||||
* TODO: Remove before shipping. Debug-only mock for testing the AI writing help UI
|
||||
* on devices without Gemini Nano / AICore support.
|
||||
*/
|
||||
class MockWritingAssistant : WritingAssistant {
|
||||
override suspend fun checkAvailability(): WritingAssistantStatus = WritingAssistantStatus.Available
|
||||
|
||||
override suspend fun transform(
|
||||
text: String,
|
||||
tone: WritingTone,
|
||||
): WritingResult {
|
||||
delay(800)
|
||||
|
||||
val transformed =
|
||||
when (tone) {
|
||||
WritingTone.CORRECT -> {
|
||||
correctMock(text)
|
||||
}
|
||||
|
||||
WritingTone.REPHRASE -> {
|
||||
"Here's another way to put it: $text"
|
||||
}
|
||||
|
||||
WritingTone.SHORTER -> {
|
||||
text
|
||||
.split(".")
|
||||
.firstOrNull()
|
||||
?.trim()
|
||||
?.plus(".") ?: text
|
||||
}
|
||||
|
||||
WritingTone.ELABORATE -> {
|
||||
"$text Furthermore, this point deserves deeper consideration and nuance."
|
||||
}
|
||||
|
||||
WritingTone.FRIENDLY -> {
|
||||
"Hey! $text Hope that makes sense! :)"
|
||||
}
|
||||
|
||||
WritingTone.PROFESSIONAL -> {
|
||||
"I would like to bring to your attention the following: $text"
|
||||
}
|
||||
|
||||
WritingTone.MORE_DIRECT -> {
|
||||
text.replace("I think ", "").replace("maybe ", "").replace("perhaps ", "")
|
||||
}
|
||||
|
||||
WritingTone.PUNCHY -> {
|
||||
text.uppercase().replace(".", "!")
|
||||
}
|
||||
|
||||
WritingTone.EMOJIFY -> {
|
||||
"$text \uD83D\uDE80\uD83D\uDD25\u2728"
|
||||
}
|
||||
}
|
||||
|
||||
return WritingResult(
|
||||
originalText = text,
|
||||
transformedText = transformed,
|
||||
tone = tone,
|
||||
)
|
||||
}
|
||||
|
||||
private fun correctMock(text: String): String =
|
||||
text
|
||||
.replace("teh ", "the ")
|
||||
.replace("dont ", "don't ")
|
||||
.replace("cant ", "can't ")
|
||||
.replace("wont ", "won't ")
|
||||
.replace("i ", "I ")
|
||||
|
||||
override fun close() {
|
||||
// no-op: mock holds no native handles or background workers to release.
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,15 @@ import androidx.compose.runtime.Immutable
|
||||
interface WritingAssistant {
|
||||
suspend fun checkAvailability(): WritingAssistantStatus
|
||||
|
||||
/**
|
||||
* Asks the platform to fetch the on-device model when [checkAvailability] reported
|
||||
* [WritingAssistantStatus.Downloadable]. Returns the status after the attempt.
|
||||
*
|
||||
* Implementations must be safe to call repeatedly: only the first call per instance
|
||||
* starts a download, later ones just report the current status.
|
||||
*/
|
||||
suspend fun requestDownload(): WritingAssistantStatus
|
||||
|
||||
suspend fun transform(
|
||||
text: String,
|
||||
tone: WritingTone,
|
||||
@@ -40,8 +49,6 @@ enum class WritingTone {
|
||||
ELABORATE,
|
||||
FRIENDLY,
|
||||
PROFESSIONAL,
|
||||
MORE_DIRECT,
|
||||
PUNCHY,
|
||||
EMOJIFY,
|
||||
}
|
||||
|
||||
@@ -50,6 +57,9 @@ sealed class WritingAssistantStatus {
|
||||
|
||||
data object Unavailable : WritingAssistantStatus()
|
||||
|
||||
/** The device supports the model but it has not been fetched yet. */
|
||||
data object Downloadable : WritingAssistantStatus()
|
||||
|
||||
data object Downloading : WritingAssistantStatus()
|
||||
}
|
||||
|
||||
|
||||
+27
-1
@@ -53,6 +53,7 @@ class CallForegroundService : Service() {
|
||||
const val ACTION_UPDATE = "com.vitorpamplona.amethyst.CALL_UPDATE"
|
||||
const val EXTRA_PEER_NAME = "peer_name"
|
||||
const val EXTRA_IS_VIDEO = "is_video"
|
||||
const val EXTRA_IS_SCREEN_SHARING = "is_screen_sharing"
|
||||
const val EXTRA_STATUS_TEXT = "status_text"
|
||||
const val EXTRA_IS_RINGING = "is_ringing"
|
||||
private const val HANGUP_REQUEST_CODE = 0x70001
|
||||
@@ -75,6 +76,7 @@ class CallForegroundService : Service() {
|
||||
ACTION_START, ACTION_UPDATE -> {
|
||||
val peerName = intent.getStringExtra(EXTRA_PEER_NAME) ?: "Unknown"
|
||||
val isVideo = intent.getBooleanExtra(EXTRA_IS_VIDEO, false)
|
||||
val isScreenSharing = intent.getBooleanExtra(EXTRA_IS_SCREEN_SHARING, false)
|
||||
val isRinging = intent.getBooleanExtra(EXTRA_IS_RINGING, false)
|
||||
val statusText = intent.getStringExtra(EXTRA_STATUS_TEXT)
|
||||
val notification = buildNotification(peerName, statusText)
|
||||
@@ -97,6 +99,9 @@ class CallForegroundService : Service() {
|
||||
if (isVideo && hasCameraPermission) {
|
||||
type = type or ServiceInfo.FOREGROUND_SERVICE_TYPE_CAMERA
|
||||
}
|
||||
if (isScreenSharing) {
|
||||
type = type or ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION
|
||||
}
|
||||
}
|
||||
type
|
||||
} else {
|
||||
@@ -108,7 +113,11 @@ class CallForegroundService : Service() {
|
||||
try {
|
||||
val fallbackType =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL
|
||||
var type = ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL
|
||||
if (isScreenSharing) {
|
||||
type = type or ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION
|
||||
}
|
||||
type
|
||||
} else {
|
||||
0
|
||||
}
|
||||
@@ -142,6 +151,23 @@ class CallForegroundService : Service() {
|
||||
* it at 3 seconds as a safety net.
|
||||
*/
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
val removed = rootIntent?.component?.className
|
||||
Log.d(TAG) { "onTaskRemoved root=$removed" }
|
||||
|
||||
// Only the call's own task going away means the user dismissed the call. This callback
|
||||
// fires for *every* task of the app, and MainActivity lives in a separate one (it is
|
||||
// `singleInstance`, and CallActivity is launched with FLAG_ACTIVITY_NEW_TASK). Android
|
||||
// reclaims that backgrounded MainActivity task on its own while a call is running —
|
||||
// notably a few hundred ms after CallActivity enters picture-in-picture on HOME — and
|
||||
// hanging up on it ended calls the user never touched.
|
||||
//
|
||||
// A null root intent leaves the source unknown; treat it as a dismissal so a genuinely
|
||||
// swiped-away app can't leave a call running with no UI to end it.
|
||||
if (removed != null && removed != CallActivity::class.java.name) {
|
||||
super.onTaskRemoved(rootIntent)
|
||||
return
|
||||
}
|
||||
|
||||
publishHangupBlocking()
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
package com.vitorpamplona.amethyst.service.call
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.media.projection.MediaProjection
|
||||
import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -65,9 +67,20 @@ class CallMediaManager(
|
||||
var localVideoTrack: VideoTrack? = null
|
||||
private set
|
||||
|
||||
var screenVideoSource: VideoSource? = null
|
||||
private set
|
||||
var screenVideoTrack: VideoTrack? = null
|
||||
private set
|
||||
|
||||
private var cameraCapturer: CameraVideoCapturer? = null
|
||||
private var surfaceTextureHelper: SurfaceTextureHelper? = null
|
||||
private var screenCapturer: ScreenShareCapturer? = null
|
||||
private var screenSurfaceTextureHelper: SurfaceTextureHelper? = null
|
||||
private var usingFrontCamera: Boolean = true
|
||||
private var cameraWasEnabledBeforeScreenShare = false
|
||||
private var stoppingScreenShare = false
|
||||
|
||||
var onScreenShareEnded: (() -> Unit)? = null
|
||||
|
||||
private val _localVideoTrackFlow = MutableStateFlow<VideoTrack?>(null)
|
||||
val localVideoTrackFlow: StateFlow<VideoTrack?> = _localVideoTrackFlow.asStateFlow()
|
||||
@@ -75,6 +88,9 @@ class CallMediaManager(
|
||||
private val _isVideoEnabled = MutableStateFlow(false)
|
||||
val isVideoEnabled: StateFlow<Boolean> = _isVideoEnabled.asStateFlow()
|
||||
|
||||
private val _isScreenSharing = MutableStateFlow(false)
|
||||
val isScreenSharing: StateFlow<Boolean> = _isScreenSharing.asStateFlow()
|
||||
|
||||
private val _isFrontCamera = MutableStateFlow(true)
|
||||
val isFrontCamera: StateFlow<Boolean> = _isFrontCamera.asStateFlow()
|
||||
|
||||
@@ -146,6 +162,136 @@ class CallMediaManager(
|
||||
captureFps = fps
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts Android's system screen capture using the one-shot permission result supplied by
|
||||
* [android.media.projection.MediaProjectionManager]. The returned track is owned by this
|
||||
* manager and must be attached to the peer senders before [stopScreenShare] releases it.
|
||||
*/
|
||||
@Synchronized
|
||||
fun startScreenShare(permissionData: Intent): VideoTrack {
|
||||
screenVideoTrack?.let { return it }
|
||||
|
||||
val factory = peerConnectionFactory ?: throw IllegalStateException("PeerConnectionFactory not initialized")
|
||||
val egl = sharedEglBase ?: throw IllegalStateException("EGL context not initialized")
|
||||
val displayMetrics = context.resources.displayMetrics
|
||||
val captureSize = screenShareCaptureSize(displayMetrics.widthPixels, displayMetrics.heightPixels)
|
||||
cameraWasEnabledBeforeScreenShare = _isVideoEnabled.value
|
||||
var source: VideoSource? = null
|
||||
var track: VideoTrack? = null
|
||||
var helper: SurfaceTextureHelper? = null
|
||||
var capturer: ScreenShareCapturer? = null
|
||||
|
||||
try {
|
||||
val createdSource = factory.createVideoSource(true)
|
||||
source = createdSource
|
||||
val createdTrack = factory.createVideoTrack("screen0", createdSource)
|
||||
track = createdTrack
|
||||
val createdHelper = SurfaceTextureHelper.create("ScreenCaptureThread", egl.eglBaseContext)
|
||||
helper = createdHelper
|
||||
val createdCapturer =
|
||||
ScreenShareCapturer(
|
||||
permissionData,
|
||||
object : MediaProjection.Callback() {
|
||||
override fun onStop() {
|
||||
if (!stoppingScreenShare) {
|
||||
onScreenShareEnded?.invoke()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
capturer = createdCapturer
|
||||
|
||||
screenVideoSource = createdSource
|
||||
screenVideoTrack = createdTrack
|
||||
screenSurfaceTextureHelper = createdHelper
|
||||
screenCapturer = createdCapturer
|
||||
if (cameraWasEnabledBeforeScreenShare) {
|
||||
stopCamera()
|
||||
}
|
||||
createdCapturer.initialize(createdHelper, context, createdSource.capturerObserver)
|
||||
createdCapturer.startCapture(captureSize.width, captureSize.height, captureFps)
|
||||
_isScreenSharing.value = true
|
||||
_isVideoEnabled.value = true
|
||||
_localVideoTrackFlow.value = createdTrack
|
||||
return createdTrack
|
||||
} catch (e: Exception) {
|
||||
screenCapturer = null
|
||||
screenSurfaceTextureHelper = null
|
||||
screenVideoTrack = null
|
||||
screenVideoSource = null
|
||||
runCatching { capturer?.dispose() }
|
||||
runCatching { helper?.dispose() }
|
||||
runCatching { track?.dispose() }
|
||||
runCatching { source?.dispose() }
|
||||
if (cameraWasEnabledBeforeScreenShare) {
|
||||
_isVideoEnabled.value = true
|
||||
_localVideoTrackFlow.value = localVideoTrack
|
||||
startCamera()
|
||||
}
|
||||
cameraWasEnabledBeforeScreenShare = false
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops screen capture and restores the camera state from before sharing started. The
|
||||
* returned resources remain alive until the caller has replaced every [RtpSender] track.
|
||||
*
|
||||
* Pass `restoreCamera = false` when the whole session is going away — see [dispose]. Restoring
|
||||
* a camera that is about to be torn down opens the device for a few hundred milliseconds
|
||||
* (lighting the privacy indicator) and makes the following [stopCamera] block waiting for the
|
||||
* capture session it just started.
|
||||
*/
|
||||
@Synchronized
|
||||
fun stopScreenShare(restoreCamera: Boolean = true): ScreenShareResources? {
|
||||
val track = screenVideoTrack ?: return null
|
||||
val source = requireNotNull(screenVideoSource)
|
||||
stoppingScreenShare = true
|
||||
try {
|
||||
screenCapturer?.let { capturer ->
|
||||
runCatching { capturer.stopCapture() }
|
||||
runCatching { capturer.dispose() }
|
||||
}
|
||||
screenSurfaceTextureHelper?.let { runCatching { it.dispose() } }
|
||||
} finally {
|
||||
screenCapturer = null
|
||||
screenSurfaceTextureHelper = null
|
||||
screenVideoTrack = null
|
||||
screenVideoSource = null
|
||||
_isScreenSharing.value = false
|
||||
|
||||
if (restoreCamera && cameraWasEnabledBeforeScreenShare) {
|
||||
recreateCameraResources()
|
||||
_isVideoEnabled.value = true
|
||||
_localVideoTrackFlow.value = localVideoTrack
|
||||
startCamera()
|
||||
} else {
|
||||
_isVideoEnabled.value = false
|
||||
_localVideoTrackFlow.value = localVideoTrack
|
||||
}
|
||||
cameraWasEnabledBeforeScreenShare = false
|
||||
stoppingScreenShare = false
|
||||
}
|
||||
return ScreenShareResources(track, source)
|
||||
}
|
||||
|
||||
fun disposeScreenShareResources(resources: ScreenShareResources?) {
|
||||
resources ?: return
|
||||
runCatching { resources.track.dispose() }
|
||||
runCatching { resources.source.dispose() }
|
||||
}
|
||||
|
||||
private fun recreateCameraResources() {
|
||||
val factory = peerConnectionFactory ?: return
|
||||
// RtpSender.setTrack may release the previous native track wrapper, so restore a fresh
|
||||
// camera track before binding it back to the senders.
|
||||
runCatching { localVideoTrack?.dispose() }
|
||||
runCatching { localVideoSource?.dispose() }
|
||||
localVideoSource = factory.createVideoSource(false)
|
||||
localVideoTrack = factory.createVideoTrack("video0", localVideoSource)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun startCamera() {
|
||||
if (cameraCapturer != null) return
|
||||
val source = localVideoSource ?: return
|
||||
@@ -186,6 +332,7 @@ class CallMediaManager(
|
||||
)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun stopCamera() {
|
||||
try {
|
||||
cameraCapturer?.stopCapture()
|
||||
@@ -215,6 +362,10 @@ class CallMediaManager(
|
||||
}
|
||||
|
||||
fun dispose() {
|
||||
// Everything below tears the camera down, so don't let the screen-share stop bring it back
|
||||
// up first — that opened the device mid-hangup and made stopCamera() wait on it.
|
||||
val screenResources = stopScreenShare(restoreCamera = false)
|
||||
disposeScreenShareResources(screenResources)
|
||||
try {
|
||||
stopCamera()
|
||||
} catch (e: Exception) {
|
||||
|
||||
+35
-5
@@ -21,6 +21,8 @@
|
||||
package com.vitorpamplona.amethyst.service.call
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.nipACWebRtcCalls.CallManager
|
||||
import com.vitorpamplona.amethyst.commons.nipACWebRtcCalls.CallState
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
/**
|
||||
@@ -34,33 +36,61 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
* whose lifetime is tied to the Activity's lifecycle.
|
||||
*/
|
||||
object CallSessionBridge {
|
||||
/** Account-scoped: survives MainActivity being destroyed mid-call. */
|
||||
var callManager: CallManager? = null
|
||||
private set
|
||||
|
||||
/** Account-scoped: everything [CallActivity] needs (signer, settings, signaling publish). */
|
||||
var account: Account? = null
|
||||
private set
|
||||
|
||||
/**
|
||||
* Activity-scoped, and therefore nullable at any time: MainActivity can be destroyed while a
|
||||
* call is still running. Only consumers that genuinely need ViewModel-level helpers should
|
||||
* read this, and they must tolerate null.
|
||||
*/
|
||||
var accountViewModel: AccountViewModel? = null
|
||||
private set
|
||||
|
||||
fun set(
|
||||
callManager: CallManager,
|
||||
account: Account,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
this.callManager = callManager
|
||||
this.account = account
|
||||
this.accountViewModel = accountViewModel
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets call state and clears all references. Called from
|
||||
* [AccountViewModel.onCleared] during logout or account switch.
|
||||
* Drops only the Activity-scoped [accountViewModel] reference. Called from
|
||||
* [AccountViewModel.onCleared], which fires on every MainActivity destruction — including
|
||||
* while a call is in progress — so it must leave [callManager] and [account] intact.
|
||||
*
|
||||
* While a call is up the reference is kept: [CallActivity] still renders its UI from this
|
||||
* ViewModel and holds a strong reference to it either way, so clearing here would free
|
||||
* nothing and would only break the running call's UI. It is replaced wholesale by [set] as
|
||||
* soon as MainActivity comes back, and dropped by [clear] on logout / account switch.
|
||||
*/
|
||||
fun clearViewModel() {
|
||||
if (callManager?.state?.value !is CallState.Idle) return
|
||||
accountViewModel = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Ends the current call and clears all references. Called on a real logout or account switch
|
||||
* from `AccountSessionManager`, alongside `NestBridge.clear()`.
|
||||
*
|
||||
* Uses [CallManager.reset] (non-blocking, no mutex) instead of
|
||||
* [CallManager.hangup] to avoid deadlocking on `stateMutex` if
|
||||
* a cancelled coroutine on the dying `viewModelScope` still holds
|
||||
* it. Hangup signaling to the remote peer is the responsibility
|
||||
* of [CallActivity.onDestroy] and [CallForegroundService], not
|
||||
* a cancelled coroutine still holds it. Hangup signaling to the remote peer is the
|
||||
* responsibility of [CallActivity.onDestroy] and [CallForegroundService], not
|
||||
* the bridge teardown.
|
||||
*/
|
||||
fun clear() {
|
||||
callManager?.reset()
|
||||
callManager = null
|
||||
account = null
|
||||
accountViewModel = null
|
||||
}
|
||||
}
|
||||
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* 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.call
|
||||
|
||||
import org.webrtc.VideoSource
|
||||
import org.webrtc.VideoTrack
|
||||
import kotlin.math.min
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
internal data class ScreenShareCaptureSize(
|
||||
val width: Int,
|
||||
val height: Int,
|
||||
)
|
||||
|
||||
internal fun screenShareCaptureSize(
|
||||
widthPixels: Int,
|
||||
heightPixels: Int,
|
||||
maxDimension: Int = 1920,
|
||||
): ScreenShareCaptureSize {
|
||||
val width = widthPixels.coerceAtLeast(2)
|
||||
val height = heightPixels.coerceAtLeast(2)
|
||||
val scale = min(1f, maxDimension.toFloat() / maxOf(width, height))
|
||||
|
||||
fun even(value: Int): Int = value.coerceAtLeast(2).let { it - it % 2 }
|
||||
|
||||
return ScreenShareCaptureSize(
|
||||
width = even((width * scale).roundToInt()),
|
||||
height = even((height * scale).roundToInt()),
|
||||
)
|
||||
}
|
||||
|
||||
data class ScreenShareResources(
|
||||
val track: VideoTrack,
|
||||
val source: VideoSource,
|
||||
)
|
||||
+219
@@ -0,0 +1,219 @@
|
||||
/*
|
||||
* 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.call
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.hardware.display.DisplayManager
|
||||
import android.hardware.display.VirtualDisplay
|
||||
import android.media.projection.MediaProjection
|
||||
import android.media.projection.MediaProjectionManager
|
||||
import android.view.Surface
|
||||
import org.webrtc.CapturerObserver
|
||||
import org.webrtc.SurfaceTextureHelper
|
||||
import org.webrtc.ThreadUtils
|
||||
import org.webrtc.VideoCapturer
|
||||
import org.webrtc.VideoFrame
|
||||
import org.webrtc.VideoSink
|
||||
|
||||
/**
|
||||
* Captures the screen through [MediaProjection] and feeds it to WebRTC.
|
||||
*
|
||||
* Behaviourally a drop-in for `org.webrtc.ScreenCapturerAndroid` (a derivative of it — original
|
||||
* © 2016 The WebRTC project authors, BSD-style license), with one defect fixed: the upstream
|
||||
* class builds the capture `Surface` inline,
|
||||
*
|
||||
* ```java
|
||||
* virtualDisplay = mediaProjection.createVirtualDisplay(..., new Surface(helper.getSurfaceTexture()), ...);
|
||||
* ```
|
||||
*
|
||||
* and keeps no reference to it, so `Surface.release()` is never called. `VirtualDisplay.release()`
|
||||
* does not cover it — the Surface belongs to the caller — so every capture session leaked one,
|
||||
* reclaimed only whenever the finalizer next ran. It showed up as a StrictMode
|
||||
* `LeakedClosableViolation` pointing at `ScreenCapturerAndroid.createVirtualDisplay`, and
|
||||
* `changeCaptureFormat` leaked another one per call. This version owns the Surface and releases
|
||||
* it with the virtual display.
|
||||
*/
|
||||
class ScreenShareCapturer(
|
||||
private val mediaProjectionPermissionResultData: Intent,
|
||||
private val mediaProjectionCallback: MediaProjection.Callback,
|
||||
) : VideoCapturer,
|
||||
VideoSink {
|
||||
private var width: Int = 0
|
||||
private var height: Int = 0
|
||||
|
||||
private var virtualDisplay: VirtualDisplay? = null
|
||||
|
||||
/** The reference the upstream capturer drops on the floor. Released in [releaseDisplay]. */
|
||||
private var surface: Surface? = null
|
||||
|
||||
private var surfaceTextureHelper: SurfaceTextureHelper? = null
|
||||
private var capturerObserver: CapturerObserver? = null
|
||||
private var mediaProjection: MediaProjection? = null
|
||||
private var mediaProjectionManager: MediaProjectionManager? = null
|
||||
|
||||
private var numCapturedFrames: Long = 0
|
||||
private var isDisposed = false
|
||||
|
||||
private fun checkNotDisposed() {
|
||||
if (isDisposed) throw IllegalStateException("capturer is disposed.")
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun initialize(
|
||||
surfaceTextureHelper: SurfaceTextureHelper,
|
||||
applicationContext: Context,
|
||||
capturerObserver: CapturerObserver,
|
||||
) {
|
||||
checkNotDisposed()
|
||||
this.surfaceTextureHelper = surfaceTextureHelper
|
||||
this.capturerObserver = capturerObserver
|
||||
this.mediaProjectionManager = applicationContext.getSystemService(MediaProjectionManager::class.java)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun startCapture(
|
||||
width: Int,
|
||||
height: Int,
|
||||
ignoredFramerate: Int,
|
||||
) {
|
||||
checkNotDisposed()
|
||||
this.width = width
|
||||
this.height = height
|
||||
|
||||
val helper = surfaceTextureHelper ?: throw IllegalStateException("surfaceTextureHelper not set.")
|
||||
val manager = mediaProjectionManager ?: throw IllegalStateException("capturer not initialized.")
|
||||
|
||||
val projection =
|
||||
manager.getMediaProjection(Activity.RESULT_OK, mediaProjectionPermissionResultData)
|
||||
?: throw IllegalStateException("MediaProjection permission data was rejected.")
|
||||
mediaProjection = projection
|
||||
|
||||
// Let the MediaProjection callback use the SurfaceTextureHelper thread.
|
||||
projection.registerCallback(mediaProjectionCallback, helper.handler)
|
||||
|
||||
createVirtualDisplay()
|
||||
capturerObserver?.onCapturerStarted(true)
|
||||
helper.startListening(this)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun stopCapture() {
|
||||
checkNotDisposed()
|
||||
val helper = surfaceTextureHelper ?: return
|
||||
ThreadUtils.invokeAtFrontUninterruptibly(helper.handler) {
|
||||
helper.stopListening()
|
||||
capturerObserver?.onCapturerStopped()
|
||||
|
||||
releaseDisplay()
|
||||
|
||||
mediaProjection?.let { projection ->
|
||||
// Unregister the callback before stopping, otherwise the callback recursively
|
||||
// calls this method.
|
||||
projection.unregisterCallback(mediaProjectionCallback)
|
||||
projection.stop()
|
||||
}
|
||||
mediaProjection = null
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun dispose() {
|
||||
isDisposed = true
|
||||
// Best-effort. On the failure path startCapture() can have created the display and Surface
|
||||
// without a matching stopCapture(), and nothing else would hand them back.
|
||||
releaseDisplay()
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes the output video size, e.g. when the captured screen rotates.
|
||||
*/
|
||||
@Synchronized
|
||||
override fun changeCaptureFormat(
|
||||
width: Int,
|
||||
height: Int,
|
||||
ignoredFramerate: Int,
|
||||
) {
|
||||
checkNotDisposed()
|
||||
this.width = width
|
||||
this.height = height
|
||||
|
||||
// Capturer is stopped; the virtual display will be created by startCapture().
|
||||
if (virtualDisplay == null) return
|
||||
|
||||
val helper = surfaceTextureHelper ?: return
|
||||
|
||||
// Recreate on the SurfaceTextureHelper thread to avoid interfering with frame processing,
|
||||
// which runs on that same thread.
|
||||
ThreadUtils.invokeAtFrontUninterruptibly(helper.handler) {
|
||||
releaseDisplay()
|
||||
createVirtualDisplay()
|
||||
}
|
||||
}
|
||||
|
||||
private fun createVirtualDisplay() {
|
||||
val helper = surfaceTextureHelper ?: return
|
||||
val projection = mediaProjection ?: return
|
||||
|
||||
helper.setTextureSize(width, height)
|
||||
|
||||
val newSurface = Surface(helper.surfaceTexture)
|
||||
surface = newSurface
|
||||
virtualDisplay =
|
||||
projection.createVirtualDisplay(
|
||||
"WebRTC_ScreenCapture",
|
||||
width,
|
||||
height,
|
||||
VIRTUAL_DISPLAY_DPI,
|
||||
DISPLAY_FLAGS,
|
||||
newSurface,
|
||||
null,
|
||||
null,
|
||||
)
|
||||
}
|
||||
|
||||
/** Releases the virtual display first, so nothing is still drawing into the Surface. */
|
||||
private fun releaseDisplay() {
|
||||
virtualDisplay?.release()
|
||||
virtualDisplay = null
|
||||
surface?.release()
|
||||
surface = null
|
||||
}
|
||||
|
||||
/** Called on the internal looper thread of [SurfaceTextureHelper]. */
|
||||
override fun onFrame(frame: VideoFrame) {
|
||||
numCapturedFrames++
|
||||
capturerObserver?.onFrameCaptured(frame)
|
||||
}
|
||||
|
||||
override fun isScreencast(): Boolean = true
|
||||
|
||||
fun getNumCapturedFrames(): Long = numCapturedFrames
|
||||
|
||||
companion object {
|
||||
private const val DISPLAY_FLAGS =
|
||||
DisplayManager.VIRTUAL_DISPLAY_FLAG_PUBLIC or DisplayManager.VIRTUAL_DISPLAY_FLAG_PRESENTATION
|
||||
|
||||
/** DPI for the VirtualDisplay; does not appear to matter here. */
|
||||
private const val VIRTUAL_DISPLAY_DPI = 400
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -78,7 +78,7 @@ class MemoryTrimmingService(
|
||||
level: Int = ComponentCallbacks2.TRIM_MEMORY_BACKGROUND,
|
||||
) {
|
||||
if (isTrimmingMemoryMutex.compareAndSet(false, true)) {
|
||||
Log.d("ServiceManager", "Trimming Memory (level=$level)")
|
||||
Log.d("ServiceManager") { "Trimming Memory (level=$level)" }
|
||||
try {
|
||||
doTrim(account, otherAccounts, level)
|
||||
} finally {
|
||||
|
||||
+22
-10
@@ -27,11 +27,12 @@ import coil3.annotation.ExperimentalCoilApi
|
||||
import coil3.fetch.FetchResult
|
||||
import coil3.fetch.Fetcher
|
||||
import coil3.network.CacheStrategy
|
||||
import coil3.network.ConcurrentRequestStrategy
|
||||
import coil3.network.ConnectivityChecker
|
||||
import coil3.network.DeDupeConcurrentRequestStrategy
|
||||
import coil3.network.NetworkFetcher
|
||||
import coil3.network.okhttp.asNetworkClient
|
||||
import coil3.request.Options
|
||||
import com.vitorpamplona.amethyst.service.okhttp.BlossomReadAuthTokenProvider
|
||||
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.BlossomServerResolver
|
||||
import com.vitorpamplona.quartz.utils.startsWithIgnoreCase
|
||||
import okhttp3.Call
|
||||
@@ -57,8 +58,15 @@ class BlossomFetcher(
|
||||
class Factory(
|
||||
val blossomServerResolver: () -> BlossomServerResolver,
|
||||
val networkClient: (url: String) -> Call.Factory,
|
||||
// Shared with every other network-backed factory on this ImageLoader --
|
||||
// see the note in ImageLoaderSetup.setup(): the de-dupe only works when
|
||||
// all fetchers coordinate through the same instance.
|
||||
concurrentRequestStrategy: ConcurrentRequestStrategy,
|
||||
private val readAuth: BlossomReadAuthTokenProvider? = null,
|
||||
) : Fetcher.Factory<Uri> {
|
||||
private val cacheStrategyLazy = lazy { CacheStrategy.DEFAULT }
|
||||
private val connectivityCheckerLazy = singleParameterLazy(::ConnectivityChecker)
|
||||
private val concurrentRequestStrategyLazy = lazyOf(concurrentRequestStrategy)
|
||||
|
||||
override fun create(
|
||||
data: Uri,
|
||||
@@ -66,16 +74,20 @@ class BlossomFetcher(
|
||||
imageLoader: ImageLoader,
|
||||
): Fetcher? {
|
||||
if (!isApplicable(data)) return null
|
||||
// Wrapped per resolved url (not per Factory) because the server the
|
||||
// blob actually lives on is only known once the resolver has run.
|
||||
return BlossomFetcher(options, data, blossomServerResolver) { url ->
|
||||
NetworkFetcher(
|
||||
url = url,
|
||||
options = options,
|
||||
networkClient = lazy { networkClient(url).asNetworkClient() },
|
||||
diskCache = lazy { imageLoader.diskCache },
|
||||
cacheStrategy = lazy { CacheStrategy.DEFAULT },
|
||||
connectivityChecker = lazy { connectivityCheckerLazy.get(options.context) },
|
||||
concurrentRequestStrategy = lazy { DeDupeConcurrentRequestStrategy() },
|
||||
)
|
||||
readAuthAware(url, readAuth) { authHeader ->
|
||||
NetworkFetcher(
|
||||
url = url,
|
||||
options = options.withAuthHeader(authHeader),
|
||||
networkClient = lazy { networkClient(url).asNetworkClient() },
|
||||
diskCache = lazy { imageLoader.diskCache },
|
||||
cacheStrategy = cacheStrategyLazy,
|
||||
connectivityChecker = lazy { connectivityCheckerLazy.get(options.context) },
|
||||
concurrentRequestStrategy = concurrentRequestStrategyLazy,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
/*
|
||||
* 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.images
|
||||
|
||||
import coil3.Extras
|
||||
import coil3.fetch.FetchResult
|
||||
import coil3.fetch.Fetcher
|
||||
import coil3.network.HttpException
|
||||
import coil3.network.httpHeaders
|
||||
import coil3.request.Options
|
||||
import com.vitorpamplona.amethyst.service.okhttp.BlossomReadAuthInterceptor
|
||||
import com.vitorpamplona.amethyst.service.okhttp.BlossomReadAuthTokenProvider
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
|
||||
/**
|
||||
* Retries an auth-gated Blossom blob with a signed BUD-01 `t=get` token when the
|
||||
* anonymous fetch comes back `401`.
|
||||
*
|
||||
* This is the half of the read-auth flow that has to wait for a signature.
|
||||
* `BlossomReadAuthInterceptor` cannot: it runs on an OkHttp dispatcher thread,
|
||||
* so waiting there holds one of the 16 per-host slots and stalls every other
|
||||
* image from the same host. `Fetcher.fetch()` is `suspend`, so the wait costs a
|
||||
* suspended coroutine and nothing else.
|
||||
*
|
||||
* [build] produces the underlying network fetcher, optionally carrying an
|
||||
* `Authorization` header. Deliberately a `(String?) -> Fetcher` lambda rather
|
||||
* than taking Coil's [Options] directly — the retry decision is then testable
|
||||
* without an Android `Context` to construct [Options] with.
|
||||
*/
|
||||
class BlossomReadAuthFetcher(
|
||||
private val url: String,
|
||||
private val auth: BlossomReadAuthTokenProvider,
|
||||
private val build: (authHeader: String?) -> Fetcher,
|
||||
) : Fetcher {
|
||||
override suspend fun fetch(): FetchResult? {
|
||||
try {
|
||||
return build(null).fetch()
|
||||
} catch (e: HttpException) {
|
||||
// Coil's NetworkFetcher throws HttpException for any non-2xx/304,
|
||||
// which is how a 401 reaches us with its code intact.
|
||||
if (e.response.code != HTTP_UNAUTHORIZED) throw e
|
||||
|
||||
val httpUrl = url.toHttpUrlOrNull() ?: throw e
|
||||
// Gate only: read-auth applies to Blossom blob URLs, but the token is
|
||||
// scoped to the host (BUD-11 `server` tag) and carries no `x` tag.
|
||||
BlossomReadAuthInterceptor.blossomHashOrNull(httpUrl.encodedPath) ?: throw e
|
||||
val header = auth.header(httpUrl.host) ?: throw e
|
||||
|
||||
return build(header).fetch()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val HTTP_UNAUTHORIZED = 401
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps [build] in read-auth handling when a token provider is configured, and
|
||||
* returns the plain fetcher when it isn't (tests, pre-configuration call sites).
|
||||
*/
|
||||
fun readAuthAware(
|
||||
url: String,
|
||||
auth: BlossomReadAuthTokenProvider?,
|
||||
build: (authHeader: String?) -> Fetcher,
|
||||
): Fetcher =
|
||||
if (auth == null) {
|
||||
build(null)
|
||||
} else {
|
||||
BlossomReadAuthFetcher(url, auth, build)
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy of these options carrying [header] as `Authorization`, or the same
|
||||
* options when there is no header. Coil's `NetworkFetcher` builds its request
|
||||
* from `options.httpHeaders`, so this is how the retry gets authenticated.
|
||||
*/
|
||||
fun Options.withAuthHeader(header: String?): Options =
|
||||
if (header == null) {
|
||||
this
|
||||
} else {
|
||||
copy(
|
||||
extras =
|
||||
extras
|
||||
.newBuilder()
|
||||
.set(
|
||||
Extras.Key.httpHeaders,
|
||||
httpHeaders.newBuilder().set("Authorization", header).build(),
|
||||
).build(),
|
||||
)
|
||||
}
|
||||
+32
-13
@@ -33,6 +33,7 @@ import coil3.gif.AnimatedImageDecoder
|
||||
import coil3.gif.GifDecoder
|
||||
import coil3.memory.MemoryCache
|
||||
import coil3.network.CacheStrategy
|
||||
import coil3.network.ConcurrentRequestStrategy
|
||||
import coil3.network.ConnectivityChecker
|
||||
import coil3.network.DeDupeConcurrentRequestStrategy
|
||||
import coil3.network.NetworkFetcher
|
||||
@@ -43,6 +44,7 @@ import coil3.svg.SvgDecoder
|
||||
import coil3.util.Logger
|
||||
import coil3.video.VideoFrameDecoder
|
||||
import com.vitorpamplona.amethyst.isDebug
|
||||
import com.vitorpamplona.amethyst.service.okhttp.BlossomReadAuthTokenProvider
|
||||
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.BlossomServerResolver
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -60,7 +62,7 @@ class ImageLoaderSetup {
|
||||
|
||||
val debugLogger = if (isDebug) MyDebugLogger() else null
|
||||
|
||||
@OptIn(DelicateCoilApi::class)
|
||||
@OptIn(DelicateCoilApi::class, ExperimentalCoilApi::class)
|
||||
fun setup(
|
||||
app: Context,
|
||||
diskCache: () -> DiskCache,
|
||||
@@ -69,7 +71,19 @@ class ImageLoaderSetup {
|
||||
callFactory: (url: String) -> Call.Factory,
|
||||
thumbnailCache: ThumbnailDiskCache,
|
||||
backgroundScope: CoroutineScope,
|
||||
// Signs the BUD-01 retry when a gated host answers 401. Null keeps every
|
||||
// fetch anonymous (tests, pre-configuration call sites).
|
||||
readAuth: BlossomReadAuthTokenProvider? = null,
|
||||
) {
|
||||
// ONE strategy for the whole ImageLoader. DeDupeConcurrentRequestStrategy
|
||||
// coordinates through a map of in-flight fetches that it owns, so it only
|
||||
// works when every fetcher shares the same instance -- a fresh one per
|
||||
// request can never see anybody else's fetch and the de-dupe silently
|
||||
// no-ops. Shared across all three network-backed factories so a feed
|
||||
// image and the same blob reached through `blossom:` (or a profile
|
||||
// picture) still collapse onto one download.
|
||||
val concurrentRequests = DeDupeConcurrentRequestStrategy()
|
||||
|
||||
SingletonImageLoader.setUnsafe(
|
||||
ImageLoader
|
||||
.Builder(app)
|
||||
@@ -87,13 +101,13 @@ class ImageLoaderSetup {
|
||||
add(Base64Fetcher.Factory)
|
||||
add(BlurHashFetcher.Factory)
|
||||
add(ThumbHashFetcher.Factory)
|
||||
add(BlossomFetcher.Factory(blossomServerResolver, callFactory))
|
||||
add(ProfilePictureFetcher.Factory(thumbnailCache, callFactory, backgroundScope))
|
||||
add(BlossomFetcher.Factory(blossomServerResolver, callFactory, concurrentRequests, readAuth))
|
||||
add(ProfilePictureFetcher.Factory(thumbnailCache, callFactory, backgroundScope, concurrentRequests, readAuth))
|
||||
add(Base64Fetcher.BKeyer)
|
||||
add(BlurHashFetcher.BKeyer)
|
||||
add(ThumbHashFetcher.TKeyer)
|
||||
add(ProfilePictureFetcher.BKeyer)
|
||||
add(OkHttpFactory(callFactory))
|
||||
add(OkHttpFactory(callFactory, concurrentRequests, readAuth))
|
||||
}.build(),
|
||||
)
|
||||
}
|
||||
@@ -132,9 +146,12 @@ class MyDebugLogger(
|
||||
@OptIn(ExperimentalCoilApi::class)
|
||||
class OkHttpFactory(
|
||||
val networkClient: (url: String) -> Call.Factory,
|
||||
concurrentRequestStrategy: ConcurrentRequestStrategy,
|
||||
private val readAuth: BlossomReadAuthTokenProvider? = null,
|
||||
) : Fetcher.Factory<Uri> {
|
||||
private val cacheStrategyLazy = lazy { CacheStrategy.DEFAULT }
|
||||
private val connectivityCheckerLazy = singleParameterLazy(::ConnectivityChecker)
|
||||
private val concurrentRequestStrategyLazy = lazyOf(concurrentRequestStrategy)
|
||||
|
||||
override fun create(
|
||||
data: Uri,
|
||||
@@ -145,15 +162,17 @@ class OkHttpFactory(
|
||||
|
||||
val url = data.toString()
|
||||
|
||||
return NetworkFetcher(
|
||||
url = url,
|
||||
options = options,
|
||||
networkClient = lazy { networkClient(url).asNetworkClient() },
|
||||
diskCache = lazy { imageLoader.diskCache },
|
||||
cacheStrategy = cacheStrategyLazy,
|
||||
connectivityChecker = lazy { connectivityCheckerLazy.get(options.context) },
|
||||
concurrentRequestStrategy = lazy { DeDupeConcurrentRequestStrategy() },
|
||||
)
|
||||
return readAuthAware(url, readAuth) { authHeader ->
|
||||
NetworkFetcher(
|
||||
url = url,
|
||||
options = options.withAuthHeader(authHeader),
|
||||
networkClient = lazy { networkClient(url).asNetworkClient() },
|
||||
diskCache = lazy { imageLoader.diskCache },
|
||||
cacheStrategy = cacheStrategyLazy,
|
||||
connectivityChecker = lazy { connectivityCheckerLazy.get(options.context) },
|
||||
concurrentRequestStrategy = concurrentRequestStrategyLazy,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isApplicable(data: Uri): Boolean = data.scheme == "http" || data.scheme == "https"
|
||||
|
||||
+21
-10
@@ -30,12 +30,13 @@ import coil3.fetch.Fetcher
|
||||
import coil3.fetch.ImageFetchResult
|
||||
import coil3.key.Keyer
|
||||
import coil3.network.CacheStrategy
|
||||
import coil3.network.ConcurrentRequestStrategy
|
||||
import coil3.network.ConnectivityChecker
|
||||
import coil3.network.DeDupeConcurrentRequestStrategy
|
||||
import coil3.network.NetworkFetcher
|
||||
import coil3.network.okhttp.asNetworkClient
|
||||
import coil3.request.Options
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.ProfilePictureUrl
|
||||
import com.vitorpamplona.amethyst.service.okhttp.BlossomReadAuthTokenProvider
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.Call
|
||||
@@ -95,8 +96,16 @@ class ProfilePictureFetcher(
|
||||
private val thumbnailCache: ThumbnailDiskCache,
|
||||
private val networkClient: (url: String) -> Call.Factory,
|
||||
private val backgroundScope: CoroutineScope,
|
||||
// Shared with every other network-backed factory on this ImageLoader --
|
||||
// see the note in ImageLoaderSetup.setup(). Avatars repeat constantly
|
||||
// down a feed, so this is where a per-request instance cost the most:
|
||||
// every row holding the same author's picture downloaded it again.
|
||||
concurrentRequestStrategy: ConcurrentRequestStrategy,
|
||||
private val readAuth: BlossomReadAuthTokenProvider? = null,
|
||||
) : Fetcher.Factory<ProfilePictureUrl> {
|
||||
private val cacheStrategyLazy = lazy { CacheStrategy.DEFAULT }
|
||||
private val connectivityCheckerLazy = singleParameterLazy(::ConnectivityChecker)
|
||||
private val concurrentRequestStrategyLazy = lazyOf(concurrentRequestStrategy)
|
||||
|
||||
override fun create(
|
||||
data: ProfilePictureUrl,
|
||||
@@ -106,15 +115,17 @@ class ProfilePictureFetcher(
|
||||
val diskCacheLazy = lazy { imageLoader.diskCache }
|
||||
|
||||
val netFetcher =
|
||||
NetworkFetcher(
|
||||
url = data.url,
|
||||
options = options,
|
||||
networkClient = lazy { networkClient(data.url).asNetworkClient() },
|
||||
diskCache = diskCacheLazy,
|
||||
cacheStrategy = lazy { CacheStrategy.DEFAULT },
|
||||
connectivityChecker = lazy { connectivityCheckerLazy.get(options.context) },
|
||||
concurrentRequestStrategy = lazy { DeDupeConcurrentRequestStrategy() },
|
||||
)
|
||||
readAuthAware(data.url, readAuth) { authHeader ->
|
||||
NetworkFetcher(
|
||||
url = data.url,
|
||||
options = options.withAuthHeader(authHeader),
|
||||
networkClient = lazy { networkClient(data.url).asNetworkClient() },
|
||||
diskCache = diskCacheLazy,
|
||||
cacheStrategy = cacheStrategyLazy,
|
||||
connectivityChecker = lazy { connectivityCheckerLazy.get(options.context) },
|
||||
concurrentRequestStrategy = concurrentRequestStrategyLazy,
|
||||
)
|
||||
}
|
||||
|
||||
return ProfilePictureFetcher(
|
||||
data.url,
|
||||
|
||||
+16
-1
@@ -201,6 +201,13 @@ class LightningAddressResolver {
|
||||
?: response.code.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param onZapRequestSent receives the zap request that was ACTUALLY sent to the
|
||||
* callback, or null when it was not. A provider that does not advertise
|
||||
* `allowsNostr` never sees [nostrRequest], and its invoice therefore commits to
|
||||
* nothing about it — so a caller must not go on to claim the two are bound. See
|
||||
* the drop below.
|
||||
*/
|
||||
suspend fun lnAddressInvoice(
|
||||
lnAddress: String,
|
||||
milliSats: Long,
|
||||
@@ -209,6 +216,7 @@ class LightningAddressResolver {
|
||||
okHttpClient: (String) -> OkHttpClient,
|
||||
onProgress: (percent: Float) -> Unit,
|
||||
context: Context,
|
||||
onZapRequestSent: (LnZapRequestEvent?) -> Unit = {},
|
||||
): String {
|
||||
val mapper = jacksonObjectMapper()
|
||||
|
||||
@@ -264,12 +272,19 @@ class LightningAddressResolver {
|
||||
)
|
||||
}
|
||||
|
||||
// NIP-57 binds a zap request to its invoice through `description_hash`, and a
|
||||
// provider that ignores `nostr=` mints an invoice that commits to nothing about
|
||||
// it. Report what actually went, so a caller cannot attach the event to a
|
||||
// payment it was never bound to.
|
||||
val sentZapRequest = nostrRequest?.takeIf { allowsNostr }
|
||||
onZapRequestSent(sentZapRequest)
|
||||
|
||||
val invoice =
|
||||
fetchLightningInvoice(
|
||||
lnCallback = callbackUrl,
|
||||
milliSats = milliSats,
|
||||
message = message,
|
||||
nostrRequest = if (allowsNostr) nostrRequest else null,
|
||||
nostrRequest = sentZapRequest,
|
||||
okHttpClient = okHttpClient,
|
||||
context = context,
|
||||
)
|
||||
|
||||
+10
@@ -76,6 +76,11 @@ import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestUpdateEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.reply.GitReplyEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusAppliedEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusClosedEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusDraftEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusOpenEvent
|
||||
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent
|
||||
@@ -278,6 +283,11 @@ class EventNotificationConsumer(
|
||||
is GitPatchEvent -> CodeNotification.notify(applicationContext, account, event)
|
||||
is GitPullRequestEvent -> CodeNotification.notify(applicationContext, account, event)
|
||||
is GitPullRequestUpdateEvent -> CodeNotification.notify(applicationContext, account, event)
|
||||
is GitReplyEvent -> CodeNotification.notify(applicationContext, account, event)
|
||||
is GitStatusOpenEvent -> CodeNotification.notify(applicationContext, account, event)
|
||||
is GitStatusAppliedEvent -> CodeNotification.notify(applicationContext, account, event)
|
||||
is GitStatusClosedEvent -> CodeNotification.notify(applicationContext, account, event)
|
||||
is GitStatusDraftEvent -> CodeNotification.notify(applicationContext, account, event)
|
||||
|
||||
is LiveChessGameAcceptEvent -> ChessNotification.notify(applicationContext, account, event, R.string.app_notification_chess_challenge_accepted)
|
||||
is LiveChessMoveEvent -> ChessNotification.notify(applicationContext, account, event, R.string.app_notification_chess_your_turn)
|
||||
|
||||
+5
-1
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.notifications
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.nip71Video.selectVideoTrack
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
@@ -205,7 +206,10 @@ object NotificationContent {
|
||||
when (event) {
|
||||
is PictureEvent -> event.imetaTags().firstOrNull()?.url
|
||||
is VideoEvent -> {
|
||||
val meta = event.imetaTags().firstOrNull()
|
||||
// selectVideoTrack fills the poster in from sibling imetas, so a ladder that
|
||||
// declares `image` on only some of its rungs still gets a big picture instead of
|
||||
// falling back to a playlist URL Coil cannot decode.
|
||||
val meta = event.selectVideoTrack()
|
||||
meta?.image?.firstOrNull() ?: meta?.url
|
||||
}
|
||||
else -> null
|
||||
|
||||
+16
-1
@@ -47,6 +47,11 @@ import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestUpdateEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.reply.GitReplyEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusAppliedEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusClosedEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusDraftEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusOpenEvent
|
||||
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent
|
||||
@@ -105,7 +110,9 @@ class NotificationDispatcher(
|
||||
// consumeFromCache can't route it. It's delivered directly via
|
||||
// [notifyWelcome] from processMarmotWelcomeFlow, which does know the
|
||||
// recipient account.
|
||||
private val NOTIFICATION_KINDS: Set<Int> =
|
||||
// `internal` (was `private`) so the notification-kinds contract test
|
||||
// can pin the push-side kind set against the in-app feed's kind set.
|
||||
internal val NOTIFICATION_KINDS: Set<Int> =
|
||||
setOf(
|
||||
// Direct-arrival
|
||||
PrivateDmEvent.KIND,
|
||||
@@ -131,6 +138,14 @@ class NotificationDispatcher(
|
||||
GitIssueEvent.KIND,
|
||||
GitPullRequestEvent.KIND,
|
||||
GitPullRequestUpdateEvent.KIND,
|
||||
// NIP-34 threaded activity: legacy git-reply comment (1622)
|
||||
// and the four status transitions (open/applied/closed/draft,
|
||||
// kinds 1630-1633). Same push channel as issues/patches/PRs.
|
||||
GitReplyEvent.KIND,
|
||||
GitStatusOpenEvent.KIND,
|
||||
GitStatusAppliedEvent.KIND,
|
||||
GitStatusClosedEvent.KIND,
|
||||
GitStatusDraftEvent.KIND,
|
||||
HighlightEvent.KIND,
|
||||
LongTextNoteEvent.KIND,
|
||||
WikiNoteEvent.KIND,
|
||||
|
||||
+54
-7
@@ -92,6 +92,11 @@ class NotificationRelayService : Service() {
|
||||
// Keeps notification updates well under Android's rate limit (~10/s).
|
||||
private const val NOTIFICATION_REFRESH_MS = 1000L
|
||||
|
||||
// Toggles the expanded per-job breakdown on and off. Fired by the notification's own
|
||||
// action button, so the details are always something the user asked for.
|
||||
private const val ACTION_SHOW_DETAILS = "com.vitorpamplona.amethyst.SHOW_NOTIFICATION_SERVICE_DETAILS"
|
||||
private const val ACTION_HIDE_DETAILS = "com.vitorpamplona.amethyst.HIDE_NOTIFICATION_SERVICE_DETAILS"
|
||||
|
||||
const val ACTION_AUTO_RESTART = "com.vitorpamplona.amethyst.AUTO_RESTART_NOTIFICATION_SERVICE"
|
||||
|
||||
fun start(context: Context) {
|
||||
@@ -149,6 +154,9 @@ class NotificationRelayService : Service() {
|
||||
/** Last non-empty per-job breakdown, kept so a reconnect does not blank the expanded view. */
|
||||
private var lastBreakdown: List<String> = emptyList()
|
||||
|
||||
/** Whether the user asked for the per-job breakdown. Off until they tap "show details". */
|
||||
private var detailsExpanded = false
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onCreate() {
|
||||
@@ -165,6 +173,13 @@ class NotificationRelayService : Service() {
|
||||
startId: Int,
|
||||
): Int {
|
||||
Log.d(TAG, "Starting service")
|
||||
// The details toggle re-enters here through the notification's action button. It only
|
||||
// flips the flag; the rebuild happens in the ensureForeground() below, which reposts the
|
||||
// notification with (or without) the breakdown.
|
||||
when (intent?.action) {
|
||||
ACTION_SHOW_DETAILS -> detailsExpanded = true
|
||||
ACTION_HIDE_DETAILS -> detailsExpanded = false
|
||||
}
|
||||
// Every startForegroundService() call re-arms Android's "must call
|
||||
// startForeground() within the timeout" requirement — including the repeated
|
||||
// calls MainActivity.onResume fires on each resume, even when the service is
|
||||
@@ -359,9 +374,12 @@ class NotificationRelayService : Service() {
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
|
||||
// Expanded only. The collapsed line stays the bare count it has always been — that is all
|
||||
// most people want from an ongoing notification — and the per-job breakdown appears solely
|
||||
// when someone deliberately expands it to ask why the phone is talking to N relays.
|
||||
// Opt-in, never automatic. Android auto-expands a notification when it is the only one in
|
||||
// the shade, and there is no way to opt out of that — so attaching the breakdown as a
|
||||
// BigTextStyle up front made the per-job list the *default* view for anyone whose shade was
|
||||
// otherwise empty, which is the opposite of what it is for. The card is therefore built
|
||||
// with no expanded style at all until someone taps "show details"; that reposts it with the
|
||||
// breakdown and a "hide details" action that puts it back to the bare count.
|
||||
//
|
||||
// Held across reconnects rather than recomputed blindly: the breakdown is derived from the
|
||||
// *connected* relays, so a drop to zero (the "connecting…" state) would otherwise empty it and
|
||||
@@ -369,9 +387,37 @@ class NotificationRelayService : Service() {
|
||||
// looking at it. What each connection is *for* does not change while it is re-establishing,
|
||||
// so the last known answer is still the right one; only the count above it goes stale, and
|
||||
// that count is already labelled "connecting".
|
||||
val fresh = RelayPurposeSummary.lines(this)
|
||||
if (fresh.isNotEmpty()) lastBreakdown = fresh
|
||||
val breakdown = fresh.ifEmpty { lastBreakdown }.takeIf { it.isNotEmpty() }
|
||||
//
|
||||
// Computed only while expanded: walking every connected relay's active requests once a
|
||||
// second is wasted work when nobody has asked to see the result.
|
||||
val breakdown =
|
||||
if (detailsExpanded) {
|
||||
val fresh = RelayPurposeSummary.lines(this)
|
||||
if (fresh.isNotEmpty()) lastBreakdown = fresh
|
||||
lastBreakdown.takeIf { it.isNotEmpty() }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
val detailsIntent =
|
||||
Intent(this, NotificationRelayService::class.java).apply {
|
||||
action = if (detailsExpanded) ACTION_HIDE_DETAILS else ACTION_SHOW_DETAILS
|
||||
}
|
||||
val detailsPendingIntent =
|
||||
PendingIntent.getService(
|
||||
this,
|
||||
if (detailsExpanded) 4 else 3,
|
||||
detailsIntent,
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
val detailsLabel =
|
||||
getString(
|
||||
if (detailsExpanded) {
|
||||
R.string.always_on_notif_hide_details
|
||||
} else {
|
||||
R.string.always_on_notif_show_details
|
||||
},
|
||||
)
|
||||
|
||||
// Deliberately left ungrouped. This notification is ongoing and IMPORTANCE_LOW, so it
|
||||
// sits in the shade's Silent section next to the low-importance content kinds
|
||||
@@ -396,7 +442,8 @@ class NotificationRelayService : Service() {
|
||||
.bigText(contentText + "\n\n" + it.joinToString("\n")),
|
||||
)
|
||||
}
|
||||
}.setSmallIcon(R.drawable.amethyst_service)
|
||||
}.addAction(0, detailsLabel, detailsPendingIntent)
|
||||
.setSmallIcon(R.drawable.amethyst_service)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setOngoing(true)
|
||||
.setSilent(true)
|
||||
|
||||
+5
-3
@@ -32,9 +32,11 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
/**
|
||||
* The per-job breakdown behind the always-on notification's relay count.
|
||||
*
|
||||
* **Only ever shown expanded.** The collapsed line stays exactly what it was — a count — because
|
||||
* that is all most people ever want from an ongoing notification. This is for the moment someone
|
||||
* taps to ask *why* their phone is talking to 40 relays.
|
||||
* **Only ever shown on request.** The card stays exactly what it was — a count — because that is all
|
||||
* most people ever want from an ongoing notification. This is for the moment someone taps
|
||||
* "show details" to ask *why* their phone is talking to 40 relays. It is not attached to the
|
||||
* notification otherwise: Android auto-expands a lone notification, so anything hung off the
|
||||
* expanded view alone would be the default view rather than an opt-in one.
|
||||
*
|
||||
* A relay usually serves several jobs at once (measured: a typical relay carries four), so these
|
||||
* counts deliberately **overlap and sum to more than the relay count**. They answer "how many relays
|
||||
|
||||
+99
-2
@@ -35,12 +35,26 @@ import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestUpdateEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.reply.GitReplyEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusAppliedEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusClosedEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusDraftEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusOpenEvent
|
||||
|
||||
/**
|
||||
* Git / code notifications — NIP-34 issues (1621), patches (1617), pull requests
|
||||
* (1618) and PR updates (1619) on repos you maintain. Rendered as a slate card
|
||||
* titled by the action ("X opened an issue" …) with the subject as the body.
|
||||
* (1618), PR updates (1619), replies (1622, legacy), and status transitions
|
||||
* (1630 open, 1631 applied/merged, 1632 closed, 1633 draft) on repos or threads
|
||||
* you're p-tagged into. Rendered as a slate card titled by the action ("X opened
|
||||
* an issue", "X merged a pull request", …) with the subject as the body.
|
||||
* Author name + avatar enriched observably.
|
||||
*
|
||||
* Status kinds resolve their title from the *target* event's kind (patch/PR/issue)
|
||||
* when it's in cache, so a merge on a PR reads "merged a pull request" but the
|
||||
* same 1631 targeting a plain kind-1617 patch reads "applied a patch". Falls
|
||||
* back to a generic wording when the target isn't yet resolved (rare: the
|
||||
* notification lands after the target because the p-tag subscription pulls
|
||||
* status events regardless of whether the target has been seen).
|
||||
*/
|
||||
object CodeNotification {
|
||||
suspend fun notify(
|
||||
@@ -67,6 +81,89 @@ object CodeNotification {
|
||||
event: GitPullRequestUpdateEvent,
|
||||
) = post(context, account, event.id, event.createdAt, event.pubKey, R.string.app_notification_code_channel_message_pr_update, event.content)
|
||||
|
||||
suspend fun notify(
|
||||
context: Context,
|
||||
account: Account,
|
||||
event: GitReplyEvent,
|
||||
) = post(context, account, event.id, event.createdAt, event.pubKey, R.string.app_notification_code_channel_message_reply, event.content)
|
||||
|
||||
suspend fun notify(
|
||||
context: Context,
|
||||
account: Account,
|
||||
event: GitStatusOpenEvent,
|
||||
) = post(context, account, event.id, event.createdAt, event.pubKey, R.string.app_notification_code_channel_message_status_open, event.content)
|
||||
|
||||
suspend fun notify(
|
||||
context: Context,
|
||||
account: Account,
|
||||
event: GitStatusAppliedEvent,
|
||||
) = post(
|
||||
context,
|
||||
account,
|
||||
event.id,
|
||||
event.createdAt,
|
||||
event.pubKey,
|
||||
titleRes =
|
||||
titleForStatusOnTarget(
|
||||
event.rootEventId(),
|
||||
pr = R.string.app_notification_code_channel_message_status_applied_pr,
|
||||
patch = R.string.app_notification_code_channel_message_status_applied_patch,
|
||||
issue = R.string.app_notification_code_channel_message_status_applied_issue,
|
||||
fallback = R.string.app_notification_code_channel_message_status_applied,
|
||||
),
|
||||
subject = event.content,
|
||||
)
|
||||
|
||||
suspend fun notify(
|
||||
context: Context,
|
||||
account: Account,
|
||||
event: GitStatusClosedEvent,
|
||||
) = post(
|
||||
context,
|
||||
account,
|
||||
event.id,
|
||||
event.createdAt,
|
||||
event.pubKey,
|
||||
titleRes =
|
||||
titleForStatusOnTarget(
|
||||
event.rootEventId(),
|
||||
pr = R.string.app_notification_code_channel_message_status_closed_pr,
|
||||
patch = R.string.app_notification_code_channel_message_status_closed_patch,
|
||||
issue = R.string.app_notification_code_channel_message_status_closed_issue,
|
||||
fallback = R.string.app_notification_code_channel_message_status_closed,
|
||||
),
|
||||
subject = event.content,
|
||||
)
|
||||
|
||||
suspend fun notify(
|
||||
context: Context,
|
||||
account: Account,
|
||||
event: GitStatusDraftEvent,
|
||||
) = post(context, account, event.id, event.createdAt, event.pubKey, R.string.app_notification_code_channel_message_status_draft, event.content)
|
||||
|
||||
/**
|
||||
* Pick a title string for a status event based on the *target*'s kind, so
|
||||
* a 1631 on a kind-1618 PR reads "merged a pull request" while the same
|
||||
* status kind on a kind-1617 patch reads "applied a patch". [rootId] is
|
||||
* the marked-`root` `e` tag on the status event; when the target isn't in
|
||||
* cache we return [fallback] which is deliberately generic.
|
||||
*/
|
||||
private fun titleForStatusOnTarget(
|
||||
rootId: String?,
|
||||
pr: Int,
|
||||
patch: Int,
|
||||
issue: Int,
|
||||
fallback: Int,
|
||||
): Int {
|
||||
val targetKind = rootId?.let { LocalCache.getNoteIfExists(it)?.event?.kind } ?: return fallback
|
||||
return when (targetKind) {
|
||||
GitPullRequestEvent.KIND -> pr
|
||||
GitPatchEvent.KIND -> patch
|
||||
GitIssueEvent.KIND -> issue
|
||||
else -> fallback
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun post(
|
||||
context: Context,
|
||||
account: Account,
|
||||
|
||||
+1
-1
@@ -58,7 +58,7 @@ object NwcPaymentNotifier {
|
||||
val time = tx.settled_at ?: tx.created_at ?: TimeUtils.now()
|
||||
|
||||
val title = stringRes(context, R.string.app_notification_payments_channel_message, amount)
|
||||
val comment = (tx.parsedMetadata()?.comment ?: tx.description)?.ifBlank { null }
|
||||
val comment = tx.parsedMetadata()?.displayComment() ?: tx.displayDescription()
|
||||
val body = comment ?: title
|
||||
|
||||
val accountNpub = NotificationRoutes.accountNpub(account)
|
||||
|
||||
+27
-20
@@ -49,22 +49,27 @@ import java.util.concurrent.ConcurrentHashMap
|
||||
* - and at most one retry (an application interceptor's second `chain.proceed`
|
||||
* runs the downstream chain again, it does not re-enter this interceptor).
|
||||
*
|
||||
* [authHeaderProvider] is `(host, sha256) -> header?`. It is synchronous by
|
||||
* contract (the caller bridges the suspend signer), returns `null` when no
|
||||
* signer is available or signing times out, and is only consulted on a real
|
||||
* `401`, so an unauthenticated user simply keeps seeing the broken image
|
||||
* rather than paying any signing cost.
|
||||
* This interceptor never signs and never waits. [cachedHeaderProvider] is a
|
||||
* pure cache read and [onAuthRequired] is fire-and-forget: `intercept` runs on
|
||||
* an OkHttp dispatcher thread, where blocking would hold one of the 16 per-host
|
||||
* slots for the whole signing window and stall every other image from that
|
||||
* host. The signed *retry* therefore lives one layer up, in
|
||||
* `BlossomReadAuthFetcher`, which is `suspend` and can await the signature
|
||||
* without occupying a slot.
|
||||
*
|
||||
* The first blob from an auth-gated host costs an extra round trip (anonymous
|
||||
* `GET` → `401` → signed retry), but that host is then remembered in
|
||||
* [knownAuthHosts] so every later blob from it is signed **up front** — one
|
||||
* round trip, not two. This matters on a Buzz community feed where nearly every
|
||||
* image comes from the same gated host: without it each image would keep paying
|
||||
* the wasted 401 probe. The learned host also short-circuits to anonymous when
|
||||
* no signer is available, so a logged-out user never re-probes needlessly.
|
||||
* The first blob from an auth-gated host still costs an extra round trip
|
||||
* (anonymous `GET` -> `401` -> signed retry by the fetcher), but the host is
|
||||
* then remembered in [knownAuthHosts] so every later blob from it is signed
|
||||
* **up front** from the cache — one round trip, not two. This matters on a Buzz
|
||||
* community feed where nearly every image comes from the same gated host.
|
||||
* Callers that cannot retry (e.g. the media3 video datasource) get the token on
|
||||
* their next request, once [onAuthRequired] has landed it in the cache.
|
||||
*/
|
||||
class BlossomReadAuthInterceptor(
|
||||
private val authHeaderProvider: (host: String, sha256: HexKey) -> String?,
|
||||
/** Pure cache read — must not sign, must not block. */
|
||||
private val cachedHeaderProvider: (host: String) -> String?,
|
||||
/** Fire-and-forget: starts a signature for a host we just learned is gated. */
|
||||
private val onAuthRequired: (host: String) -> Unit,
|
||||
) : Interceptor {
|
||||
// Hosts observed to answer 401 to an anonymous Blossom GET. Small (a user
|
||||
// follows a handful of auth-gated servers at most) and shared across all
|
||||
@@ -81,14 +86,16 @@ class BlossomReadAuthInterceptor(
|
||||
return chain.proceed(request)
|
||||
}
|
||||
|
||||
val sha256 = blossomHashOrNull(request.url.encodedPath) ?: return chain.proceed(request)
|
||||
// The hash is a gate, not an input: read-auth applies only to Blossom
|
||||
// blob URLs. The token itself is host-scoped and carries no `x` tag.
|
||||
blossomHashOrNull(request.url.encodedPath) ?: return chain.proceed(request)
|
||||
val host = request.url.host
|
||||
|
||||
// Known-gated host: skip the anonymous probe and sign the first attempt.
|
||||
// Falls through to anonymous only when we can't produce a token (no
|
||||
// signer / timeout) — the server would 401 either way.
|
||||
if (host in knownAuthHosts) {
|
||||
authHeaderProvider(host, sha256)?.let { header ->
|
||||
cachedHeaderProvider(host)?.let { header ->
|
||||
return chain.proceed(request.withAuth(header))
|
||||
}
|
||||
}
|
||||
@@ -99,12 +106,12 @@ class BlossomReadAuthInterceptor(
|
||||
// Learn the host so its next blob is signed up front.
|
||||
knownAuthHosts.add(host)
|
||||
|
||||
val header = authHeaderProvider(host, sha256) ?: return response
|
||||
// Start the signature but do not wait for it: this thread holds a
|
||||
// per-host dispatcher slot. BlossomReadAuthFetcher performs the signed
|
||||
// retry for this very request from a coroutine.
|
||||
onAuthRequired(host)
|
||||
|
||||
// Close the 401 body before replaying so the connection can be reused.
|
||||
response.close()
|
||||
|
||||
return chain.proceed(request.withAuth(header))
|
||||
return response
|
||||
}
|
||||
|
||||
private fun Request.withAuth(header: String) =
|
||||
|
||||
+114
-33
@@ -21,29 +21,49 @@
|
||||
package com.vitorpamplona.amethyst.service.okhttp
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.service.upload.BlossomAuth
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
/**
|
||||
* Signs and caches BUD-01 read-auth headers for [BlossomReadAuthInterceptor].
|
||||
* Signs and caches BUD-01 read-auth headers for auth-gated Blossom hosts.
|
||||
*
|
||||
* The interceptor is synchronous (it runs on an OkHttp dispatcher thread) but
|
||||
* signing is `suspend`, so [authHeader] bridges with [runBlocking] guarded by a
|
||||
* timeout: an internal key signs instantly, while a remote (NIP-46) or external
|
||||
* (NIP-55) signer that hangs or needs user interaction simply yields `null` and
|
||||
* the download stays unauthenticated instead of pinning the thread.
|
||||
* Signing never blocks a caller's thread. It runs on [scope]; callers either
|
||||
* `suspend` on [header] or fire [warm] and pick the token up later. That
|
||||
* matters because the consumer used to be [BlossomReadAuthInterceptor], which
|
||||
* runs on an OkHttp dispatcher thread — bridging the suspend signer with
|
||||
* `runBlocking` there held one of the 16 per-host dispatcher slots for as long
|
||||
* as the signer took (up to the timeout), so a feed's first burst against a
|
||||
* gated host could occupy every slot and stall every other image from it.
|
||||
*
|
||||
* Tokens are cached per host, not per blob. A BUD-11 `server`-scoped token
|
||||
* grants reads for every blob on the host (thumbnails included), so one signed
|
||||
* event covers a whole feed's worth of images from an auth-gated host for the
|
||||
* life of the token. The blob hash of the request that first triggered signing
|
||||
* is still included as the `x` tag for BUD-01 servers that check it.
|
||||
* One signature per host, however many callers. The token cache alone couldn't
|
||||
* provide that: it is only populated *after* a signature returns, so a cold
|
||||
* burst of N images all missed it and all signed concurrently — with a NIP-55
|
||||
* external signer that meant N IPC round trips (and potentially N prompts).
|
||||
* [inFlight] is what collapses them; the leader signs and every follower awaits
|
||||
* the same [CompletableDeferred].
|
||||
*
|
||||
* Tokens are cached per host, not per blob, and are therefore minted with a
|
||||
* BUD-11 `server` tag and **no** `x` tag. BUD-11 lists `x` as optional for
|
||||
* `GET /<sha256>` but is strict about what including one means: "When `x` tags
|
||||
* are present, the token is only valid for operations on the specified blob
|
||||
* hashes." A token carrying the hash of whichever blob happened to trigger
|
||||
* signing would therefore be invalid for every other blob it was reused for.
|
||||
* Server-scoped and hash-free, one signed event legitimately covers a whole
|
||||
* feed's worth of images from the host for the life of the token.
|
||||
*
|
||||
* The tradeoff that buys: the token authorizes reading any blob on that host
|
||||
* until it expires, rather than one. It is only ever sent to that host, over
|
||||
* TLS, and BUD-11 sanctions the shape — but it is a wider grant than a
|
||||
* per-blob token, which is the price of caching at all.
|
||||
*/
|
||||
class BlossomReadAuthTokenProvider(
|
||||
private val signerProvider: () -> NostrSigner?,
|
||||
private val scope: CoroutineScope,
|
||||
private val clock: () -> Long = { System.currentTimeMillis() },
|
||||
) {
|
||||
private class CachedToken(
|
||||
@@ -52,31 +72,91 @@ class BlossomReadAuthTokenProvider(
|
||||
)
|
||||
|
||||
private val cache = ConcurrentHashMap<String, CachedToken>()
|
||||
private val inFlight = ConcurrentHashMap<String, CompletableDeferred<String?>>()
|
||||
|
||||
fun authHeader(
|
||||
host: String,
|
||||
sha256: HexKey,
|
||||
): String? {
|
||||
val now = clock()
|
||||
/**
|
||||
* The token already held for [host], or null. Pure map read — safe to call
|
||||
* from an OkHttp interceptor, and never signs.
|
||||
*/
|
||||
fun cachedHeader(host: String): String? = cache[host]?.takeIf { it.expiresAtMs > clock() }?.header
|
||||
|
||||
cache[host]?.let { if (it.expiresAtMs > now) return it.header }
|
||||
/**
|
||||
* The token for [host], signing one if none is cached. Suspends rather than
|
||||
* blocking, so the caller must already be in a coroutine — on the image path
|
||||
* that is Coil's `Fetcher.fetch()`.
|
||||
*/
|
||||
suspend fun header(host: String): String? {
|
||||
cachedHeader(host)?.let { return it }
|
||||
return signOnce(host)?.await()
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a signature for [host] without waiting for it. For callers that
|
||||
* cannot suspend (the interceptor) and only need the token to exist by the
|
||||
* time some later request needs it.
|
||||
*/
|
||||
fun warm(host: String) {
|
||||
if (cachedHeader(host) != null) return
|
||||
signOnce(host)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the in-flight signature for [host], starting one if this caller
|
||||
* wins the race. Null when there is no signer to sign with.
|
||||
*
|
||||
* Leader/follower over [ConcurrentHashMap.putIfAbsent] rather than
|
||||
* `computeIfAbsent`: the completion handler removes the map entry, and a job
|
||||
* that finishes immediately would run that removal *inside* the mapping
|
||||
* function, which `ConcurrentHashMap` forbids.
|
||||
*/
|
||||
private fun signOnce(host: String): CompletableDeferred<String?>? {
|
||||
inFlight[host]?.let { return it }
|
||||
|
||||
val signer = signerProvider() ?: return null
|
||||
|
||||
val header =
|
||||
runBlocking {
|
||||
withTimeoutOrNull(SIGN_TIMEOUT_MS) {
|
||||
BlossomAuth.createGetAuth(
|
||||
hash = sha256,
|
||||
alt = "Downloading media from $host",
|
||||
signer = signer,
|
||||
servers = listOf(host),
|
||||
)
|
||||
}
|
||||
} ?: return null
|
||||
val fresh = CompletableDeferred<String?>()
|
||||
inFlight.putIfAbsent(host, fresh)?.let { return it }
|
||||
|
||||
cache[host] = CachedToken(header, now + CACHE_TTL_MS)
|
||||
return header
|
||||
scope
|
||||
.launch {
|
||||
val header =
|
||||
try {
|
||||
withTimeoutOrNull(SIGN_TIMEOUT_MS) {
|
||||
BlossomAuth.createGetAuth(
|
||||
// No `x` tag: this token is reused for every blob
|
||||
// on the host. See the class kdoc.
|
||||
hash = null,
|
||||
alt = "Downloading media from $host",
|
||||
signer = signer,
|
||||
servers = listOf(host),
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
null
|
||||
}
|
||||
|
||||
if (header != null) {
|
||||
cache[host] = CachedToken(header, clock() + CACHE_TTL_MS)
|
||||
}
|
||||
|
||||
// Retire the entry *before* completing it. `invokeOnCompletion` fires when the
|
||||
// job ends, which is after `complete()` resumes the awaiting caller — so a
|
||||
// caller that returned from `header()` could come straight back, find this
|
||||
// finished deferred still in the map, and be handed its already-signed token
|
||||
// instead of signing a new one. A caller whose token has just expired does
|
||||
// exactly that, and got the expired token back for as long as the window
|
||||
// lasted — [refreshesAfterExpiry] closes it immediately and so hit it every run.
|
||||
inFlight.remove(host, fresh)
|
||||
fresh.complete(header)
|
||||
}.invokeOnCompletion {
|
||||
inFlight.remove(host, fresh)
|
||||
// No-op when the job completed normally; releases followers when
|
||||
// it was cancelled (scope torn down) instead of hanging them.
|
||||
fresh.complete(null)
|
||||
}
|
||||
|
||||
return fresh
|
||||
}
|
||||
|
||||
companion object {
|
||||
@@ -84,7 +164,8 @@ class BlossomReadAuthTokenProvider(
|
||||
// refresh a little early to avoid handing over a token that dies mid-flight.
|
||||
private const val CACHE_TTL_MS = 55L * 60L * 1000L
|
||||
|
||||
// Bounds how long an image download may block waiting on a slow signer.
|
||||
// Bounds how long an image may wait on a slow signer. No thread is held
|
||||
// for this window any more — only the waiting coroutine.
|
||||
private const val SIGN_TIMEOUT_MS = 8_000L
|
||||
}
|
||||
}
|
||||
|
||||
+29
-2
@@ -28,6 +28,7 @@ import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -47,10 +48,14 @@ import androidx.media3.ui.compose.SURFACE_TYPE_TEXTURE_VIEW
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.controls.BottomGradientOverlay
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.controls.FullscreenSwipeControlsState
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.controls.FullscreenSwipeLevelIndicator
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.controls.LogVideoQualitySelection
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.controls.METERED_MAX_SHORT_SIDE_PX
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.controls.RenderAnimatedBottomInfo
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.controls.RenderCenterButtons
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.controls.RenderTopButtons
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.controls.TopGradientOverlay
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.controls.applyViewportConstraint
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.controls.clampViewportShortSide
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.controls.fullscreenSwipeControls
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.LoadedMediaItem
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.isHlsMedia
|
||||
@@ -105,6 +110,15 @@ fun RenderVideoPlayer(
|
||||
// unnecessary recomposition of the whole player tree just to update a value that is only
|
||||
// ever read inside the onDoubleTap callback below.
|
||||
val containerWidth = remember { intArrayOf(0) }
|
||||
|
||||
// Last measured player size, kept out of snapshot state for the same reason as containerWidth:
|
||||
// it exists only so a connectivity flip can re-push the viewport without a layout pass.
|
||||
val lastMeasured = remember { intArrayOf(0, 0) }
|
||||
val isMetered by accountViewModel.settings.isMobileOrMeteredConnection.collectAsStateWithLifecycle()
|
||||
// Fullscreen is exempt: the cap exists to hold back feeds that autoplay without being asked,
|
||||
// and someone who tapped into fullscreen on mobile data asked. Capping there would also put a
|
||||
// ceiling the quality menu's "Auto" could not exceed.
|
||||
val viewportCeiling = if (isMetered && !isFullscreen) METERED_MAX_SHORT_SIDE_PX else 0
|
||||
val isLive = remember(mediaItem.src.videoUri, mediaItem.src.mimeType) { isHlsMedia(mediaItem.src.videoUri, mediaItem.src.mimeType) }
|
||||
|
||||
val swipeState = remember { FullscreenSwipeControlsState() }
|
||||
@@ -131,6 +145,14 @@ fun RenderVideoPlayer(
|
||||
}
|
||||
|
||||
WatchPlaybackErrors(controllerState)
|
||||
LogVideoQualitySelection(controllerState.controller)
|
||||
|
||||
// Moving on or off mobile data does not relayout, so the new ceiling has to be pushed by hand.
|
||||
// Before the first measurement lastMeasured is still zero and applyViewportConstraint no-ops.
|
||||
LaunchedEffect(viewportCeiling, controllerState.controller) {
|
||||
val (w, h) = clampViewportShortSide(lastMeasured[0], lastMeasured[1], viewportCeiling)
|
||||
applyViewportConstraint(controllerState.controller, w, h)
|
||||
}
|
||||
|
||||
// Audio files have no video dimensions, so without this the player collapses to a thin strip and
|
||||
// the controls get crammed. Size it square (capped) so the visualizer and controls get room.
|
||||
@@ -148,8 +170,13 @@ fun RenderVideoPlayer(
|
||||
Box(
|
||||
modifier =
|
||||
playerModifier
|
||||
.onSizeChanged { containerWidth[0] = it.width }
|
||||
.pointerInput(isLive, controllerState) {
|
||||
.onSizeChanged {
|
||||
containerWidth[0] = it.width
|
||||
lastMeasured[0] = it.width
|
||||
lastMeasured[1] = it.height
|
||||
val (w, h) = clampViewportShortSide(it.width, it.height, viewportCeiling)
|
||||
applyViewportConstraint(controllerState.controller, w, h)
|
||||
}.pointerInput(isLive, controllerState) {
|
||||
detectTapGestures(
|
||||
onTap = { controllerVisible.value = !controllerVisible.value },
|
||||
onDoubleTap = { offset ->
|
||||
|
||||
-6
@@ -26,8 +26,6 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.controls.ApplyInitialVideoQuality
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.controls.VideoQualityPolicy
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.mainVideo.VideoPlayerActiveMutex
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.GetMediaItem
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
@@ -90,10 +88,6 @@ fun VideoViewInner(
|
||||
mediaItem = mediaItem,
|
||||
muted = muted,
|
||||
) { controller ->
|
||||
ApplyInitialVideoQuality(
|
||||
player = controller.controller,
|
||||
policy = if (isFullscreen) VideoQualityPolicy.AUTO else VideoQualityPolicy.LOWEST,
|
||||
)
|
||||
VideoPlayerActiveMutex(controller) { videoModifier, isClosestToTheCenterOfTheScreen ->
|
||||
ControlWhenPlayerIsActive(controller, automaticallyStartPlayback, isClosestToTheCenterOfTheScreen)
|
||||
RenderVideoPlayer(
|
||||
|
||||
-135
@@ -1,135 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.playback.composable.controls
|
||||
|
||||
import androidx.annotation.MainThread
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.Tracks
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
|
||||
/**
|
||||
* Which HLS rendition to pick automatically the first time tracks become available for a media
|
||||
* item. Expressed as an explicit policy instead of a `Boolean` so call sites must commit to one
|
||||
* value at construction and any future dynamic-state caller is forced to key the effect on it.
|
||||
*/
|
||||
enum class VideoQualityPolicy {
|
||||
/** Lock to the lowest-resolution rendition (feed and PiP: save bandwidth). */
|
||||
LOWEST,
|
||||
|
||||
/** Clear any video override so the player uses adaptive bitrate (fullscreen). */
|
||||
AUTO,
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a default video quality when tracks become available on the given player.
|
||||
*
|
||||
* The initial selection is applied once per media item. If the user later changes the quality
|
||||
* manually, or swaps to a different media item, the new choice wins — we don't reapply for the
|
||||
* same media id. Selections intentionally don't persist across composable lifecycles, so opening
|
||||
* a feed video in fullscreen starts with [VideoQualityPolicy.AUTO] and returning to the feed
|
||||
* starts with [VideoQualityPolicy.LOWEST] again.
|
||||
*/
|
||||
@Composable
|
||||
fun ApplyInitialVideoQuality(
|
||||
player: Player,
|
||||
policy: VideoQualityPolicy,
|
||||
) {
|
||||
// Tracks the media id we've already initialized so we don't fight user overrides after the
|
||||
// first application.
|
||||
val appliedForMediaId = remember(player) { mutableStateOf<String?>(null) }
|
||||
|
||||
DisposableEffect(player, policy) {
|
||||
// Re-arm the guard whenever the player or the policy changes so a new policy gets a
|
||||
// chance to apply even if the same media id has already been handled under the old one.
|
||||
appliedForMediaId.value = null
|
||||
|
||||
val listener =
|
||||
object : Player.Listener {
|
||||
override fun onTracksChanged(tracks: Tracks) {
|
||||
applyInitialQuality(player, tracks, policy, appliedForMediaId)
|
||||
}
|
||||
}
|
||||
|
||||
// Tracks might already be available by the time we attach the listener.
|
||||
applyInitialQuality(player, player.currentTracks, policy, appliedForMediaId)
|
||||
player.addListener(listener)
|
||||
onDispose { player.removeListener(listener) }
|
||||
}
|
||||
}
|
||||
|
||||
// Invoked from Player.Listener callbacks and DisposableEffect bodies, both of which run on
|
||||
// the player's application looper (main thread for ExoPlayer). The body mutates Compose state
|
||||
// and trackSelectionParameters; both are main-thread-only.
|
||||
@MainThread
|
||||
private fun applyInitialQuality(
|
||||
player: Player,
|
||||
tracks: Tracks,
|
||||
policy: VideoQualityPolicy,
|
||||
appliedForMediaId: MutableState<String?>,
|
||||
) {
|
||||
val mediaId = player.currentMediaItem?.mediaId ?: return
|
||||
if (appliedForMediaId.value == mediaId) return
|
||||
|
||||
val videoGroup = getVideoTrackGroup(tracks) ?: return
|
||||
// No point forcing a choice when there's only one rendition, and no future update will
|
||||
// change that for this media id, so mark it as settled.
|
||||
if (videoGroup.length <= 1) {
|
||||
Log.d("VideoQuality") {
|
||||
val f = videoGroup.getTrackFormat(0)
|
||||
"policy=$policy mediaId=$mediaId SINGLE rendition ${f.width}x${f.height} (no choice)"
|
||||
}
|
||||
appliedForMediaId.value = mediaId
|
||||
return
|
||||
}
|
||||
|
||||
val renditions =
|
||||
(0 until videoGroup.length).joinToString(", ") {
|
||||
val f = videoGroup.getTrackFormat(it)
|
||||
"${f.width}x${f.height}"
|
||||
}
|
||||
|
||||
when (policy) {
|
||||
VideoQualityPolicy.AUTO -> {
|
||||
if (hasVideoOverride(player)) clearVideoOverride(player)
|
||||
Log.d("VideoQuality") {
|
||||
"policy=AUTO mediaId=$mediaId cleared override (from ${videoGroup.length} renditions: [$renditions])"
|
||||
}
|
||||
appliedForMediaId.value = mediaId
|
||||
}
|
||||
|
||||
VideoQualityPolicy.LOWEST -> {
|
||||
// If no supported track has a positive short side yet, leave the guard unset so we
|
||||
// retry on the next onTracksChanged when real video dimensions arrive.
|
||||
val lowestIndex = findLowestResolutionTrackIndex(videoGroup) ?: return
|
||||
selectVideoTrack(player, videoGroup, lowestIndex)
|
||||
Log.d("VideoQuality") {
|
||||
val f = videoGroup.getTrackFormat(lowestIndex)
|
||||
"policy=LOWEST mediaId=$mediaId selected=${f.width}x${f.height} (from ${videoGroup.length} renditions: [$renditions])"
|
||||
}
|
||||
appliedForMediaId.value = mediaId
|
||||
}
|
||||
}
|
||||
}
|
||||
+153
-17
@@ -21,34 +21,170 @@
|
||||
package com.vitorpamplona.amethyst.service.playback.composable.controls
|
||||
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.TrackSelectionOverride
|
||||
import androidx.media3.common.Tracks
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import com.vitorpamplona.amethyst.service.playback.PLAYBACK_DIAG_TAG
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import com.vitorpamplona.quartz.utils.LogLevel
|
||||
import kotlin.math.ceil
|
||||
|
||||
internal fun getVideoTrackGroup(tracks: Tracks): Tracks.Group? = tracks.groups.firstOrNull { it.type == C.TRACK_TYPE_VIDEO && it.length > 0 }
|
||||
|
||||
// Finds the track with the smallest short side (min(width, height)) in the given video group.
|
||||
// Returns null if no track has a positive short side. Used to force lowest-resolution playback
|
||||
// in feeds to save bandwidth. Skips tracks the device can't decode — ExoPlayer would silently
|
||||
// reject an override pointing at an unsupported track and fall back to adaptive selection.
|
||||
@OptIn(UnstableApi::class)
|
||||
internal fun findLowestResolutionTrackIndex(group: Tracks.Group): Int? {
|
||||
var bestIndex: Int? = null
|
||||
var bestShortSide = Int.MAX_VALUE
|
||||
for (i in 0 until group.length) {
|
||||
if (!group.isTrackSupported(i)) continue
|
||||
val format = group.getTrackFormat(i)
|
||||
val shortSide = minOf(format.width, format.height)
|
||||
if (shortSide > 0 && shortSide < bestShortSide) {
|
||||
bestShortSide = shortSide
|
||||
bestIndex = i
|
||||
}
|
||||
/**
|
||||
* Constrains adaptive selection to the area the player is actually drawn in.
|
||||
*
|
||||
* ExoPlayer already filters renditions by a viewport, but its default viewport is the **physical
|
||||
* display** (`TrackSelectionParameters.Builder.init` sets
|
||||
* `isViewportSizeLimitedByPhysicalDisplaySize`), so every player — a thumbnail in a note, a
|
||||
* full-bleed short, a PiP window — is told it has the whole screen to fill and picks whatever
|
||||
* bandwidth allows up to display resolution. Handing it the measured size instead is the whole
|
||||
* quality policy: a full-width video gets the top of the ladder, a small one gets the rung that
|
||||
* matches its pixels, and both still adapt to the connection underneath that ceiling.
|
||||
*
|
||||
* Safe by construction, which is why this uses `setViewportSize` rather than `setMaxVideoSize`:
|
||||
* DefaultTrackSelector derives its retain threshold from an actual rendition ("the smallest to
|
||||
* exceed the maximum size at which it can be displayed within the viewport") and leaves the group
|
||||
* untouched when no rendition covers the viewport, so a tiny player can never filter every track
|
||||
* away. A manual pick from the quality menu still wins — overrides are re-applied after
|
||||
* constraint-based selection runs.
|
||||
*/
|
||||
internal fun Modifier.constrainVideoQualityToViewport(
|
||||
player: Player,
|
||||
maxShortSidePx: () -> Int = { 0 },
|
||||
): Modifier =
|
||||
onSizeChanged {
|
||||
// Evaluated at measure time, not at composition: a caller whose window is still resizing
|
||||
// (PiP) needs the answer for *this* layout pass. Always pushes something — a caller that
|
||||
// wants to distrust its own measurement caps it rather than skipping, so a pooled player
|
||||
// can never keep a viewport left over from the view that had it last.
|
||||
val (width, height) = clampViewportShortSide(it.width, it.height, maxShortSidePx())
|
||||
applyViewportConstraint(player, width, height)
|
||||
}
|
||||
return bestIndex
|
||||
|
||||
/**
|
||||
* Short side the viewport is capped to on a metered connection.
|
||||
*
|
||||
* Sizing the ladder to the player is the right default on wifi, but on mobile data it would hand a
|
||||
* full-width card most of the ladder with nothing holding it back — the app's other lever is the
|
||||
* autoplay `ConnectivityType` gate, which decides *whether* to play, not how much to pull. 480 on
|
||||
* the short side keeps a card watchable while staying near the rung the old fixed-lowest policy
|
||||
* would have picked.
|
||||
*/
|
||||
const val METERED_MAX_SHORT_SIDE_PX = 480
|
||||
|
||||
/**
|
||||
* Ceiling the PiP player uses until its window has actually shrunk.
|
||||
*
|
||||
* `processIntentForPiP` calls `enterPictureInPictureMode` from composition, so the first layout
|
||||
* pass can measure the activity at full screen. Capping rather than skipping the push means the
|
||||
* selector never sees that full-screen size, and never keeps a stale one either when PiP is never
|
||||
* entered at all — the window is a few inches wide, so nothing above this is ever wanted here.
|
||||
*/
|
||||
const val PIP_PRESHRINK_MAX_SHORT_SIDE_PX = 480
|
||||
|
||||
/**
|
||||
* Scales a measured player size down until its short side fits [maxShortSidePx], preserving aspect
|
||||
* so the viewport still describes the shape of the player and not just its area. Sizes already
|
||||
* within the cap, and a non-positive cap, pass through untouched.
|
||||
*/
|
||||
internal fun clampViewportShortSide(
|
||||
widthPx: Int,
|
||||
heightPx: Int,
|
||||
maxShortSidePx: Int,
|
||||
): Pair<Int, Int> {
|
||||
val shortSide = minOf(widthPx, heightPx)
|
||||
if (maxShortSidePx <= 0 || shortSide <= 0 || shortSide <= maxShortSidePx) return widthPx to heightPx
|
||||
|
||||
val scale = maxShortSidePx.toDouble() / shortSide
|
||||
// Round up so the cap is never undershot into a lower rung by a rounding artifact.
|
||||
return ceil(widthPx * scale).toInt() to ceil(heightPx * scale).toInt()
|
||||
}
|
||||
|
||||
// Runs from onSizeChanged on the player's application looper (main thread), which is where
|
||||
// trackSelectionParameters must be written. Writing them re-runs track selection and, for a
|
||||
// pooled controller, crosses an IPC boundary, so [needsViewportUpdate] keeps a no-op layout pass
|
||||
// from churning either.
|
||||
internal fun applyViewportConstraint(
|
||||
player: Player,
|
||||
widthPx: Int,
|
||||
heightPx: Int,
|
||||
) {
|
||||
val current = player.trackSelectionParameters
|
||||
if (!needsViewportUpdate(current.viewportWidth, current.viewportHeight, widthPx, heightPx)) return
|
||||
|
||||
Log.d(VIDEO_QUALITY_TAG) {
|
||||
"viewport ${widthPx}x$heightPx mediaId=${player.currentMediaItem?.mediaId} " +
|
||||
"(was ${current.viewportWidth}x${current.viewportHeight})"
|
||||
}
|
||||
|
||||
player.trackSelectionParameters =
|
||||
current
|
||||
.buildUpon()
|
||||
// orientationMayChange mirrors media3's own default: it keeps a landscape rendition in
|
||||
// a portrait box (a letterboxed live stream) from being judged against the short edge.
|
||||
.setViewportSize(widthPx, heightPx, true)
|
||||
.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Logcat tag for the rendition trace: every viewport push, and the rung actually selected against
|
||||
* the ladder that was on offer. Replaces the per-media-item trace the old fixed-policy selector
|
||||
* emitted — without it, "Amethyst is eating my data" is not a debuggable report.
|
||||
*
|
||||
* ```
|
||||
* adb logcat -s VideoQuality
|
||||
* ```
|
||||
*/
|
||||
const val VIDEO_QUALITY_TAG = "VideoQuality"
|
||||
|
||||
/**
|
||||
* Traces which rendition adaptive selection landed on, against the full ladder the manifest
|
||||
* offered.
|
||||
*
|
||||
* The listener is registered only when the trace can actually be emitted — debug builds set
|
||||
* `Log.minLevel = DEBUG` while benchmark/release set `ERROR` (see [PLAYBACK_DIAG_TAG]) — so the
|
||||
* release path keeps the "no listener per player" property that dropping the old selector bought.
|
||||
*/
|
||||
@Composable
|
||||
fun LogVideoQualitySelection(player: Player) {
|
||||
if (Log.minLevel > LogLevel.DEBUG) return
|
||||
|
||||
DisposableEffect(player) {
|
||||
val listener =
|
||||
object : Player.Listener {
|
||||
override fun onTracksChanged(tracks: Tracks) {
|
||||
val group = getVideoTrackGroup(tracks) ?: return
|
||||
val ladder =
|
||||
(0 until group.length).joinToString(", ") { i ->
|
||||
val f = group.getTrackFormat(i)
|
||||
"${f.width}x${f.height}${if (group.isTrackSelected(i)) "*" else ""}"
|
||||
}
|
||||
Log.d(VIDEO_QUALITY_TAG) {
|
||||
"selected(*) mediaId=${player.currentMediaItem?.mediaId} of $ladder"
|
||||
}
|
||||
}
|
||||
}
|
||||
player.addListener(listener)
|
||||
onDispose { player.removeListener(listener) }
|
||||
}
|
||||
}
|
||||
|
||||
// A zero dimension means the player has not been laid out yet — leaving the previous constraint
|
||||
// in place is better than declaring a viewport nothing can fill.
|
||||
internal fun needsViewportUpdate(
|
||||
currentWidth: Int,
|
||||
currentHeight: Int,
|
||||
newWidth: Int,
|
||||
newHeight: Int,
|
||||
): Boolean = newWidth > 0 && newHeight > 0 && (currentWidth != newWidth || currentHeight != newHeight)
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
internal fun hasVideoOverride(player: Player): Boolean = player.trackSelectionParameters.overrides.any { (key, _) -> key.type == C.TRACK_TYPE_VIDEO }
|
||||
|
||||
|
||||
-8
@@ -35,8 +35,6 @@ import androidx.compose.runtime.remember
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.DEFAULT_MUTED_SETTING
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.GetVideoController
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.controls.ApplyInitialVideoQuality
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.controls.VideoQualityPolicy
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.GetMediaItem
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemData
|
||||
|
||||
@@ -59,12 +57,6 @@ class PipVideoActivity : ComponentActivity() {
|
||||
// PiP IS the opt-in for background playback — never release on background.
|
||||
releaseOnBackgroundTimeout = false,
|
||||
) { controllerState ->
|
||||
// PiP window is small, keep bandwidth low by forcing the lowest
|
||||
// rendition. User can still manually change quality via controls.
|
||||
ApplyInitialVideoQuality(
|
||||
player = controllerState.controller,
|
||||
policy = VideoQualityPolicy.LOWEST,
|
||||
)
|
||||
RegisterBackgroundMedia(controllerState)
|
||||
RegisterControllerReceiver(controllerState)
|
||||
WatchControllerForActions(mediaItemData, controllerState)
|
||||
|
||||
+19
-1
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.playback.pip
|
||||
|
||||
import androidx.activity.compose.LocalActivity
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -42,6 +43,8 @@ import androidx.media3.ui.compose.state.rememberPlayPauseButtonState
|
||||
import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.WaveformData
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.controls.PIP_PRESHRINK_MAX_SHORT_SIDE_PX
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.controls.constrainVideoQualityToViewport
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemData
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.wavefront.Waveform
|
||||
import com.vitorpamplona.amethyst.ui.components.getActivity
|
||||
@@ -69,7 +72,22 @@ fun RenderPipVideo(
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier, contentAlignment = Alignment.Center) {
|
||||
// processIntentForPiP calls enterPictureInPictureMode from composition (PiPFromIntents), so the
|
||||
// first layout pass can measure the activity at full screen before the window shrinks. Cap that
|
||||
// measurement instead of skipping it: skipping would leave a pooled player on whatever viewport
|
||||
// its previous view pushed if PiP is never actually entered (per-app PiP off, no
|
||||
// FEATURE_PICTURE_IN_PICTURE). The shrink relayouts and pushes the real size.
|
||||
val activity = LocalActivity.current
|
||||
|
||||
Box(
|
||||
modifier.constrainVideoQualityToViewport(
|
||||
player = controller.controller,
|
||||
maxShortSidePx = {
|
||||
if (activity?.isInPictureInPictureMode == true) 0 else PIP_PRESHRINK_MAX_SHORT_SIDE_PX
|
||||
},
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
ContentFrame(
|
||||
player = controller.controller,
|
||||
keepContentOnReset = true,
|
||||
|
||||
+12
-18
@@ -20,13 +20,13 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.relayClient
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.tor.RelayClassification
|
||||
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.service.resourceusage.UsageKeys
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorServiceStatus
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -101,9 +101,7 @@ class RelayProxyClientConnector(
|
||||
// flipped relay would sit out its (now-irrelevant) backoff. We track these so such a relay can
|
||||
// skip its retry delay on the next reconnect — scoped to onlyIfChanged, so only the relays that
|
||||
// actually flipped re-dial and the rest of the pool's backoff is left untouched.
|
||||
private var lastTrustedRelays: Set<NormalizedRelayUrl>? = null
|
||||
private var lastDmRelays: Set<NormalizedRelayUrl>? = null
|
||||
private var lastMoneyOpRelays: Set<NormalizedRelayUrl>? = null
|
||||
private var lastClassification: RelayClassification? = null
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
val relayServices =
|
||||
@@ -152,7 +150,7 @@ class RelayProxyClientConnector(
|
||||
onTrigger(UsageKeys.TRIGGER_OFF)
|
||||
client.disconnect()
|
||||
}
|
||||
if (infra.torStatus is TorServiceStatus.Active) {
|
||||
if (infra.torStatus.isFullyBootstrapped) {
|
||||
Log.d("ManageRelayServices", "Connectivity off, Tor idle")
|
||||
}
|
||||
// disconnect() already cleared every relay's backoff. Forget the network
|
||||
@@ -163,7 +161,7 @@ class RelayProxyClientConnector(
|
||||
infra.connectivity is ConnectivityStatus.Active && !client.isActive() -> {
|
||||
Log.d("ManageRelayServices", "Connectivity On: Resuming Relay Services")
|
||||
|
||||
if (infra.torStatus is TorServiceStatus.Active) {
|
||||
if (infra.torStatus.isFullyBootstrapped) {
|
||||
Log.d("ManageRelayServices", "Connectivity resumed, Tor active")
|
||||
}
|
||||
|
||||
@@ -174,9 +172,7 @@ class RelayProxyClientConnector(
|
||||
lastTorSettings = torSettings
|
||||
lastTorConnection = infra.torConnection
|
||||
lastClearConnection = infra.clearConnection
|
||||
lastTrustedRelays = infra.evaluator.trustedRelayList
|
||||
lastDmRelays = infra.evaluator.dmRelayList
|
||||
lastMoneyOpRelays = infra.evaluator.moneyOpRelayList
|
||||
lastClassification = infra.evaluator.classification
|
||||
}
|
||||
|
||||
else -> {
|
||||
@@ -202,13 +198,13 @@ class RelayProxyClientConnector(
|
||||
// so let onlyIfChanged pick out the flipped relay(s) and skip THEIR retry delay —
|
||||
// without resetBackoff(), so the rest of the pool's backoff is untouched (these sets
|
||||
// churn while relay lists load, and forgiving the whole pool then would be too much).
|
||||
//
|
||||
// One comparison over the whole classification, not one per category: this used to
|
||||
// be a four-way `||` and adding a category meant remembering to extend it. Missing
|
||||
// a term fails silently — the affected relays keep a socket on a transport the
|
||||
// policy has already moved them off.
|
||||
val classificationChanged =
|
||||
lastTrustedRelays != null &&
|
||||
(
|
||||
infra.evaluator.trustedRelayList != lastTrustedRelays ||
|
||||
infra.evaluator.dmRelayList != lastDmRelays ||
|
||||
infra.evaluator.moneyOpRelayList != lastMoneyOpRelays
|
||||
)
|
||||
lastClassification != null && infra.evaluator.classification != lastClassification
|
||||
|
||||
val previousNetworkId = lastNetworkId
|
||||
|
||||
@@ -216,9 +212,7 @@ class RelayProxyClientConnector(
|
||||
lastClearConnection = infra.clearConnection
|
||||
lastNetworkId = networkId ?: lastNetworkId
|
||||
lastTorSettings = torSettings
|
||||
lastTrustedRelays = infra.evaluator.trustedRelayList
|
||||
lastDmRelays = infra.evaluator.dmRelayList
|
||||
lastMoneyOpRelays = infra.evaluator.moneyOpRelayList
|
||||
lastClassification = infra.evaluator.classification
|
||||
|
||||
if (networkChanged) {
|
||||
Log.d("ManageRelayServices") {
|
||||
|
||||
+2
-2
@@ -265,8 +265,8 @@ private fun RelayHeader(
|
||||
horizontalArrangement = Arrangement.spacedBy(11.dp),
|
||||
) {
|
||||
RobohashFallbackAsyncImage(
|
||||
robot = info?.id ?: prompt.relayUrl.displayUrl(),
|
||||
model = info?.icon,
|
||||
robot = info.id ?: prompt.relayUrl.displayUrl(),
|
||||
model = info.icon,
|
||||
contentDescription = null,
|
||||
colorFilter = RelayIconFilter,
|
||||
modifier = Modifier.size(34.dp).clip(MaterialTheme.shapes.small),
|
||||
|
||||
+2
-2
@@ -140,13 +140,13 @@ object RelayAuthPurposeDeriver {
|
||||
// `e` tags — take whichever we get so the prompt can say whose conversation.
|
||||
AuthPurposeKind.THREAD -> {
|
||||
readsThread = true
|
||||
explained?.entityIds?.let(readThreadNotes::addAll)
|
||||
explained.entityIds?.let(readThreadNotes::addAll)
|
||||
filter.tags?.get("e")?.let(readThreadNotes::addAll)
|
||||
}
|
||||
// Declared, but the *who*/*what* still comes from the filter. Prefer the entity
|
||||
// ids the assembler named over sniffing tags, and fall back when it named none.
|
||||
AuthPurposeKind.READ_VENUE -> {
|
||||
val declared = explained?.entityIds.orEmpty()
|
||||
val declared = explained.entityIds.orEmpty()
|
||||
if (declared.isNotEmpty()) readVenues.addAll(declared) else readVenues.addAll(filter.venueTags())
|
||||
}
|
||||
AuthPurposeKind.READ_OUTBOX -> {
|
||||
|
||||
+16
-16
@@ -222,17 +222,16 @@ class BootRelayDiagnostics(
|
||||
r.closed.forEach { (k, v) -> closedTotals[k] = (closedTotals[k] ?: 0) + v.get() }
|
||||
}
|
||||
|
||||
Log.d(TAG, "===== boot census @${atSeconds}s =====")
|
||||
Log.i(
|
||||
TAG,
|
||||
Log.d(TAG) { "===== boot census @${atSeconds}s =====" }
|
||||
Log.i(TAG) {
|
||||
"census @${atSeconds}s 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.i(TAG, "census @${atSeconds}s failures_by_cause=" + causeTotals.entries.sortedByDescending { it.value }.joinToString { "${it.key}:${it.value}" })
|
||||
Log.i(TAG, "census @${atSeconds}s closed_by_prefix=" + closedTotals.entries.sortedByDescending { it.value }.joinToString { "${it.key}:${it.value}" })
|
||||
"auths=${snapshot.values.sumOf { it.authsSent.get() }}"
|
||||
}
|
||||
Log.i(TAG) { "census @${atSeconds}s failures_by_cause=${causeTotals.byCountDesc()}" }
|
||||
Log.i(TAG) { "census @${atSeconds}s closed_by_prefix=${closedTotals.byCountDesc()}" }
|
||||
|
||||
// Relays that cost us dials and gave nothing back, worst first: the wasted-effort list.
|
||||
Log.d(TAG, "--- top wasted dials (no events received) ---")
|
||||
@@ -242,13 +241,12 @@ class BootRelayDiagnostics(
|
||||
.sortedByDescending { it.value.tentatives.get() }
|
||||
.take(25)
|
||||
.forEach { (url, r) ->
|
||||
Log.d(
|
||||
TAG,
|
||||
Log.d(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()}",
|
||||
)
|
||||
"reqs=${r.reqsSent.get()} eose=${r.eoses.get()}"
|
||||
}
|
||||
}
|
||||
|
||||
// The relays actually carrying the boot, so a suppression change can be checked for
|
||||
@@ -258,12 +256,14 @@ class BootRelayDiagnostics(
|
||||
.sortedByDescending { it.value.events.get() }
|
||||
.take(20)
|
||||
.forEach { (url, r) ->
|
||||
Log.d(
|
||||
TAG,
|
||||
Log.d(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()}",
|
||||
)
|
||||
"openMs=${r.firstOpenAtMs.get()} eoseMs=${r.firstEoseAtMs.get()} dials=${r.tentatives.get()}"
|
||||
}
|
||||
}
|
||||
Log.d(TAG, "===== end census @${atSeconds}s =====")
|
||||
Log.d(TAG) { "===== end census @${atSeconds}s =====" }
|
||||
}
|
||||
}
|
||||
|
||||
/** Buckets rendered highest-count first — the shape both census summary lines want. */
|
||||
private fun Map<String, Int>.byCountDesc() = entries.sortedByDescending { it.value }.joinToString { "${it.key}:${it.value}" }
|
||||
|
||||
+12
@@ -44,6 +44,10 @@ import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestUpdateEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.reply.GitReplyEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusAppliedEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusClosedEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusDraftEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusOpenEvent
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
|
||||
@@ -111,6 +115,14 @@ val NotificationsPerKeyKinds2 =
|
||||
GitPatchEvent.KIND,
|
||||
GitPullRequestEvent.KIND,
|
||||
GitPullRequestUpdateEvent.KIND,
|
||||
// NIP-34 status events (1630/1631/1632/1633): opened, applied/merged,
|
||||
// closed, drafted. The status author p-tags every prior participant of
|
||||
// the target patch/PR/issue, so a `#p`=me subscription surfaces
|
||||
// "someone merged/closed a thread I'm on" without a repo-scoped query.
|
||||
GitStatusOpenEvent.KIND,
|
||||
GitStatusAppliedEvent.KIND,
|
||||
GitStatusClosedEvent.KIND,
|
||||
GitStatusDraftEvent.KIND,
|
||||
HighlightEvent.KIND,
|
||||
CommentEvent.KIND,
|
||||
CalendarDateSlotEvent.KIND,
|
||||
|
||||
+3
-2
@@ -153,8 +153,9 @@ object BlossomPaymentHandler {
|
||||
|
||||
val check = checkAmount(payment, shownSats)
|
||||
if (check !is AmountCheck.Ok) {
|
||||
Log.w("BlossomPayment", "refusing invoice: ${refusalReason(check)}")
|
||||
return PayResult.Refused(refusalReason(check))
|
||||
val reason = refusalReason(check)
|
||||
Log.w("BlossomPayment") { "refusing invoice: $reason" }
|
||||
return PayResult.Refused(reason)
|
||||
}
|
||||
|
||||
// Never send the same invoice twice: an earlier attempt may still settle.
|
||||
|
||||
@@ -30,7 +30,6 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
@@ -81,6 +80,7 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
|
||||
import com.vitorpamplona.amethyst.ui.components.BechLink
|
||||
import com.vitorpamplona.amethyst.ui.components.LoadUrlPreview
|
||||
import com.vitorpamplona.amethyst.ui.components.OutlinedThinPaddingTextField
|
||||
import com.vitorpamplona.amethyst.ui.insets.imePaddingSafe
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar
|
||||
import com.vitorpamplona.amethyst.ui.note.NoteCompose
|
||||
@@ -175,7 +175,7 @@ fun EditPostView(
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.imePadding()
|
||||
.imePaddingSafe()
|
||||
.weight(1f),
|
||||
) {
|
||||
Row(
|
||||
|
||||
@@ -24,6 +24,7 @@ import android.content.ContentResolver
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.media.MediaScannerConnection
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.provider.MediaStore
|
||||
@@ -57,10 +58,11 @@ object MediaSaverToDisk {
|
||||
resolveBlossom: suspend (String) -> String? = { null },
|
||||
onSuccess: () -> Any?,
|
||||
onError: (Throwable) -> Any?,
|
||||
) = withContext(Dispatchers.IO) {
|
||||
) {
|
||||
// No dispatch here: save() and downloadAndSave() both move themselves to IO.
|
||||
when {
|
||||
videoUri.isNullOrBlank() -> {
|
||||
return@withContext
|
||||
return
|
||||
}
|
||||
|
||||
videoUri.startsWith("file") -> {
|
||||
@@ -131,18 +133,25 @@ object MediaSaverToDisk {
|
||||
}
|
||||
|
||||
val trimmedUrl = trimInlineMetaData(downloadUrl)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val headerType =
|
||||
response
|
||||
.header("Content-Type")
|
||||
?.substringBefore(";")
|
||||
?.trim()
|
||||
val headerType =
|
||||
response
|
||||
.header("Content-Type")
|
||||
?.substringBefore(";")
|
||||
?.trim()
|
||||
|
||||
val realType =
|
||||
headerType?.takeIf(::isSaveableMimeType)
|
||||
?: mimeType?.takeIf(::isSaveableMimeType)
|
||||
?: getMimeTypeFromExtension(trimmedUrl).takeIf(::isSaveableMimeType)
|
||||
?: ""
|
||||
// Resolved for both paths: the API level decides how the file is
|
||||
// written, never which directory it belongs in.
|
||||
val realType =
|
||||
headerType?.takeIf(::isSaveableMimeType)
|
||||
?: mimeType?.takeIf(::isSaveableMimeType)
|
||||
?: getMimeTypeFromExtension(trimmedUrl).takeIf(::isSaveableMimeType)
|
||||
?: ""
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
// Deliberately Q-only: MediaStore refuses an insert without a usable
|
||||
// type, so there is nothing to do but report it. The legacy path has
|
||||
// always written whatever it downloaded and still does - an unresolved
|
||||
// type lands in Downloads, which accepts any file.
|
||||
check(realType.isNotBlank()) { "Can't find out the content type" }
|
||||
|
||||
saveContentQ(
|
||||
@@ -154,6 +163,7 @@ object MediaSaverToDisk {
|
||||
} else {
|
||||
saveContentDefault(
|
||||
fileName = File(trimmedUrl).name,
|
||||
contentType = realType,
|
||||
contentSource = response.body.source(),
|
||||
context = context,
|
||||
)
|
||||
@@ -176,44 +186,53 @@ object MediaSaverToDisk {
|
||||
private fun isSaveableMimeType(type: String): Boolean =
|
||||
type.isNotBlank() &&
|
||||
(
|
||||
type.startsWith("image/", ignoreCase = true) ||
|
||||
type.startsWith("video/", ignoreCase = true) ||
|
||||
type.startsWith("audio/", ignoreCase = true) ||
|
||||
MediaStoreTarget.of(type) != MediaStoreTarget.DOWNLOADS ||
|
||||
type.equals(PDF_MIME_TYPE, ignoreCase = true)
|
||||
)
|
||||
|
||||
/**
|
||||
* Copies a local file into the gallery. Suspending and dispatched to IO like
|
||||
* [downloadAndSave]: callers reach this from click handlers, and a
|
||||
* storage-permission callback among them launches on the main dispatcher,
|
||||
* where copying a whole video would block the UI thread.
|
||||
*/
|
||||
@OptIn(ExperimentalUuidApi::class)
|
||||
fun save(
|
||||
suspend fun save(
|
||||
localFile: File,
|
||||
mimeType: String?,
|
||||
context: Context,
|
||||
onSuccess: () -> Any?,
|
||||
onError: (Throwable) -> Any?,
|
||||
) {
|
||||
try {
|
||||
val extension =
|
||||
mimeType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
|
||||
val buffer = localFile.inputStream().source().buffer()
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
saveContentQ(
|
||||
displayName = Uuid.random().toString(),
|
||||
contentType = mimeType ?: "",
|
||||
contentSource = buffer,
|
||||
contentResolver = context.contentResolver,
|
||||
)
|
||||
} else {
|
||||
saveContentDefault(
|
||||
fileName = "${Uuid.random()}.$extension",
|
||||
contentSource = buffer,
|
||||
context = context,
|
||||
)
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
// use{}: readAll leaves its source open, so without this the file
|
||||
// descriptor stays open until the finalizer runs.
|
||||
localFile.inputStream().source().buffer().use { buffer ->
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
saveContentQ(
|
||||
displayName = Uuid.random().toString(),
|
||||
contentType = mimeType ?: "",
|
||||
contentSource = buffer,
|
||||
contentResolver = context.contentResolver,
|
||||
)
|
||||
} else {
|
||||
val extension =
|
||||
mimeType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
|
||||
saveContentDefault(
|
||||
fileName = "${Uuid.random()}.$extension",
|
||||
contentType = mimeType ?: "",
|
||||
contentSource = buffer,
|
||||
context = context,
|
||||
)
|
||||
}
|
||||
}
|
||||
onSuccess()
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.w("MediaSaverToDisk", "Unable to save", e)
|
||||
onError(e)
|
||||
}
|
||||
onSuccess()
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.w("MediaSaverToDisk", "Unable to save", e)
|
||||
onError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,29 +244,7 @@ object MediaSaverToDisk {
|
||||
contentResolver: ContentResolver,
|
||||
) {
|
||||
val cleanMimeType = normalizeMimeTypeForMediaStore(contentType.substringBefore(";").trim())
|
||||
|
||||
val (masterUri, baseDir) =
|
||||
when {
|
||||
cleanMimeType.startsWith("image/", ignoreCase = true) -> {
|
||||
MediaStore.Images.Media.EXTERNAL_CONTENT_URI to Environment.DIRECTORY_PICTURES
|
||||
}
|
||||
|
||||
cleanMimeType.startsWith("audio/", ignoreCase = true) -> {
|
||||
// Audio content goes into the Music MediaStore + folder. Routing it through
|
||||
// Video.EXTERNAL_CONTENT_URI (the previous fall-through behavior) crashes
|
||||
// with IllegalArgumentException because MediaProvider rejects audio/* into
|
||||
// the Video collection.
|
||||
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI to Environment.DIRECTORY_MUSIC
|
||||
}
|
||||
|
||||
cleanMimeType.equals(PDF_MIME_TYPE, ignoreCase = true) -> {
|
||||
MediaStore.Downloads.EXTERNAL_CONTENT_URI to Environment.DIRECTORY_DOWNLOADS
|
||||
}
|
||||
|
||||
else -> {
|
||||
MediaStore.Video.Media.EXTERNAL_CONTENT_URI to Environment.DIRECTORY_PICTURES
|
||||
}
|
||||
}
|
||||
val target = MediaStoreTarget.of(cleanMimeType)
|
||||
|
||||
val contentValues =
|
||||
ContentValues().apply {
|
||||
@@ -255,11 +252,11 @@ object MediaSaverToDisk {
|
||||
put(MediaStore.MediaColumns.MIME_TYPE, cleanMimeType)
|
||||
put(
|
||||
MediaStore.MediaColumns.RELATIVE_PATH,
|
||||
baseDir + File.separatorChar + AMETHYST_SUBDIRECTORY,
|
||||
target.relativeDirectory + File.separatorChar + AMETHYST_SUBDIRECTORY,
|
||||
)
|
||||
}
|
||||
|
||||
val uri = contentResolver.insert(masterUri, contentValues)
|
||||
val uri = contentResolver.insert(target.collectionUri(), contentValues)
|
||||
checkNotNull(uri) { "Can't insert the new content" }
|
||||
|
||||
try {
|
||||
@@ -276,12 +273,15 @@ object MediaSaverToDisk {
|
||||
|
||||
private fun saveContentDefault(
|
||||
fileName: String,
|
||||
contentType: String,
|
||||
contentSource: BufferedSource,
|
||||
context: Context,
|
||||
) {
|
||||
val baseDir = MediaStoreTarget.of(contentType).relativeDirectory
|
||||
|
||||
val subdirectory =
|
||||
File(
|
||||
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES),
|
||||
Environment.getExternalStoragePublicDirectory(baseDir),
|
||||
AMETHYST_SUBDIRECTORY,
|
||||
).apply {
|
||||
if (!exists()) mkdirs()
|
||||
@@ -307,6 +307,60 @@ object MediaSaverToDisk {
|
||||
else -> mimeType
|
||||
}
|
||||
|
||||
/**
|
||||
* The MediaStore collection a download is filed under, together with the public
|
||||
* directory it is written to.
|
||||
*
|
||||
* MediaProvider validates the primary directory of [MediaStore.MediaColumns.RELATIVE_PATH]
|
||||
* against the collection being inserted into and rejects a mismatch with
|
||||
* `IllegalArgumentException: Primary directory Pictures not allowed for
|
||||
* content://media/external/video/media; allowed directories are [DCIM, Movies]`.
|
||||
* A collection usually accepts more than one directory; these are the ones Amethyst
|
||||
* files under.
|
||||
*/
|
||||
internal enum class MediaStoreTarget(
|
||||
val relativeDirectory: String,
|
||||
) {
|
||||
// The directory names are the values of Environment.DIRECTORY_PICTURES, _MUSIC,
|
||||
// _MOVIES and _DOWNLOADS. They are spelled out because those are plain static
|
||||
// fields that the unit-test android.jar leaves null, which would make this
|
||||
// mapping impossible to cover off-device. MediaStoreTargetInstrumentedTest pins
|
||||
// them back to the platform constants on-device.
|
||||
IMAGES("Pictures"),
|
||||
AUDIO("Music"),
|
||||
VIDEO("Movies"),
|
||||
DOWNLOADS("Download"),
|
||||
;
|
||||
|
||||
/**
|
||||
* Has to stay a method. The EXTERNAL_CONTENT_URI fields are null under the same
|
||||
* unit-test android.jar, and MediaStore.Downloads only exists from API 29, so
|
||||
* reading them from the constructor would break class init off-device and below Q.
|
||||
*/
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
fun collectionUri(): Uri =
|
||||
when (this) {
|
||||
IMAGES -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI
|
||||
AUDIO -> MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
|
||||
VIDEO -> MediaStore.Video.Media.EXTERNAL_CONTENT_URI
|
||||
DOWNLOADS -> MediaStore.Downloads.EXTERNAL_CONTENT_URI
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* PDFs, and anything that isn't image, audio or video content, go to Downloads
|
||||
* — the one collection that accepts every kind of file.
|
||||
*/
|
||||
fun of(mimeType: String): MediaStoreTarget =
|
||||
when {
|
||||
mimeType.startsWith("image/", ignoreCase = true) -> IMAGES
|
||||
mimeType.startsWith("audio/", ignoreCase = true) -> AUDIO
|
||||
mimeType.startsWith("video/", ignoreCase = true) -> VIDEO
|
||||
else -> DOWNLOADS
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val AMETHYST_SUBDIRECTORY = "Amethyst"
|
||||
private const val PDF_MIME_TYPE = "application/pdf"
|
||||
private const val BLOSSOM_SCHEME = "blossom:"
|
||||
|
||||
@@ -27,7 +27,6 @@ import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
@@ -61,6 +60,7 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.ShowImageUploadGallery
|
||||
import com.vitorpamplona.amethyst.ui.components.SetDialogToEdgeToEdge
|
||||
import com.vitorpamplona.amethyst.ui.components.TextSpinner
|
||||
import com.vitorpamplona.amethyst.ui.components.TitleExplainer
|
||||
import com.vitorpamplona.amethyst.ui.insets.imePaddingSafe
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.SettingSwitchItem
|
||||
@@ -127,7 +127,7 @@ fun NewMediaView(
|
||||
Modifier
|
||||
.padding(pad)
|
||||
.consumeWindowInsets(pad)
|
||||
.imePadding(),
|
||||
.imePaddingSafe(),
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
|
||||
+2
-2
@@ -30,7 +30,6 @@ import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
@@ -56,6 +55,7 @@ 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.actions.uploads.SelectSingleFromGallery
|
||||
import com.vitorpamplona.amethyst.ui.insets.imePaddingSafe
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
@@ -108,7 +108,7 @@ fun NewUserMetadataScreen(
|
||||
top = pad.calculateTopPadding(),
|
||||
bottom = pad.calculateBottomPadding(),
|
||||
).consumeWindowInsets(pad)
|
||||
.imePadding(),
|
||||
.imePaddingSafe(),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(10.dp).verticalScroll(rememberScrollState()),
|
||||
|
||||
+2
-2
@@ -27,7 +27,6 @@ import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
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.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
@@ -56,6 +55,7 @@ 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.insets.imePaddingSafe
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
@@ -117,7 +117,7 @@ fun Bolt12OffersScaffold(
|
||||
end = 16.dp,
|
||||
bottom = padding.calculateBottomPadding(),
|
||||
).consumeWindowInsets(padding)
|
||||
.imePadding(),
|
||||
.imePaddingSafe(),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp, alignment = Alignment.Top),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
|
||||
+2
-2
@@ -28,7 +28,6 @@ import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
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
|
||||
@@ -51,6 +50,7 @@ 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.insets.imePaddingSafe
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
@@ -102,7 +102,7 @@ fun MediaServersScaffold(
|
||||
end = 16.dp,
|
||||
bottom = padding.calculateBottomPadding(),
|
||||
).consumeWindowInsets(padding)
|
||||
.imePadding(),
|
||||
.imePaddingSafe(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user